From f5008a69167cd47e0989bb9e291cbea270727683 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 28 Jun 2026 23:41:14 +0300 Subject: [PATCH 01/88] fix(github): stop PR-status requests from starving startup connection pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watching N worktrees fired N PR-status requests at once (startWatching called refresh() directly, bypassing the batch limiter). Each request can take 20s+ under GitHub secondary-rate-limiting, and N of them saturate the browser's ~6 HTTP/1.1 connections per origin, starving the critical path (bootstrap session.status, diffs, sending messages) until they finish — the UI appeared frozen for ~20s on startup. - Gate all PR-status network calls through a global concurrency semaphore (max 2), so free sockets always remain for critical traffic. - Bound resolveGitHubPrStatus with a 12s timeout so a slow request fails fast instead of holding a socket; the client keeps its last-known status. - Reuse already-fetched repo metadata for the default branch instead of a redundant repos.get, reducing serial GitHub calls (less rate-limiting). --- .../ui/src/stores/useGitHubPrStatusStore.ts | 45 ++++++++++++++++++- packages/web/server/lib/github/pr-status.js | 11 +++++ packages/web/server/lib/github/routes.js | 35 ++++++++++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 6cf8a2c5e4..9293375058 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -93,6 +93,43 @@ const timers = new Map(); const bootstrapTimers = new Map(); const inFlightBySignature = new Set(); const lastRefreshBySignature = new Map(); + +// Global concurrency gate for PR-status network requests. +// +// PR status is non-critical chrome, but each request can be slow (the server +// makes many serial GitHub API calls and GitHub secondary-rate-limits bursts, +// so a single request can take 20s+). The browser allows only ~6 concurrent +// HTTP/1.1 connections per origin. Without this cap, watching N worktrees fires +// N PR-status requests at once (each startWatching() calls refresh() directly, +// bypassing refreshTargets' batch limiter), which saturates the connection pool +// and starves the critical path (bootstrap session.status, diffs, sending +// messages) for the full duration — the whole UI appears frozen on startup. +// +// Capping concurrency low guarantees free sockets remain for critical traffic. +const PR_STATUS_NETWORK_CONCURRENCY = 2; +let prStatusNetworkActive = 0; +const prStatusNetworkWaiters: Array<() => void> = []; + +const acquirePrStatusNetworkSlot = (): Promise => { + if (prStatusNetworkActive < PR_STATUS_NETWORK_CONCURRENCY) { + prStatusNetworkActive += 1; + return Promise.resolve(); + } + return new Promise((resolve) => { + prStatusNetworkWaiters.push(resolve); + }); +}; + +const releasePrStatusNetworkSlot = (): void => { + const next = prStatusNetworkWaiters.shift(); + if (next) { + // Hand the slot directly to the next waiter — keep the active count steady. + next(); + return; + } + prStatusNetworkActive = Math.max(0, prStatusNetworkActive - 1); +}; + const createEntry = (): PrStatusEntry => ({ status: null, isLoading: false, @@ -488,7 +525,13 @@ export const useGitHubPrStatusStore = create()( activeRequestCount: prev.activeRequestCount + 1, totalRequestCount: prev.totalRequestCount + 1, })); - const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force }); + await acquirePrStatusNetworkSlot(); + let next: GitHubPullRequestStatus; + try { + next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force }); + } finally { + releasePrStatusNetworkSlot(); + } set((prev) => { const nextEntries = { ...prev.entries }; signatureKeys.forEach((signatureKey) => { diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 6374827d59..4f8e27f816 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -160,6 +160,17 @@ const getRepoDefaultBranch = async (octokit, repo) => { return cached.defaultBranch; } + // Reuse the full repo metadata if it was already fetched (expandRepoNetwork + // calls getRepoMetadata for every candidate before the default-branch loop). + // This avoids a redundant repos.get per repo — fewer serial GitHub calls means + // less exposure to secondary-rate-limiting that makes PR status slow. + const metaCached = repoMetadataCache.get(repoKey); + if (metaCached && Date.now() - metaCached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) { + const defaultBranch = normalizeText(metaCached.data?.default_branch) || null; + defaultBranchCache.set(repoKey, { defaultBranch, fetchedAt: Date.now() }); + return defaultBranch; + } + try { const response = await octokit.rest.repos.get({ owner: repo.owner, diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index ea9b975fa0..6307c26823 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -1,7 +1,26 @@ const PR_STATUS_CACHE_TTL_MS = 90_000; const PR_STATUS_CACHE_MAX_ENTRIES = 200; +// Upper bound for resolving a single PR status. resolveGitHubPrStatus makes many +// serial GitHub API calls; under GitHub secondary-rate-limiting a single request +// can otherwise hang 20s+. We bound it so the route fails fast instead of holding +// the response (and a client socket) open — the client keeps its last-known +// status on error, and a later poll fills it in. +const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000; const prStatusCache = new Map(); +function withTimeout(promise, timeoutMs, label) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + const error = new Error(`${label} timed out after ${timeoutMs}ms`); + error.code = 'ETIMEDOUT'; + reject(error); + }, timeoutMs); + if (typeof timer.unref === 'function') timer.unref(); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + function getRequestedRepo(req) { const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : ''; const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : ''; @@ -417,12 +436,16 @@ export function registerGitHubRoutes(app) { } const { resolveGitHubPrStatus } = await import('./pr-status.js'); - const resolvedStatus = await resolveGitHubPrStatus({ - octokit, - directory, - branch, - remoteName: remote, - }); + const resolvedStatus = await withTimeout( + resolveGitHubPrStatus({ + octokit, + directory, + branch, + remoteName: remote, + }), + PR_STATUS_RESOLVE_TIMEOUT_MS, + 'resolveGitHubPrStatus', + ); const searchRepo = resolvedStatus.repo; const first = resolvedStatus.pr; if (!searchRepo) { From cfc38efe72bded6403e1457c102ecdfff8d8c62d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 00:02:03 +0300 Subject: [PATCH 02/88] fix(opencode): never auto-attach to a pre-existing OpenCode instance A blind probe of the default port 4096 made the desktop hijack a user's separately-running OpenCode (e.g. the OpenCode desktop app): it attached as an external server instead of starting its own. That coupled OpenChamber's lifecycle to the foreign instance and broke initialization against an unexpected server version/config. Attaching to an external OpenCode now requires explicit opt-in via env (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START). Without that, we always start our own managed instance on a freshly-allocated port. --- packages/web/server/lib/opencode/lifecycle.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index d01a9fd387..69339b7a5d 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -819,15 +819,15 @@ export const createOpenCodeLifecycleRuntime = (deps) => { state.lastOpenCodeError = null; state.openCodeNotReadySince = 0; syncToHmrState(); - } else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) { - console.log('Auto-detected existing OpenCode server on default port 4096'); - setOpenCodePort(4096); - state.isOpenCodeReady = true; - state.isExternalOpenCode = true; - state.lastOpenCodeError = null; - state.openCodeNotReadySince = 0; - syncToHmrState(); } else { + // We never auto-attach to an arbitrary pre-existing OpenCode instance. + // Attaching to an external server requires explicit opt-in via env + // (OPENCODE_HOST / OPENCODE_PORT / OPENCODE_SKIP_START), handled by the + // branches above. Without that opt-in we always start our OWN managed + // instance on a freshly-allocated port. A blind probe of the default + // port 4096 used to hijack a user's separately-running OpenCode (e.g. + // the OpenCode desktop app), coupling our lifecycle to theirs and + // breaking init against an unexpected server version/config. if (env.ENV_EFFECTIVE_PORT) { console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`); setOpenCodePort(env.ENV_EFFECTIVE_PORT); From d7392bd08ea74c4bd0cadae01246a195d06a9e94 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 00:15:30 +0300 Subject: [PATCH 03/88] fix(opencode): never expose a port we don't manage to the process killer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Electron-side OpenCode killer kills by port (lsof + kill -KILL). getOpenCodeProcessInfo returned openCodePort unconditionally, so for an external/attached OpenCode (e.g. a user's own server on 4096) the only thing stopping the killer from taking it down was the separate `managed` flag — a single weak signal guarding a destructive action. Withhold pid/port unless we actually manage the process, so the killer has no target even if `managed` is ever miscomputed. Managed flow is unchanged. --- packages/web/server/index.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index b8bb35cc97..4f7339dc59 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1327,11 +1327,20 @@ async function main(options = {}) { }), isReady: () => isOpenCodeReady, restartOpenCode: () => restartOpenCode(), - getOpenCodeProcessInfo: () => ({ - managed: Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode), - pid: typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null, - port: openCodePort, - }), + getOpenCodeProcessInfo: () => { + const managed = Boolean((openCodeProcess || openCodePort) && !ENV_SKIP_OPENCODE_START && !isExternalOpenCode); + // Only ever expose pid/port for a server WE manage. The Electron-side + // killer kills by port (lsof + kill -KILL), so returning a port we don't + // own — e.g. an external/desktop OpenCode on 4096 we attached to — would + // let a single miscomputed `managed` flag take down the user's separate + // server. Structurally withhold what isn't ours so the killer has no + // target, instead of relying on the flag check alone. + return { + managed, + pid: managed && typeof openCodeProcess?.pid === 'number' ? openCodeProcess.pid : null, + port: managed ? openCodePort : null, + }; + }, stop: (shutdownOptions = {}) => gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }) }; From 8ad325238d2d69bb61452c5ed4477249fb8a434b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 00:18:22 +0300 Subject: [PATCH 04/88] docs(changelog): add unreleased entries for startup and OpenCode attach fixes --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a57b8b135..ae4c101895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. +- OpenCode: when a separate OpenCode is already running (the TUI, `opencode serve`, or a daemon on the default port 4096), the app now starts its own server instead of attaching to it. This fixes the "OpenChamber could not finish initialization" error and stops the app from opening or closing your separate OpenCode when it starts and quits. Connecting to an external OpenCode now requires setting `OPENCODE_HOST`, `OPENCODE_PORT`, or `OPENCODE_SKIP_START`. + ## [1.13.7] - 2026-06-28 - Chat: with tool calls (such as Bash and Edit) shown expanded by default, scrolling no longer twitches, and slow scrolling no longer jumps past several messages. From 2488a36343a37b383e52bf0a0115dc41d76af48c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 00:19:59 +0300 Subject: [PATCH 05/88] chore: updated changelog --- packages/vscode/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 61ff9f9762..2f529cb17c 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] + +- No notable changes. + ## [1.13.7] - 2026-06-28 - Chat: with tool calls (such as Bash and Edit) shown expanded by default, scrolling no longer twitches, and slow scrolling no longer jumps past several messages. From 5c92b8cecd48e45854321db8ff58f69d83847733 Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:21:54 +1100 Subject: [PATCH 06/88] fix(sidebar): preserve explicit session selection on stale worktree data (#1813) When clicking a session inside a worktree group, the layout effect in useProjectSessionSelection could override the user's selection with the project's first root session. This happened because projectSections (and thus projectSessionMeta) might not yet include the worktree group on the first render after the click. The fix adds a guard: if currentSessionId is set but not found in the current projectMap (stale data), the effect returns early instead of falling through to auto-selection logic. Fixes #1804 Co-authored-by: Leonid Skorobogatyy --- .../hooks/useProjectSessionSelection.test.ts | 262 ++++++++++++++++++ .../hooks/useProjectSessionSelection.ts | 9 + 2 files changed, 271 insertions(+) create mode 100644 packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts new file mode 100644 index 0000000000..816c956f51 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import type { SessionGroup, SessionNode } from '../types'; + +// --------------------------------------------------------------------------- +// Helper: simulate the projectSessionMeta computation from the hook +// (same visitNodes logic as useProjectSessionSelection.ts lines 46-71) +// --------------------------------------------------------------------------- + +type ProjectSection = { + project: { id: string; normalizedPath: string }; + groups: SessionGroup[]; +}; + +function computeProjectMeta(projectSections: ProjectSection[]) { + const metaByProject = new Map>(); + const firstSessionByProject = new Map(); + + 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('guard preserves currentSessionId when projectMap is stale (the bug fix)', () => { + const { metaByProject, firstSessionByProject } = computeProjectMeta(staleSections); + const projectMap = metaByProject.get('project-1')!; + const currentSessionId = 'wt-session-1'; + + // Path A fails: currentSessionId is set but not in stale projectMap + const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId)); + expect(pathAHit).toBe(false); + + // Guard: if (currentSessionId) return; + // This is what prevents the fallthrough to Path C (auto-select wrong session) + // Without the guard, Path C would select firstSessionByProject = root-session-1 + // instead of preserving the user's wt-session-1 selection + const fallback = firstSessionByProject.get('project-1')?.id ?? null; + expect(fallback).toBe('root-session-1'); + expect(fallback).not.toBe(currentSessionId); + }); + + 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: guard does NOT fire when currentSessionId matches new project', () => { + // Simulates: user clicks a session in project-2 (normal click, not worktree) + const { metaByProject } = computeProjectMeta(project2Sections); + const projectMap = metaByProject.get('project-2')!; + const currentSessionId = 'project-2-session-1'; + + // Path A succeeds — the session is in the new project's projectMap + const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId)); + expect(pathAHit).toBe(true); + + // Guard condition only fires when Path A fails — should not fire here + const guardWouldFire = Boolean(currentSessionId && !(projectMap?.has(currentSessionId))); + expect(guardWouldFire).toBe(false); + }); + + test('guard does NOT fire when currentSessionId is null (deleted/archived session)', () => { + const { metaByProject } = computeProjectMeta(staleSections); + const projectMap = metaByProject.get('project-1')!; + const currentSessionId = null; + + // Path A: currentSessionId is null → skipped + const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId)); + expect(pathAHit).toBe(false); + + // Guard: currentSessionId is null → skipped, falls through to Path B/C + const guardWouldFire = currentSessionId !== null && !pathAHit; + expect(guardWouldFire).toBe(false); + }); + + test('guard does NOT fire for empty projects — falls through to Path B (open draft)', () => { + // Empty project: no groups/sessions in projectSections + const emptySections: ProjectSection[] = [ + { + project: { id: 'empty-project', normalizedPath: '/workspace/empty' }, + groups: [], + }, + ]; + const { metaByProject } = computeProjectMeta(emptySections); + const projectMap = metaByProject.get('empty-project'); + const currentSessionId = 'some-session-id'; + + // projectMap is undefined for empty project + expect(projectMap).toBe(undefined); + + // Guard: projectMap is undefined → skipped, falls through to Path B + // which opens a new session draft for the empty project + const guardWouldFire = Boolean(currentSessionId && projectMap); + expect(guardWouldFire).toBe(false); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts index 5ffaf722e7..034eb6798f 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts @@ -117,6 +117,15 @@ export const useProjectSessionSelection = (args: Args): void => { return; } + // Path A' — currentSessionId is set but not in stale projectMap. + // Preserve user's explicit selection when the projectMap exists but + // is missing the session (worktree data not yet loaded). For + // empty projects (projectMap is undefined), fall through to Path B + // so a new session draft is opened. + if (currentSessionId && projectMap) { + return; + } + if (!projectMap || projectMap.size === 0) { setActiveMainTab('chat'); if (mobileVariant) { From e2c5da62dfeab64050c81a53ad4a5b415c2a75cc Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Sun, 28 Jun 2026 17:23:27 -0400 Subject: [PATCH 07/88] fix(sync): stop watchdog redundant resyncs on healthy event stream (#1829) The stale-event check excluded heartbeats from lastActiveEventAt, so a quiet-but-connected session (only receiving heartbeats) tripped the 20s stale timer and triggered a full resync every ~15s. This re-fetched listPendingQuestions, listPendingPermissions, session.get, and session.messages despite the event stream being healthy. Track all stream activity (including heartbeats) in a global lastStreamActivityAt ref. The stale check now only fires when no events at all arrive for 20s, meaning the stream is genuinely dead. Resyncs still fire correctly on genuine reconnects, transport switches, and status-poll escalation when a real discrepancy is detected. Fixes #1656 --- .../__tests__/session-status-snapshot.test.ts | 53 +++++++++++++++++++ packages/ui/src/sync/sync-context.tsx | 49 ++++++++++------- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts b/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts index b6b0247737..67d030c571 100644 --- a/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts +++ b/packages/ui/src/sync/__tests__/session-status-snapshot.test.ts @@ -7,6 +7,7 @@ import type { DirectoryStore } from "../child-store" import { applySessionStatusSnapshot, needsSnapshotAfterStatusPoll, + shouldTriggerStaleResync, } from "../sync-context" type StatusSnapshot = Record @@ -115,3 +116,55 @@ describe("needsSnapshotAfterStatusPoll", () => { expect(needsSnapshotAfterStatusPoll(store.getState(), "ses_a", undefined)).toBe(false) }) }) + +describe("shouldTriggerStaleResync", () => { + const STALE_MS = 20_000 + const COOLDOWN_MS = 15_000 + + test("does NOT trigger when heartbeats are recent (quiet-but-connected session)", () => { + // 5s ago a heartbeat arrived — stream is alive even though no meaningful + // events came through. This is the core fix for issue #1656. + const now = 100_000 + const lastStreamActivityAt = now - 5_000 + expect(shouldTriggerStaleResync(lastStreamActivityAt, 0, now, STALE_MS, COOLDOWN_MS)).toBe(false) + }) + + test("does NOT trigger when a non-heartbeat event is recent", () => { + const now = 100_000 + const lastStreamActivityAt = now - 3_000 + expect(shouldTriggerStaleResync(lastStreamActivityAt, 0, now, STALE_MS, COOLDOWN_MS)).toBe(false) + }) + + test("triggers when no events at all (including heartbeats) for the stale threshold", () => { + const now = 100_000 + const lastStreamActivityAt = now - STALE_MS - 1 + expect(shouldTriggerStaleResync(lastStreamActivityAt, 0, now, STALE_MS, COOLDOWN_MS)).toBe(true) + }) + + test("does NOT trigger when within the resync cooldown even if stream is stale", () => { + const now = 100_000 + const lastStreamActivityAt = now - STALE_MS - 1 + const lastFullResyncAt = now - 5_000 // only 5s ago, cooldown is 15s + expect(shouldTriggerStaleResync(lastStreamActivityAt, lastFullResyncAt, now, STALE_MS, COOLDOWN_MS)).toBe(false) + }) + + test("triggers when stream is stale AND cooldown has elapsed", () => { + const now = 100_000 + const lastStreamActivityAt = now - STALE_MS - 1 + const lastFullResyncAt = now - COOLDOWN_MS - 1 + expect(shouldTriggerStaleResync(lastStreamActivityAt, lastFullResyncAt, now, STALE_MS, COOLDOWN_MS)).toBe(true) + }) + + test("does NOT trigger when no events have been received yet (lastStreamActivityAt is 0)", () => { + // Prevents firing before the first heartbeat arrives + expect(shouldTriggerStaleResync(0, 0, 100_000, STALE_MS, COOLDOWN_MS)).toBe(false) + }) + + test("uses default thresholds when omitted", () => { + const now = 100_000 + // 25s since last activity (> 20s default), 20s since last resync (> 15s default) + expect(shouldTriggerStaleResync(now - 25_000, now - 20_000, now)).toBe(true) + // 10s since last activity (< 20s default) + expect(shouldTriggerStaleResync(now - 10_000, 0, now)).toBe(false) + }) +}) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 8c27fbaaed..9387eaf7b0 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -418,11 +418,6 @@ function toSessionStatus(status: Awaited messageSessionById: Map @@ -1575,7 +1591,7 @@ export function SyncProvider(props: { const routingIndexRef = useRef(null) if (!routingIndexRef.current) routingIndexRef.current = createEventRoutingIndex() const routingIndex = routingIndexRef.current - const lastActiveEventAtByDirectoryRef = useRef(new Map()) + const lastStreamActivityAtRef = useRef(0) const lastStatusPollAtByDirectoryRef = useRef(new Map()) const lastFullResyncAtByDirectoryRef = useRef(new Map()) const lastChildDiscoveryAtByDirectoryRef = useRef(new Map()) @@ -1759,9 +1775,13 @@ export function SyncProvider(props: { return resolveDirectoryFromRoutingIndex(routingIndex, directory, payload, childStores) }, onEvent: (directory, payload) => { - if (!isStreamHeartbeatEvent(payload)) { - lastActiveEventAtByDirectoryRef.current.set(directory, Date.now()) - } + // Track ALL stream activity (including heartbeats) as proof of + // connection health. The watchdog stale check uses this to distinguish + // a genuinely dead stream (no heartbeats for 20s) from a quiet-but- + // connected session that is only receiving heartbeats. Excluding + // heartbeats here caused issue #1656: the stale timer fired for any + // quiet session, triggering redundant full resyncs every ~15s. + lastStreamActivityAtRef.current = Date.now() dispatchVSCodeRuntimeNotificationEvent(directory, payload) if (payload.type === "installation.update-available") { const version = typeof (payload.properties as { version?: unknown })?.version === "string" @@ -1901,28 +1921,19 @@ export function SyncProvider(props: { const state = store.getState() const candidateSessionIds = getActiveSessionCandidateIds(directory, state) if (candidateSessionIds.length === 0) { - lastActiveEventAtByDirectoryRef.current.delete(directory) lastStatusPollAtByDirectoryRef.current.delete(directory) lastFullResyncAtByDirectoryRef.current.delete(directory) continue } - if (!lastActiveEventAtByDirectoryRef.current.has(directory)) { - lastActiveEventAtByDirectoryRef.current.set(directory, now) - } - const lastStatusPollAt = lastStatusPollAtByDirectoryRef.current.get(directory) ?? 0 if (now - lastStatusPollAt >= ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS) { lastStatusPollAtByDirectoryRef.current.set(directory, now) void pollDirectoryStatuses(directory, store, candidateSessionIds).catch(() => undefined) } - const lastActiveEventAt = lastActiveEventAtByDirectoryRef.current.get(directory) ?? now const lastFullResyncAt = lastFullResyncAtByDirectoryRef.current.get(directory) ?? 0 - if ( - now - lastActiveEventAt >= ACTIVE_SESSION_STALE_EVENT_MS - && now - lastFullResyncAt >= ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS - ) { + if (shouldTriggerStaleResync(lastStreamActivityAtRef.current, lastFullResyncAt, now)) { pipelineReconnectRef.current?.("active_stream_stale") triggerDirectoryResync(directory) } From 3801dfc5da911a810448ee06e91dfc36fcfe4824 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:38:19 +0300 Subject: [PATCH 08/88] fix(deps): update dependency @pierre/diffs to v1.3.0-beta.6 (#1789) --- bun.lock | 18 +++++++++--------- packages/ui/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 506dcb99ab..20ce1bac85 100644 --- a/bun.lock +++ b/bun.lock @@ -99,7 +99,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.2", + "version": "1.13.4", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -114,7 +114,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.2", + "version": "1.13.4", "dependencies": { "@base-ui/react": "^1.4.0", "@codemirror/autocomplete": "^6.20.0", @@ -146,7 +146,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.17.9", - "@pierre/diffs": "1.3.0-beta.4", + "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", "@xenova/transformers": "^2.17.2", @@ -214,7 +214,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.2", + "version": "1.13.4", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "^1.17.9", @@ -237,7 +237,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.2", + "version": "1.13.4", "bin": { "openchamber": "./bin/cli.js", }, @@ -969,11 +969,11 @@ "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], - "@pierre/diffs": ["@pierre/diffs@1.3.0-beta.4", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-poFcsvhcQt9lH/InzAPaGs47WYHMidnFCjuYGNU41HiVLJP4mkIQDSdvcnPIkBuh/cYbPOQg/YE3T1kSpr01GA=="], + "@pierre/diffs": ["@pierre/diffs@1.3.0-beta.6", "", { "dependencies": { "@pierre/theme": "1.1.0", "@pierre/theming": "0.0.2", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-SGxpOvuPeAq2sIMYqCokt8pVJ9UAZ6P5/qR4RYlcEVGRXOfGszbNMbrHs+IfRKnk6AufPNqVkIQWTwLC77x85A=="], - "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + "@pierre/theme": ["@pierre/theme@1.1.0", "", {}, "sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ=="], - "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], + "@pierre/theming": ["@pierre/theming@0.0.2", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -1751,7 +1751,7 @@ "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 156cf183da..f697cccec7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -41,7 +41,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.17.9", - "@pierre/diffs": "1.3.0-beta.4", + "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", "@xenova/transformers": "^2.17.2", From b0631c813594e424c3f218e4df4fe46ccd72428e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:38:58 +0300 Subject: [PATCH 09/88] chore(deps): update oven/bun docker tag to v1.3.14 (#1786) --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index bf8a1a3547..e207f05e36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM oven/bun:1.3.5 AS base +FROM oven/bun:1.3.14 AS base WORKDIR /app FROM base AS deps @@ -16,7 +16,7 @@ WORKDIR /app COPY . . RUN bun run build:web -FROM oven/bun:1.3.5 AS runtime +FROM oven/bun:1.3.14 AS runtime WORKDIR /home/openchamber RUN apt-get update && apt-get install -y --no-install-recommends \ From bf51a7e3641ce5fdcb87edadbd59faf9227f8563 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:39:38 +0300 Subject: [PATCH 10/88] chore(deps): update cloudflare/cloudflared docker digest to 6d91c12 (#1785) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e207f05e36..928b02fc26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,7 +48,7 @@ RUN npm config set prefix /home/openchamber/.npm-global && mkdir -p /home/opench npm install -g opencode-ai # cloudflared 2026.3.0 - update digest explicitly when upgrading -COPY --from=cloudflare/cloudflared@sha256:ba461b8aa9c042156dbd39c38657fe7431bafa063220eab8d5330a523863da9f /usr/local/bin/cloudflared /usr/local/bin/cloudflared +COPY --from=cloudflare/cloudflared@sha256:6d91c121b803126f7a5344005d17a9324788fc09d305b6e2560ec6040a7ae283 /usr/local/bin/cloudflared /usr/local/bin/cloudflared ENV NODE_ENV=production From 861a448b4e1a57731f0e53d95f65ea0ca13d723e Mon Sep 17 00:00:00 2001 From: bashrusakh <127580858+bashrusakh@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:42:13 +1100 Subject: [PATCH 11/88] fix(worktree): subagent sessions kept when deleting worktree group from sidebar (#1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktree): include sessions when deleting worktree group from sidebar allGroupSessions was guarded by group.isArchivedBucket, returning [] for active worktree groups. This caused the 'delete worktree' button in the sidebar to send an empty session list — SessionDialogs only removed the git worktree directory and skipped archiving any sessions, leaving them orphaned. Remove the guard so all sessions (including recursive children / subagent sessions) are collected regardless of archived state. * fix(sessions): delete all descendants on hard-delete instead of relying on server cascade The previous code sent only the root session ID and assumed the server would cascade-delete all children. If the cascade failed, children were left orphaned. Delete root + descendants individually; 404 responses from already-cascade-deleted children are treated as success. * fix(sessions): clear worktree metadata when deleting a session Deleted sessions kept their worktree attachment in both session-worktree-store and session-ui-store. Clean it up on successful deletion and on 404 (already deleted). * fix(worktree): search subagent sessions across all directories before delete WorktreeSectionContent and BranchPickerDialog used useSessions(), which is scoped to the current sync directory. Subagent sessions created in other worktrees/project roots were missed and left orphaned. Search across active + archived global sessions when collecting descendants. --------- Co-authored-by: Leonid Skorobogatyy Co-authored-by: Bohdan Triapitsyn --- .../openchamber/WorktreeSectionContent.tsx | 17 +++++++++++++---- .../session/sidebar/SessionGroupSection.tsx | 11 ++++++----- .../session/sidebar/hooks/useSessionActions.ts | 13 ++++++------- packages/ui/src/sync/session-actions.ts | 8 ++++++++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 2edcf0f8b3..1fee25b1fa 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -4,10 +4,12 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; +import type { Session } from '@opencode-ai/sdk/v2'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessions } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useDeviceInfo } from '@/lib/device'; import { checkIsGitRepository } from '@/lib/gitApi'; import { @@ -228,10 +230,17 @@ export const WorktreeSectionContent: React.FC = ({ // Build a set of session IDs that are directly linked const directSessionIds = new Set(directSessions.map((s) => s.id)); - // Find all subsessions recursively - const findSubsessions = (parentIds: Set): typeof sessions => { - const subsessions = sessions.filter((session) => { - const parentID = (session as { parentID?: string | null }).parentID; + // Search subsessions across all directories, not just the current sync + // context, so subagent sessions created in other worktrees/project roots + // are still included in the delete list. + const allKnownSessions = [ + ...useGlobalSessionsStore.getState().activeSessions, + ...useGlobalSessionsStore.getState().archivedSessions, + ]; + + const findSubsessions = (parentIds: Set): Session[] => { + const subsessions = allKnownSessions.filter((session) => { + const parentID = (session as Session & { parentID?: string | null }).parentID; return parentID && parentIds.has(parentID); }); if (subsessions.length === 0) { diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 0768a09922..1e854bca9f 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -675,12 +675,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { return collected; }, []); - // The "delete all in group" handler closes over the full list of - // sessions in this group. Memoize so the recursive flatten only runs - // when the underlying source group nodes change, not on every render. + // Flat list of all sessions in this group (including nested children). + // Used by both the "delete all archived" button and the "delete worktree" + // button. Memoize so the recursive flatten only runs when the underlying + // source group nodes change, not on every render. const allGroupSessions = React.useMemo( - () => (group.isArchivedBucket ? collectGroupSessions(sourceGroupNodes) : []), - [collectGroupSessions, sourceGroupNodes, group.isArchivedBucket], + () => collectGroupSessions(sourceGroupNodes), + [collectGroupSessions, sourceGroupNodes], ); // Precompute the per-folder "delete all sessions in folder" list once diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts index e7447fae34..7338be53f9 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -217,13 +217,12 @@ export const useSessionActions = (args: Args) => { const ids = [session.id, ...descendantIds]; if (shouldHardDelete) { - // The server cascade-deletes all descendant sessions when the parent - // is removed. Only send the root session delete request; sending - // individual requests for each descendant would hit 404 (already - // deleted by cascade) and trigger rollback that restores them. - const success = await args.deleteSession(session.id); - if (success) { - const totalDeleted = descendantIds.length + 1; + // Delete root + all descendants individually. If the server + // cascade-deletes some children before we get to them, 404 is + // treated as success by deleteSession and no rollback occurs. + const { deletedIds, failedIds } = await args.deleteSessions(ids); + if (failedIds.length === 0) { + const totalDeleted = deletedIds.length; toast.success(totalDeleted === 1 ? t('sessions.sidebar.bulkActions.deletedSingle', { count: totalDeleted }) : t('sessions.sidebar.bulkActions.deletedPlural', { count: totalDeleted })); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 05a9092c77..8d4fe79676 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -466,6 +466,10 @@ function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void { } } +function cleanupSessionWorktreeMetadata(sessionId: string): void { + useSessionUIStore.getState().setWorktreeMetadata(sessionId, null) +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function deleteSession(sessionId: string, _options?: Record): Promise { const sessionDirectory = getSessionDirectory(sessionId) @@ -484,6 +488,7 @@ export async function deleteSession(sessionId: string, _options?: Record Date: Mon, 29 Jun 2026 09:28:20 +1100 Subject: [PATCH 12/88] feat(#1766): support OpenCode steer delivery / follow-up behavior settings (#1781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support OpenCode steer delivery / follow-up behavior settings Implements issue #1766 — steer delivery mode for mid-turn message insertion, replacing the old boolean queue-mode toggle with a tri-state follow-up behavior setting (Steer / Queue / Send immediately). - Plumbing: threaded optional delivery: 'steer' through sendMessage -> routeMessage -> opencodeClient.sendMessage -> promptAsync - Store: messageQueueStore stores followUpBehavior; migration from legacy queueModeEnabled persisted state - Settings: Chat -> Follow-up behavior shows three radio options using existing settings UI patterns - Composer: when session is busy, a floating queue button remains; force-sending a queued message (via chip click) uses delivery: 'steer' during a busy session; Steer button intentionally omitted — steer is available via the two-gesture path (Enter to queue -> chip to steer) - Keyboard: queue mode = Enter queues, Ctrl+Enter sends; otherwise Enter sends, Ctrl+Enter queues - Persistence: DesktopSettings, web settings payload, and server-side sanitizer handle the new key with legacy fallback - i18n: follow-up behavior section and option labels in all 9 locales plus new chat.chatInput.actions.queue label - Search: settings registry updated from chat.queue-mode to chat.follow-up-behavior Validation: type-check passes (no new errors), lint clean. * fix(#1766): make steer mode actually steer The followUpBehavior === 'steer' branch in handlePrimaryAction and the keyboard handler was a no-op — both fell into the else branch and sent without the delivery: 'steer' flag, so selecting 'Steer (insert into the running turn)' in settings produced identical behavior to 'Send immediately'. - handlePrimaryAction: when steer mode is selected and the session is busy, call handleSubmit({ delivery: 'steer' }) directly - Keyboard handler: in steer mode, Enter steers and Ctrl+Enter sends immediately (consistent with queue mode where Ctrl+Enter bypasses the special handling) Also removes the unused chat.chatInput.actions.queue i18n key from all 9 locales (it was a dead key after the Steer button was removed from the composer). Validation: type-check clean, lint clean. * refactor(#1766): flatten nested ternary in followUpBehavior resolution Replace nested ternary with explicit if/else chain per project code style (CONTRIBUTING.md). Import FollowUpBehavior type explicitly for the new let declaration. * feat(chat): drop redundant 'immediate' follow-up mode, keep Queue + Steer 'Immediate' was wire-identical to 'Steer' on a busy session: OpenCode only supports delivery 'steer' | 'queue' and defaults to 'steer', so an immediate send (no delivery flag) already steered into the running turn. The three-mode UI therefore exposed two settings that did the same thing. Collapse to two modes — Queue (unchanged: client-side queue with edit/reorder) and Steer. Any persisted/legacy 'immediate' (and legacy queueModeEnabled=false) now maps to 'steer', preserving prior behavior. Removes the immediate option, its keyboard branch, the i18n label across all locales, and narrows the followUpBehavior union to 'steer' | 'queue'. --------- Co-authored-by: Leonid Skorobogatyy Co-authored-by: Bohdan Triapitsyn --- .gitignore | 3 + packages/ui/src/components/chat/ChatInput.tsx | 48 ++++++---- .../sections/openchamber/OpenChamberPage.tsx | 4 +- .../openchamber/OpenChamberVisualSettings.tsx | 89 +++++++++++-------- packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 1 + .../ui/src/lib/i18n/messages/en.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 6 ++ packages/ui/src/lib/opencode/client.ts | 2 + packages/ui/src/lib/persistence.ts | 18 ++-- packages/ui/src/lib/settings/search.ts | 8 +- packages/ui/src/stores/messageQueueStore.ts | 63 +++++++++++-- packages/ui/src/sync/session-ui-store.ts | 5 ++ .../server/lib/opencode/settings-helpers.js | 20 ++++- 22 files changed, 244 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index f69072c8b9..305aec4c6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Agent memory +.graymatter/ + # Logs logs *.log diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 91e972c693..bee0518bdc 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1420,7 +1420,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } | null>(null); // Message queue - const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled); + const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { @@ -1697,6 +1697,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo type SubmitOptions = { queuedOnly?: boolean; queuedMessageId?: string; + delivery?: 'steer'; }; const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); @@ -1746,7 +1747,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, []); const handleQueuedMessageSend = React.useCallback((messageId: string) => { - void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId }); + // Force-sending from the queue during a busy session counts as steer + void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId, delivery: 'steer' }); }, []); const handleOpenAgentPanel = React.useCallback(() => { @@ -1768,6 +1770,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; + const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const inputSnapshot = getCurrentInputSnapshot(); const queuedMessagesToSend = queuedMessageId ? queuedMessages.filter((message) => message.id === queuedMessageId) @@ -1816,6 +1819,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } + const sendMessageOptions = delivery ? { delivery } : undefined; + // Build the primary message (first part) and additional parts let primaryText = ''; let primaryAttachments: AttachedFile[] = []; @@ -2024,6 +2029,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2046,6 +2052,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2072,6 +2079,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2094,6 +2102,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2116,6 +2125,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2138,6 +2148,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2160,6 +2171,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, + sendMessageOptions, ); scrollToBottom?.(); } catch (error) { @@ -2208,7 +2220,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo agentMentionName, additionalParts.length > 0 ? additionalParts : undefined, variantToSend, - inputMode + inputMode, + sendMessageOptions, ); if (typeof window === 'undefined') { @@ -2279,16 +2292,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // Update ref with latest handleSubmit on every render handleSubmitRef.current = handleSubmit; - // Primary action for send button - respects queue mode setting + // Primary action for send/queue button — respects selected follow-up behavior const handlePrimaryAction = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); - if (queueModeEnabled && canQueue) { + if (followUpBehavior === 'queue' && canQueue) { handleQueueMessage(); + } else if (followUpBehavior === 'steer' && canQueue) { + void handleSubmitRef.current({ delivery: 'steer' }); } else { void handleSubmitRef.current(); } - }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, queueModeEnabled, handleQueueMessage]); + }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]); // Draft welcome presets: populate the composer and submit immediately. // getCurrentInputSnapshot reads textareaRef.current.value first, so setting it @@ -2527,33 +2542,28 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - // Handle Enter/Ctrl+Enter based on queue mode + // Handle Enter/Ctrl+Enter based on selected follow-up behavior. if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) { e.preventDefault(); const isCtrlEnter = e.ctrlKey || e.metaKey; - // Queue mode: Enter queues, Ctrl+Enter sends - // Normal mode: Enter sends, Ctrl+Enter queues - // Note: Queueing only works when there's an existing session (currentSessionId) - // For new sessions (draft), always send immediately + // Queueing / steering only works when there's an existing busy + // session (or an active auto-review run). const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); - if (queueModeEnabled) { + if (followUpBehavior === 'queue') { if (isCtrlEnter || !canQueue) { - // Ctrl+Enter sends, or Enter when can't queue (new session) handleSubmit(); } else { - // Enter queues when we have a session handleQueueMessage(); } } else { - if (isCtrlEnter && canQueue) { - // Ctrl+Enter queues when we have a session - handleQueueMessage(); - } else { - // Enter sends + // steer: Enter steers into the running turn, Ctrl+Enter sends now. + if (isCtrlEnter || !canQueue) { handleSubmit(); + } else { + handleSubmit({ delivery: 'steer' }); } } } diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 3cd6dfe085..d149ca4381 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -142,9 +142,9 @@ const VisualSectionContent: React.FC = () => { ]} />; }; -// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft +// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft const ChatSectionContent: React.FC = () => { - return ; + return ; }; // Sessions section: Default model & agent, Session retention diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index c1efef3d0f..433d505367 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -5,8 +5,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; -import { useMessageQueueStore } from '@/stores/messageQueueStore'; -import { cn, getModifierLabel } from '@/lib/utils'; +import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; +import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; @@ -230,11 +230,22 @@ const WEEK_START_OPTIONS: Option<'auto' | 'monday' | 'sunday'>[] = [ }, ]; +const FOLLOW_UP_BEHAVIOR_OPTIONS: Option[] = [ + { + id: 'steer', + labelKey: 'settings.openchamber.visual.option.followUpBehavior.steer.label', + }, + { + id: 'queue', + labelKey: 'settings.openchamber.visual.option.followUpBehavior.queue.label', + }, +]; + const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; +type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -288,8 +299,8 @@ export const OpenChamberVisualSettings: React.FC const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop); const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap); const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap); - const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled); - const setQueueMode = useMessageQueueStore(state => state.setQueueMode); + const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior); + const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior); const persistChatDraft = useUIStore(state => state.persistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); @@ -550,7 +561,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('reasoning') - || shouldShow('queueMode') + || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('expandedTools') @@ -1727,7 +1738,7 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} - {shouldShow('queueMode') && ( -
setQueueMode(!queueModeEnabled)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setQueueMode(!queueModeEnabled); - } - }} - > - -
- {t('settings.openchamber.visual.field.queueMessagesByDefault')} - - - - - - {t('settings.openchamber.visual.field.queueMessagesByDefaultTooltip', { modifier: getModifierLabel() })} - - + {shouldShow('followUpBehavior') && ( +
+

{t('settings.openchamber.visual.section.followUpBehavior')}

+
+ {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { + const selected = followUpBehavior === option.id; + return ( +
setFollowUpBehavior(option.id)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setFollowUpBehavior(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + setFollowUpBehavior(option.id)} + ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} + /> + + {tUnsafe(option.labelKey)} + +
+ ); + })}
-
+
)} {shouldShow('persistDraft') && ( diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index c8bfd37e2d..d3539c293c 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -638,6 +638,7 @@ export interface SettingsPayload { autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; sessionRetentionAction?: 'archive' | 'delete'; + followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 9bdcdcbad7..867a292938 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -115,6 +115,7 @@ export type DesktopSettings = { defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; + followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; gitmojiEnabled?: boolean; defaultFileViewerPreview?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c425b72e9c..81ad24fb0c 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'Failed to reset prompt', 'settings.magicPrompts.page.toast.resetAllSuccess': 'All prompt overrides reset', 'settings.magicPrompts.page.toast.resetAllFailed': 'Failed to reset all prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 06851680cf..b97d5417bd 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Plantilla creada", "settings.promptTemplates.page.toast.createFailed": "Error al crear la plantilla", "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocurrió un error inesperado al guardar", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 8d1daedd60..b120465356 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'Échec de la réinitialisation du prompt', 'settings.magicPrompts.page.toast.resetAllSuccess': 'Tous les prompts ont été réinitialisés', 'settings.magicPrompts.page.toast.resetAllFailed': 'Échec de la réinitialisation de tous les prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 160bcd774c..8362c34ffa 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.magicPrompts.page.toast.resetFailed': 'プロンプトのリセットに失敗しました', 'settings.magicPrompts.page.toast.resetAllSuccess': 'すべてのプロンプト上書きをリセットしました', 'settings.magicPrompts.page.toast.resetAllFailed': 'すべてのプロンプトのリセットに失敗しました', + 'settings.openchamber.visual.section.followUpBehavior': 'フォローアップの動作', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'フォローアップの動作', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'フォローアップの動作: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア(実行中のターンに挿入)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー(現在のターンの後に送信)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 7aac2181eb..f578af3853 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.promptTemplates.page.toast.created': '템플릿이 생성되었습니다', 'settings.promptTemplates.page.toast.createFailed': '템플릿 생성 실패', 'settings.promptTemplates.page.toast.saveUnexpectedError': '저장 중 예기치 않은 오류가 발생했습니다', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index cd62d24d4b..21f47137fd 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1792,4 +1792,10 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst', 'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown', 'settings.window.description': 'Okno ustawień OpenChamber.', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', }; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 3c38e551e7..34b0e241fb 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Template criado", "settings.promptTemplates.page.toast.createFailed": "Falha ao criar template", "settings.promptTemplates.page.toast.saveUnexpectedError": "Ocorreu um erro inesperado ao salvar", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8d4f408dc5..14872f0174 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { "settings.promptTemplates.page.toast.created": "Шаблон створено", "settings.promptTemplates.page.toast.createFailed": "Не вдалося створити шаблон", "settings.promptTemplates.page.toast.saveUnexpectedError": "Сталася неочікувана помилка під час збереження", + "settings.openchamber.visual.section.followUpBehavior": "Follow-up behavior", + "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", + "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", + "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 49a1db3e4f..2bb3c8dfe2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1800,4 +1800,10 @@ export const settingsDict = { 'settings.promptTemplates.page.toast.created': '模板已创建', 'settings.promptTemplates.page.toast.createFailed': '创建模板失败', 'settings.promptTemplates.page.toast.saveUnexpectedError': '保存时发生意外错误', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 24af70a151..b4067efdfd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1800,4 +1800,10 @@ 'settings.usage.sidebar.field.showPredictions': '顯示預測', 'settings.view.home.cards.plugins.description': '管理 opencode 外掛', 'settings.view.home.cards.plugins.title': '外掛', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', } as const; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index aac2bd4253..216ef690d5 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -735,6 +735,7 @@ class OpencodeService { }>; messageId?: string; agentMentions?: Array<{ name: string; source?: { value: string; start: number; end: number } }>; + delivery?: 'steer'; format?: { type: 'json_schema'; schema: Record; @@ -840,6 +841,7 @@ class OpencodeService { agent: params.agent, variant: params.variant, messageID: messageId, + ...(params.delivery ? { delivery: params.delivery } : {}), ...(params.format ? { format: params.format } : {}), parts, }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 90cbae22aa..30adc0090b 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -2,7 +2,7 @@ import type { DesktopSettings } from '@/lib/desktop'; import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions'; -import { useMessageQueueStore } from '@/stores/messageQueueStore'; +import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; @@ -444,8 +444,14 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { } } - if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) { - queueStore.setQueueMode(settings.queueModeEnabled); + let nextFollowUpBehavior: FollowUpBehavior | null = null; + if (isFollowUpBehavior(settings.followUpBehavior)) { + nextFollowUpBehavior = settings.followUpBehavior; + } else if (typeof settings.queueModeEnabled === 'boolean') { + nextFollowUpBehavior = normalizeFollowUpBehavior(undefined, settings.queueModeEnabled); + } + if (nextFollowUpBehavior && nextFollowUpBehavior !== queueStore.followUpBehavior) { + queueStore.setFollowUpBehavior(nextFollowUpBehavior); } if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) { @@ -838,8 +844,10 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.gitmojiEnabled === 'boolean') { result.gitmojiEnabled = candidate.gitmojiEnabled; } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; + if (isFollowUpBehavior(candidate.followUpBehavior)) { + result.followUpBehavior = candidate.followUpBehavior; + } else if (typeof candidate.queueModeEnabled === 'boolean') { + result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); } if (typeof candidate.showDeletionDialog === 'boolean') { result.showDeletionDialog = candidate.showDeletionDialog; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 3a74ce8e9e..89032db3ea 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -217,11 +217,11 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ isAvailable: (ctx) => !ctx.isVSCode, }, { - id: 'chat.queue-mode', + id: 'chat.follow-up-behavior', page: 'chat', - titleKey: 'settings.openchamber.visual.field.queueMessagesByDefault', - descriptionKey: 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip', - keywords: ['queue', 'enter', 'send'], + titleKey: 'settings.openchamber.visual.section.followUpBehavior', + descriptionKey: 'settings.openchamber.visual.field.followUpBehaviorDescription', + keywords: ['follow up', 'queue', 'steer', 'send immediately'], }, { id: 'chat.persist-drafts', diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index e7cf4e52ea..d27862a663 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -4,6 +4,40 @@ import { getSafeStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; +export type FollowUpBehavior = 'steer' | 'queue'; + +export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue'; + +export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => ( + value === 'steer' || value === 'queue' +); + +export const normalizeFollowUpBehavior = ( + value: unknown, + legacyQueueModeEnabled?: boolean | null, +): FollowUpBehavior => { + // "immediate" was removed: on a busy session it was wire-identical to + // "steer" (OpenCode only supports delivery "steer" | "queue", defaulting + // to "steer"), so collapse any persisted/legacy "immediate" onto "steer". + if (value === 'immediate') { + return 'steer'; + } + + if (isFollowUpBehavior(value)) { + return value; + } + + if (legacyQueueModeEnabled === false) { + return 'steer'; + } + + if (legacyQueueModeEnabled === true) { + return 'queue'; + } + + return DEFAULT_FOLLOW_UP_BEHAVIOR; +}; + export interface QueuedMessage { id: string; content: string; @@ -20,7 +54,7 @@ export interface QueuedMessage { interface MessageQueueState { queuedMessages: Record; // sessionId → queue - queueModeEnabled: boolean; // global toggle + followUpBehavior: FollowUpBehavior; } interface MessageQueueActions { @@ -30,18 +64,24 @@ interface MessageQueueActions { popToInput: (sessionId: string, messageId: string) => QueuedMessage | null; clearQueue: (sessionId: string) => void; clearAllQueues: () => void; - setQueueMode: (enabled: boolean) => void; + setFollowUpBehavior: (behavior: FollowUpBehavior) => void; getQueueForSession: (sessionId: string) => QueuedMessage[]; } type MessageQueueStore = MessageQueueState & MessageQueueActions; +type PersistedMessageQueueState = { + queuedMessages?: Record; + followUpBehavior?: FollowUpBehavior; + queueModeEnabled?: boolean; +}; + export const useMessageQueueStore = create()( devtools( persist( (set, get) => ({ queuedMessages: {}, - queueModeEnabled: true, + followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, addToQueue: (sessionId, message) => { const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -149,10 +189,9 @@ export const useMessageQueueStore = create()( set({ queuedMessages: {} }); }, - setQueueMode: (enabled) => { - set({ queueModeEnabled: enabled }); - // Persist to settings.json (async, fire-and-forget) - void updateDesktopSettings({ queueModeEnabled: enabled }); + setFollowUpBehavior: (behavior) => { + set({ followUpBehavior: behavior }); + void updateDesktopSettings({ followUpBehavior: behavior }); }, getQueueForSession: (sessionId) => { @@ -161,11 +200,19 @@ export const useMessageQueueStore = create()( }), { name: 'message-queue-store', + version: 1, storage: createJSONStorage(() => getSafeStorage()), partialize: (state) => ({ queuedMessages: state.queuedMessages, - queueModeEnabled: state.queueModeEnabled, + followUpBehavior: state.followUpBehavior, }), + migrate: (persistedState) => { + const state = (persistedState ?? {}) as PersistedMessageQueueState; + return { + queuedMessages: state.queuedMessages ?? {}, + followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null), + }; + }, } ), { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index f32c837eb4..dabad03c8f 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -83,6 +83,7 @@ export function routeMessage(params: { inputMode?: "normal" | "shell" files?: Array<{ type: "file"; mime: string; url: string; filename: string }> additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> + delivery?: 'steer' }): Promise { const requestDirectory = params.directory ?? undefined if (params.inputMode === "shell") { @@ -157,6 +158,7 @@ export function routeMessage(params: { variant: params.variant, files: params.files, additionalParts: params.additionalParts, + delivery: params.delivery, messageId: messageID, directory: requestDirectory, }).then(() => {}), @@ -165,6 +167,7 @@ export function routeMessage(params: { type SendMessageOptions = { sessionId?: string + delivery?: 'steer' } type AssistantMessageSessionExecution = { @@ -1040,6 +1043,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + delivery: options?.delivery, additionalParts: mergedAdditionalParts?.map((p) => ({ text: p.text, synthetic: p.synthetic, @@ -1118,6 +1122,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + delivery: options?.delivery, additionalParts: additionalParts?.map((p) => ({ text: p.text, synthetic: p.synthetic, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index fd995069c1..f02f959eaa 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -106,6 +106,20 @@ export const createSettingsHelpers = (dependencies) => { return fallback; }; + const normalizeFollowUpBehavior = (value, legacyQueueModeEnabled = null) => { + // "immediate" was removed (it was wire-identical to "steer"); collapse it. + if (value === 'immediate') { + return 'steer'; + } + if (value === 'steer' || value === 'queue') { + return value; + } + if (legacyQueueModeEnabled === false) { + return 'steer'; + } + return 'queue'; + }; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -361,8 +375,10 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; } - if (typeof candidate.queueModeEnabled === 'boolean') { - result.queueModeEnabled = candidate.queueModeEnabled; + if (typeof candidate.followUpBehavior === 'string') { + result.followUpBehavior = normalizeFollowUpBehavior(candidate.followUpBehavior); + } else if (typeof candidate.queueModeEnabled === 'boolean') { + result.followUpBehavior = normalizeFollowUpBehavior(undefined, candidate.queueModeEnabled); } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; From 7b1bed5a288979079f6cda44e7d610f3a9cc07d3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:32:38 +0300 Subject: [PATCH 13/88] docs(changelog): add unreleased entries for follow-up behavior, session fixes, and sync --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4c101895..1497242ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. - Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. - OpenCode: when a separate OpenCode is already running (the TUI, `opencode serve`, or a daemon on the default port 4096), the app now starts its own server instead of attaching to it. This fixes the "OpenChamber could not finish initialization" error and stops the app from opening or closing your separate OpenCode when it starts and quits. Connecting to an external OpenCode now requires setting `OPENCODE_HOST`, `OPENCODE_PORT`, or `OPENCODE_SKIP_START`. +- Chat: a new Follow-up behavior setting (Settings → Chat) controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). +- Sessions: deleting a worktree group from the sidebar, or permanently deleting an archived session that has subagent sessions, now removes those subagent sessions too instead of leaving them behind (thanks to @bashrusakh). +- Sessions: clicking a session inside a worktree group no longer briefly jumps the selection to the project's first session while the sidebar data catches up (thanks to @bashrusakh). +- Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). ## [1.13.7] - 2026-06-28 From 9f1ad467c710294f0024fa2ae45a7de4bed0bb65 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:37:36 +0300 Subject: [PATCH 14/88] fix(settings): move Follow-up behavior radio group next to the other choice settings It was rendered in the middle of the checkbox list, where a labeled radio group reads as out of place. Move it into the radio/choice cluster, right after Diff Layout, and drop it from the checkbox section's visibility gate. --- .../openchamber/OpenChamberVisualSettings.tsx | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 433d505367..509a7ca315 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -1429,7 +1429,7 @@ export const OpenChamberVisualSettings: React.FC {hasBehaviorSettings && (
- {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && ( + {(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || shouldShow('messageTransport') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode) || shouldShow('followUpBehavior')) && (
{shouldShow('chatRenderMode') && (
@@ -1735,10 +1735,46 @@ export const OpenChamberVisualSettings: React.FC
)} + {shouldShow('followUpBehavior') && ( +
+

{t('settings.openchamber.visual.section.followUpBehavior')}

+
+ {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { + const selected = followUpBehavior === option.id; + return ( +
setFollowUpBehavior(option.id)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setFollowUpBehavior(option.id); + } + }} + className="flex w-full items-center gap-2 py-0 text-left" + > + setFollowUpBehavior(option.id)} + ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} + /> + + {tUnsafe(option.labelKey)} + +
+ ); + })} +
+
+ )} +
)} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('followUpBehavior') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
{shouldShow('reasoning') && (
)} - {shouldShow('followUpBehavior') && ( -
-

{t('settings.openchamber.visual.section.followUpBehavior')}

-
- {FOLLOW_UP_BEHAVIOR_OPTIONS.map((option) => { - const selected = followUpBehavior === option.id; - return ( -
setFollowUpBehavior(option.id)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setFollowUpBehavior(option.id); - } - }} - className="flex w-full items-center gap-2 py-0 text-left" - > - setFollowUpBehavior(option.id)} - ariaLabel={t('settings.openchamber.visual.field.followUpBehaviorAria', { option: tUnsafe(option.labelKey) })} - /> - - {tUnsafe(option.labelKey)} - -
- ); - })} -
-
- )} - {shouldShow('persistDraft') && (
Date: Mon, 29 Jun 2026 01:41:35 +0300 Subject: [PATCH 15/88] fix(settings): shorten Follow-up behavior option labels to Steer / Queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parenthetical explanations wrapped to two lines and added little — the section header already gives context and the two terms are self-explanatory. --- packages/ui/src/lib/i18n/messages/en.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/es.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/fr.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/ja.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/ko.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/pl.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/pt-BR.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/uk.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/zh-CN.settings.ts | 4 ++-- packages/ui/src/lib/i18n/messages/zh-TW.settings.ts | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 81ad24fb0c..2233aa6519 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b97d5417bd..9d5bd6e41c 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", - "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", - "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index b120465356..010e89dc4e 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 8362c34ffa..f9b73858c8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'フォローアップの動作', 'settings.openchamber.visual.field.followUpBehaviorAria': 'フォローアップの動作: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア(実行中のターンに挿入)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー(現在のターンの後に送信)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index f578af3853..152f020509 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 21f47137fd..3daa022470 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1796,6 +1796,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', }; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 34b0e241fb..f9eb5f0322 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", - "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", - "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 14872f0174..8b351bfc20 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { "settings.openchamber.visual.section.followUpBehaviorAria": "Follow-up behavior", "settings.openchamber.visual.field.followUpBehaviorAria": "Follow-up behavior: {option}", "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", - "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer (insert into the running turn)", - "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue (deliver after the current turn)", + "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", + "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 2bb3c8dfe2..e6a959d59e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1804,6 +1804,6 @@ export const settingsDict = { 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b4067efdfd..06989c748a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1804,6 +1804,6 @@ 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up behavior: {option}', 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', - 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer (insert into the running turn)', - 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue (deliver after the current turn)', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', } as const; From e6e3c9d86d98c45a429295823cf763fcb2818879 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:42:16 +0300 Subject: [PATCH 16/88] docs(vscode-changelog): add unreleased entries for follow-up behavior and sync --- packages/vscode/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2f529cb17c..3fd8d5c9ec 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] -- No notable changes. +- Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). +- Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). ## [1.13.7] - 2026-06-28 From 4513bd0888d6954dbba55fee8bbb88997ee602af Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:53:47 +0300 Subject: [PATCH 17/88] fix(git): don't log an error when status is requested for a deleted directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getStatus() screamed 'Failed to get Git status' and rethrew for a directory that no longer exists — a benign case hit when PR-status resolution touches a worktree that was deleted while still being watched. Treat a missing directory like a non-repo: skip the error log (callers already handle/​swallow it). --- packages/web/server/lib/git/service.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 007b46717f..4b738b00e9 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -824,6 +824,19 @@ const isNotGitRepositoryError = (error) => { return /not a git repository/i.test(text); }; +// A directory that no longer exists (e.g. a worktree deleted while something +// was still polling its status) is an expected, benign condition — not a fault +// to scream about. simple-git throws "Cannot use simple-git on a directory that +// does not exist"; the underlying fs errors are ENOENT/ENOTDIR. +const isMissingDirectoryError = (error) => { + const code = error?.code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return true; + } + const text = parseGitErrorText(error); + return /directory that does not exist|does not exist|no such file or directory/i.test(text); +}; + const runGitCommand = async (cwd, args) => { try { const { stdout, stderr } = await execFileAsync(getGitBinary(), args, { @@ -2184,7 +2197,7 @@ export async function getStatus(directory, options = {}) { rebaseInProgress, }; } catch (error) { - if (!isNotGitRepositoryError(error)) { + if (!isNotGitRepositoryError(error) && !isMissingDirectoryError(error)) { console.error('Failed to get Git status:', error); } throw error; From 77bc2cc85480318c941b2b984c5189a36fad0c7f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 01:56:56 +0300 Subject: [PATCH 18/88] fix(github): add a per-request timeout to all Octokit calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Octokit v22 uses native fetch, which has no built-in timeout, so a stuck GitHub request hung until the PR-status route's 12s overall budget fired — and one slow request could consume the entire budget. Wrap fetch with an 8s AbortSignal.timeout via a shared createOctokit() factory, and route the inline Octokit instantiations through it too. --- packages/web/server/lib/github/index.js | 1 + packages/web/server/lib/github/octokit.js | 22 +++++++++++++++++++++- packages/web/server/lib/github/routes.js | 16 ++++++++-------- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/web/server/lib/github/index.js b/packages/web/server/lib/github/index.js index 926584b773..893a26e538 100644 --- a/packages/web/server/lib/github/index.js +++ b/packages/web/server/lib/github/index.js @@ -21,6 +21,7 @@ export { export { getOctokitOrNull, + createOctokit, } from './octokit.js'; export { diff --git a/packages/web/server/lib/github/octokit.js b/packages/web/server/lib/github/octokit.js index b6cd15ccd0..caf05306dc 100644 --- a/packages/web/server/lib/github/octokit.js +++ b/packages/web/server/lib/github/octokit.js @@ -2,6 +2,26 @@ import { Octokit } from '@octokit/rest'; import { getGitHubAuth, isGhCliActive, isGhCliDisabled } from './auth.js'; import { getGhCliToken } from './gh-cli-credential.js'; +// Per-request timeout for every GitHub call. Octokit v22 uses native fetch, +// which has no built-in timeout — without this, a stuck connection hangs until +// some outer bound (the PR-status route's 12s overall budget) fires, and a +// single slow request can eat the whole budget. Bounding each request lets the +// caller fail fast and fall back to cached state instead. +const OCTOKIT_REQUEST_TIMEOUT_MS = 8000; + +const timeoutFetch = (url, options = {}) => { + // Respect a caller-provided signal if present; otherwise attach our timeout. + if (options.signal) { + return fetch(url, options); + } + return fetch(url, { ...options, signal: AbortSignal.timeout(OCTOKIT_REQUEST_TIMEOUT_MS) }); +}; + +/** Create an Octokit instance with a per-request timeout applied. */ +export function createOctokit(token) { + return new Octokit({ auth: token, request: { fetch: timeoutFetch } }); +} + export function getOctokitOrNull() { const auth = getGitHubAuth(); const ghToken = !isGhCliDisabled() ? getGhCliToken() : null; @@ -9,5 +29,5 @@ export function getOctokitOrNull() { if (!token) { return null; } - return new Octokit({ auth: token }); + return createOctokit(token); } diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 6307c26823..0f20ccc0ac 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -108,8 +108,8 @@ export function registerGitHubRoutes(app) { if (ghToken !== null && !ghCliDisabled) { try { - const { Octokit } = await import('@octokit/rest'); - ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + ghCliUser = await getGitHubUserSummary(createOctokit(ghToken)); } catch { ghCliUser = null; } @@ -246,8 +246,8 @@ export function registerGitHubRoutes(app) { return res.status(500).json({ error: 'Missing access_token from GitHub' }); } - const { Octokit } = await import('@octokit/rest'); - const octokit = new Octokit({ auth: accessToken }); + const { createOctokit } = await import('./octokit.js'); + const octokit = createOctokit(accessToken); const user = await getGitHubUserSummary(octokit); setGitHubAuth({ @@ -283,8 +283,8 @@ export function registerGitHubRoutes(app) { return res.status(404).json({ error: 'GitHub CLI account not found' }); } - const { Octokit } = await import('@octokit/rest'); - const user = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + const user = await getGitHubUserSummary(createOctokit(ghToken)); setGhCliActive(true); const accounts = getGitHubAuthAccounts() .map((account) => ({ ...account, current: false })) @@ -319,8 +319,8 @@ export function registerGitHubRoutes(app) { let ghCliUser = null; if (ghToken) { try { - const { Octokit } = await import('@octokit/rest'); - ghCliUser = await getGitHubUserSummary(new Octokit({ auth: ghToken })); + const { createOctokit } = await import('./octokit.js'); + ghCliUser = await getGitHubUserSummary(createOctokit(ghToken)); accounts = accounts.concat({ id: GH_CLI_ACCOUNT_ID, user: ghCliUser, From f0298972ca321ff4c0331e2054a6df66a899dfe4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:01:30 +0300 Subject: [PATCH 19/88] feat(github): detect rate limiting and pause PR status calls during cooldown Octokit has no throttling plugin, so under a flood of PR-status calls a primary/secondary rate limit just surfaced as repeated 403s that the cache masked. Add a shared rate-limit gate: PR-status sub-calls note 403/429 responses, and the route short-circuits to cached/stale data during the cooldown instead of issuing more doomed requests. Transient failures (rate limit or the overall timeout) no longer log as hard errors. --- packages/web/server/lib/github/pr-status.js | 7 ++- packages/web/server/lib/github/rate-limit.js | 66 ++++++++++++++++++++ packages/web/server/lib/github/routes.js | 29 +++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/github/rate-limit.js diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 4f8e27f816..f4bfdced50 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -1,5 +1,6 @@ import { getRemotes, getStatus } from '../git/index.js'; import { resolveGitHubRepoFromDirectory } from './repo/index.js'; +import { noteIfGitHubRateLimit } from './rate-limit.js'; const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000; const defaultBranchCache = new Map(); @@ -182,7 +183,8 @@ const getRepoDefaultBranch = async (octokit, repo) => { fetchedAt: Date.now(), }); return defaultBranch; - } catch { + } catch (error) { + noteIfGitHubRateLimit(error); return null; } }; @@ -210,6 +212,7 @@ const getRepoMetadata = async (octokit, repo) => { }); return data; } catch (error) { + noteIfGitHubRateLimit(error); if (error?.status === 403 || error?.status === 404) { repoMetadataCache.set(repoKey, { data: null, @@ -290,6 +293,7 @@ const safeListPulls = async (octokit, options) => { const response = await octokit.rest.pulls.list(options); return Array.isArray(response?.data) ? response.data : []; } catch (error) { + noteIfGitHubRateLimit(error); if (error?.status === 404 || error?.status === 403) { return []; } @@ -345,6 +349,7 @@ const searchFallbackPr = async ({ octokit, branch, repoNames }) => { // If we get here, search API works for this repo — clear the disabled flag _searchApiDisabledRepos.delete(repoKey); } catch (error) { + noteIfGitHubRateLimit(error); if (error?.status === 403) { _searchApiDisabledRepos.set(repoKey, Date.now()); return null; diff --git a/packages/web/server/lib/github/rate-limit.js b/packages/web/server/lib/github/rate-limit.js new file mode 100644 index 0000000000..80a514100b --- /dev/null +++ b/packages/web/server/lib/github/rate-limit.js @@ -0,0 +1,66 @@ +// Lightweight, process-global GitHub rate-limit gate. +// +// Octokit is configured without the throttling plugin, so a primary or +// secondary rate limit surfaces as a thrown 403/429. Resolving PR status for +// many worktrees fans out dozens of calls; once GitHub starts limiting, every +// further call wastes a round-trip and the cache masks the failure. When we +// detect a rate-limit response we record a cooldown and skip GitHub work until +// it passes, so the burst stops and the reason is visible in the logs. + +const MAX_COOLDOWN_MS = 15 * 60 * 1000; +const DEFAULT_COOLDOWN_MS = 60 * 1000; + +let rateLimitedUntil = 0; + +const headerValue = (headers, name) => { + if (!headers) return undefined; + // Octokit/fetch headers can be a plain object or a Headers instance. + if (typeof headers.get === 'function') return headers.get(name); + return headers[name]; +}; + +const parseRetryAfterMs = (error) => { + const headers = error?.response?.headers; + const retryAfter = headerValue(headers, 'retry-after'); + if (retryAfter !== undefined && retryAfter !== null) { + const secs = Number(retryAfter); + if (Number.isFinite(secs) && secs > 0) return secs * 1000; + } + const reset = headerValue(headers, 'x-ratelimit-reset'); + if (reset !== undefined && reset !== null) { + const delta = Number(reset) * 1000 - Date.now(); + if (Number.isFinite(delta) && delta > 0) return delta; + } + return null; +}; + +/** True when an Octokit error represents a primary or secondary rate limit. */ +export const isGitHubRateLimitError = (error) => { + const status = error?.status ?? error?.response?.status; + if (status === 429) return true; + if (status !== 403) return false; + const remaining = headerValue(error?.response?.headers, 'x-ratelimit-remaining'); + if (remaining === '0' || remaining === 0) return true; + if (headerValue(error?.response?.headers, 'retry-after') != null) return true; + const message = String(error?.message ?? '').toLowerCase(); + return message.includes('rate limit'); +}; + +/** Record a cooldown after a detected rate-limit response. */ +export const noteGitHubRateLimit = (error) => { + const retryMs = Math.min(parseRetryAfterMs(error) ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS); + const until = Date.now() + retryMs; + if (until > rateLimitedUntil) { + rateLimitedUntil = until; + console.warn(`[github] rate limited — pausing GitHub PR status calls for ~${Math.round(retryMs / 1000)}s`); + } +}; + +/** Convenience: note the error if it is a rate-limit error. Returns whether it was. */ +export const noteIfGitHubRateLimit = (error) => { + if (!isGitHubRateLimitError(error)) return false; + noteGitHubRateLimit(error); + return true; +}; + +export const isGitHubRateLimited = () => Date.now() < rateLimitedUntil; diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 0f20ccc0ac..765f4a2637 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -419,6 +419,17 @@ export function registerGitHubRoutes(app) { return res.json(cached.data); } + // If GitHub recently rate-limited us, don't pile on more calls that will + // also fail. Serve whatever we last cached (even if stale); otherwise + // report a transient failure so the client keeps its last-known status. + const { isGitHubRateLimited } = await import('./rate-limit.js'); + if (isGitHubRateLimited()) { + if (cached) { + return res.json(cached.data); + } + return res.status(503).json({ error: 'GitHub rate limited' }); + } + // Intercept res.json to cache successful responses before sending // Only caches responses with connected:true — error/edge-case responses are not cached const originalJson = res.json.bind(res); @@ -577,6 +588,24 @@ export function registerGitHubRoutes(app) { clearGitHubAuth(); return res.json({ connected: false }); } + // Transient failures — a rate limit, or the overall resolve timeout + // firing — are expected under heavy load and should not be logged as hard + // errors. Record a rate-limit cooldown when applicable, then serve the + // last cached status (even if stale) or a 503 so the client keeps its + // last-known value instead of clearing the badge. + const { noteIfGitHubRateLimit } = await import('./rate-limit.js'); + const wasRateLimited = noteIfGitHubRateLimit(error); + const wasTimeout = error?.code === 'ETIMEDOUT'; + if (wasRateLimited || wasTimeout) { + const dir = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + const br = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; + const rem = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; + const cached = prStatusCache.get(`${dir}::${br}::${rem}`); + if (cached) { + return res.json(cached.data); + } + return res.status(503).json({ error: wasRateLimited ? 'GitHub rate limited' : 'GitHub request timed out' }); + } if (isGitHubResourceUnavailable(error)) { return res.json({ connected: true, From 431b9c6162297651d46785eaac2dc46c076f623d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:03:10 +0300 Subject: [PATCH 20/88] perf(github): resolve remote candidates and repo metadata concurrently resolveGitHubPrStatus walked remotes and candidate repos one network call at a time. Resolve all ranked remotes and fetch all candidate repo metadata with Promise.all instead, preserving rank/priority order and dedup. Cuts wall-clock on multi-remote/fork setups so a resolution is far less likely to hit the overall timeout. The PR-search loop keeps its early-return (parallelizing it would issue more calls, not fewer). --- packages/web/server/lib/github/pr-status.js | 33 ++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index f4bfdced50..b83413608d 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -225,21 +225,26 @@ const getRepoMetadata = async (octokit, repo) => { }; const resolveRemoteCandidates = async (directory, rankedRemoteNames) => { + // Resolve every ranked remote concurrently — they're independent git lookups. + // Dedup afterwards in rank order so the result is identical to the previous + // sequential pass, just without paying each lookup's latency back-to-back. + const resolvedRemotes = await Promise.all( + rankedRemoteNames.map((remoteName) => + resolveGitHubRepoFromDirectory(directory, remoteName) + .then((resolved) => ({ remoteName, repo: resolved?.repo || null })) + .catch(() => ({ remoteName, repo: null })), + ), + ); + const results = []; const seenRepoKeys = new Set(); - - for (const remoteName of rankedRemoteNames) { - const resolved = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null })); - const repo = resolved?.repo || null; + for (const { remoteName, repo } of resolvedRemotes) { const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); if (!repo || !repoKey || seenRepoKeys.has(repoKey)) { continue; } seenRepoKeys.add(repoKey); - results.push({ - remoteName, - repo, - }); + results.push({ remoteName, repo }); } return results; @@ -258,8 +263,16 @@ const expandRepoNetwork = async (octokit, candidates) => { expanded.push({ repo, remoteName, priority }); }; - for (const candidate of candidates) { - const metadata = await getRepoMetadata(octokit, candidate.repo); + // Fetch repo metadata for all candidates concurrently (independent GET + // /repos calls), then fold them in candidate order so dedup/priority is + // unchanged from the sequential version. + const metadatas = await Promise.all( + candidates.map((candidate) => + getRepoMetadata(octokit, candidate.repo).then((metadata) => ({ candidate, metadata })), + ), + ); + + for (const { candidate, metadata } of metadatas) { if (!metadata) { continue; } From 9209dc4826784dac30ab6688deb1c9f7500f2c4d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:05:26 +0300 Subject: [PATCH 21/88] fix(github): skip PR status resolution for a directory that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted worktree often still has a session in the sidebar, which keeps polling its PR status — spending a git status call (the source of the noisy 'directory does not exist' errors) plus remote/repo resolution on a gone path. Bail out early when the directory is missing; the route already returns a benign no-repo result, which caches so it stops re-polling. --- packages/web/server/lib/github/pr-status.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index b83413608d..89de3dd199 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -1,7 +1,18 @@ +import { stat } from 'node:fs/promises'; import { getRemotes, getStatus } from '../git/index.js'; import { resolveGitHubRepoFromDirectory } from './repo/index.js'; import { noteIfGitHubRateLimit } from './rate-limit.js'; +const directoryExists = async (dir) => { + if (!dir) return false; + try { + await stat(dir); + return true; + } catch { + return false; + } +}; + const REPO_DEFAULT_BRANCH_TTL_MS = 5 * 60_000; const defaultBranchCache = new Map(); const repoMetadataCache = new Map(); @@ -453,6 +464,14 @@ const findFirstMatchingPr = async ({ octokit, target, branch, sourceCandidates } }; export async function resolveGitHubPrStatus({ octokit, directory, branch, remoteName }) { + // A deleted worktree can still have a session in the sidebar that keeps + // requesting its PR status. Bail before touching git or GitHub for a + // directory that no longer exists — otherwise every poll spends a git call + // (and the remote/repo resolution that follows) on a path that's gone. + if (!(await directoryExists(directory))) { + return { repo: null, pr: null, defaultBranch: null, resolvedRemoteName: null }; + } + const normalizedBranch = normalizeText(branch); const normalizedRemoteName = normalizeText(remoteName) || 'origin'; From f93399680d5ac92f7759d39df774f40fb4be523f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 02:13:08 +0300 Subject: [PATCH 22/88] release v1.13.8 --- CHANGELOG.md | 2 ++ package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 2 +- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1497242ec6..ba7637c2e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.13.8] - 2026-06-29 + - Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. - OpenCode: when a separate OpenCode is already running (the TUI, `opencode serve`, or a daemon on the default port 4096), the app now starts its own server instead of attaching to it. This fixes the "OpenChamber could not finish initialization" error and stops the app from opening or closing your separate OpenCode when it starts and quits. Connecting to an external OpenCode now requires setting `OPENCODE_HOST`, `OPENCODE_PORT`, or `OPENCODE_SKIP_START`. - Chat: a new Follow-up behavior setting (Settings → Chat) controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). diff --git a/package.json b/package.json index 3c10dabec2..5da57494b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.7", + "version": "1.13.8", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index bf44e0139a..b7208b2871 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.7", + "version": "1.13.8", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index f697cccec7..85c4e41d58 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.7", + "version": "1.13.8", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 3fd8d5c9ec..ee61572613 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [1.13.8] - 2026-06-29 - Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). - Sync: a connected but quiet session (for example an agent running a long tool call) no longer triggers repeated background refreshes every ~15 seconds (thanks to @tomzx). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index adf7ce47eb..9b52033833 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.7", + "version": "1.13.8", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index c6eb0f36c0..3ac7bdb4ab 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.7", + "version": "1.13.8", "private": false, "type": "module", "main": "./server/index.js", From 04fbcb44f09b15f9cc0b5430e78744564b66004c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 11:56:40 +0300 Subject: [PATCH 23/88] fix(vscode): avoid writing null agent config fields Omit unset agent fields on create Delete cleared agent fields in VS Code config updates Cover null field removal with a regression test --- packages/ui/src/stores/useAgentsStore.ts | 6 ++-- .../vscode/src/bridge-config-runtime.test.js | 33 +++++++++++++++++++ packages/vscode/src/opencodeConfig.ts | 33 ++++++++++++++++++- packages/web/server/lib/opencode/agents.js | 5 ++- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 9b6420171d..d9bd2077b1 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -177,6 +177,8 @@ const SLOW_HEALTH_POLL_BASE_MS = 800; const SLOW_HEALTH_POLL_INCREMENT_MS = 200; const SLOW_HEALTH_POLL_MAX_MS = 2000; +const hasValue = (value: T | null | undefined): value is T => value !== null && value !== undefined; + export interface AgentDraft { name: string; scope: AgentScope; @@ -345,8 +347,8 @@ export const useAgentsStore = create()( if (config.description) agentConfig.description = config.description; if (config.model) agentConfig.model = config.model; if (config.variant) agentConfig.variant = config.variant; - if (config.temperature !== undefined) agentConfig.temperature = config.temperature ?? null; - if (config.top_p !== undefined) agentConfig.top_p = config.top_p ?? null; + if (hasValue(config.temperature)) agentConfig.temperature = config.temperature; + if (hasValue(config.top_p)) agentConfig.top_p = config.top_p; if (config.prompt) agentConfig.prompt = config.prompt; if (config.permission) agentConfig.permission = config.permission; if (config.disable !== undefined) agentConfig.disable = config.disable; diff --git a/packages/vscode/src/bridge-config-runtime.test.js b/packages/vscode/src/bridge-config-runtime.test.js index fef9b3de30..9cdabc23c5 100644 --- a/packages/vscode/src/bridge-config-runtime.test.js +++ b/packages/vscode/src/bridge-config-runtime.test.js @@ -52,6 +52,39 @@ afterEach(() => { const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); describe('VS Code config bridge plugin parity', () => { + test('removes agent fields when update payload sends null', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-')); + tempRoots.push(root); + const ctx = createCtx(root); + const configDir = path.join(root, '.opencode'); + const configPath = path.join(configDir, 'opencode.json'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + agent: { + build: { + variant: 'fast', + temperature: 0.3, + top_p: 0.8, + mode: 'subagent', + }, + }, + }, null, 2), 'utf8'); + + const updated = await handleConfigBridgeMessage({ + id: 'update-agent-null-fields', + type: 'api:config/agents', + payload: { + method: 'PATCH', + name: 'build', + directory: root, + body: { variant: null, temperature: null, top_p: null }, + }, + }, ctx, deps); + + expect(updated?.success).toBe(true); + expect(readJson(configPath).agent.build).toEqual({ mode: 'subagent' }); + }); + test('creates, lists, updates, and deletes project plugin entries', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugins-')); tempRoots.push(root); diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 9d9c8b5cf4..8478e1abdb 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -1605,12 +1605,30 @@ export const createAgent = (agentName: string, config: Record, } // Extract scope and prompt from config - scope is only used for path determination, not written to file - const { prompt, scope: _ignored, ...frontmatter } = config as Record & { prompt?: unknown; scope?: unknown }; + const { prompt, scope: _ignored, ...rawFrontmatter } = config as Record & { prompt?: unknown; scope?: unknown }; void _ignored; // Scope is only used for path determination + const frontmatter = Object.fromEntries( + Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined) + ); writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : ''); resetAgentLookupCache(globalAgentLookupCache); }; +const deleteAgentJsonField = (config: Record, agentName: string, field: string): boolean => { + const agentMap = config.agent as Record | undefined; + const current = agentMap?.[agentName] as Record | undefined; + if (!agentMap || !current || !(field in current)) return false; + + delete current[field]; + if (Object.keys(current).length === 0) { + delete agentMap[agentName]; + } + if (Object.keys(agentMap).length === 0) { + delete config.agent; + } + return true; +}; + export const updateAgent = (agentName: string, updates: Record, workingDirectory?: string) => { ensureDirs(); @@ -1757,6 +1775,19 @@ export const updateAgent = (agentName: string, updates: Record, const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined); const hasJsonField = Boolean(jsonSection?.[field] !== undefined); + if (value === null) { + if (hasMdField && mdData) { + delete mdData.frontmatter[field]; + mdModified = true; + } + + if (hasJsonField && deleteAgentJsonField(config, agentName, field)) { + jsonModified = true; + } + + continue; + } + // JSON takes precedence over md, so update JSON first if field exists there if (hasJsonField) { if (!config.agent) config.agent = {}; diff --git a/packages/web/server/lib/opencode/agents.js b/packages/web/server/lib/opencode/agents.js index 09b0e4787d..d2acff8918 100644 --- a/packages/web/server/lib/opencode/agents.js +++ b/packages/web/server/lib/opencode/agents.js @@ -409,7 +409,10 @@ function createAgent(agentName, config, workingDirectory, scope) { targetScope = AGENT_SCOPE.USER; } - const { prompt, scope: _scopeFromConfig, ...frontmatter } = config; + const { prompt, scope: _scopeFromConfig, ...rawFrontmatter } = config; + const frontmatter = Object.fromEntries( + Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined) + ); writeMdFile(targetPath, frontmatter, prompt || ''); console.log(`Created new agent: ${agentName} (scope: ${targetScope}, path: ${targetPath})`); From ea04cb4cefe0b3c78ac18d7c50db507445489773 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 12:12:16 +0300 Subject: [PATCH 24/88] chore: remove vacation notice from README --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index e9d870af8b..08ada71238 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,6 @@ [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) [![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) -> [!IMPORTANT] -> 🏖️ I'm on vacation from 18 Jun to 28 Jun. All issues and PRs will continue being reviewed after that. Thanks for the patience. - ## **OpenCode, everywhere.** Desktop. Browser. Phone. ### A rich interface for [OpenCode](https://opencode.ai). Review diffs, manage agents, run dev servers, and keep the big picture while your AI codes. From fb015d78e173fe372782dc4b024e71f007d3e7b5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 29 Jun 2026 12:19:28 +0300 Subject: [PATCH 25/88] fix: prevent embedded JSON examples from rendering as result cards Only parse full-message generated JSON results Keep markdown prose with JSON examples rendered normally Add regression coverage for embedded JSON examples --- .../message/parts/generatedJsonResult.test.ts | 40 +++++++++++++++++++ .../chat/message/parts/generatedJsonResult.ts | 18 +++------ 2 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts new file mode 100644 index 0000000000..f1f6cea566 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseGeneratedJsonResult } from './generatedJsonResult'; + +describe('parseGeneratedJsonResult', () => { + test('parses a full pull request JSON result', () => { + expect(parseGeneratedJsonResult('{"title":"Side task","body":"Details"}')).toEqual({ + kind: 'pr', + title: 'Side task', + body: 'Details', + raw: JSON.stringify({ title: 'Side task', body: 'Details' }, null, 2), + }); + }); + + test('parses a full fenced JSON result', () => { + expect(parseGeneratedJsonResult('```json\n{"subject":"Fix parser","highlights":["Narrow detection"]}\n```')).toEqual({ + kind: 'commit', + subject: 'Fix parser', + highlights: ['Narrow detection'], + raw: JSON.stringify({ subject: 'Fix parser', highlights: ['Narrow detection'] }, null, 2), + }); + }); + + test('ignores JSON examples embedded in markdown prose', () => { + const markdown = [ + 'Recommended endpoint:', + '', + '```json', + '{', + ' "title": "Side task",', + ' "prompt": "Investigate X"', + '}', + '```', + '', + 'This should stay markdown.', + ].join('\n'); + + expect(parseGeneratedJsonResult(markdown)).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts index 783a20ef24..2d95cb73eb 100644 --- a/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts +++ b/packages/ui/src/components/chat/message/parts/generatedJsonResult.ts @@ -16,21 +16,15 @@ export type GeneratedResult = GeneratedCommitResult | GeneratedPrResult; const parseJsonObjects = (value: string): Record[] => { const text = value.trim(); - const candidates = new Set(); + const candidates: string[] = []; - const fencedMatches = text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi); - for (const match of fencedMatches) { - if (match[1]) candidates.add(match[1].trim()); + const fencedMatch = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i); + if (fencedMatch?.[1]) { + candidates.push(fencedMatch[1].trim()); } - const firstObjectStart = text.indexOf('{'); - if (firstObjectStart >= 0) { - for (let end = text.length; end > firstObjectStart; end -= 1) { - if (text[end - 1] === '}') { - candidates.add(text.slice(firstObjectStart, end)); - break; - } - } + if (text.startsWith('{') && text.endsWith('}')) { + candidates.push(text); } const parsed: Record[] = []; From ec094e6dd9e9fa621c82567ac595d7c5dac7ed25 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 00:30:48 +0300 Subject: [PATCH 26/88] feat(desktop): support remote runtime headers --- packages/electron/main.mjs | 79 +++++++++++------ packages/electron/preload.mjs | 11 +++ packages/electron/runtime-request-headers.mjs | 16 ++++ .../electron/runtime-request-headers.test.mjs | 26 ++++++ .../desktop/DesktopHostSwitcher.tsx | 12 +-- .../remote-instances/RemoteInstancesPage.tsx | 78 ++++++++++++++++- packages/ui/src/lib/desktopHosts.test.ts | 85 ++++++++++++++++++- packages/ui/src/lib/desktopHosts.ts | 35 ++++++-- .../ui/src/lib/i18n/messages/en.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 6 ++ packages/ui/src/lib/runtime-auth.test.ts | 55 ++++++++++++ packages/ui/src/lib/runtime-auth.ts | 33 +++++++ packages/ui/src/lib/runtime-switch.ts | 7 +- packages/ui/src/lib/settings/search.ts | 2 +- packages/web/src/runtimeConfig.ts | 4 +- 23 files changed, 458 insertions(+), 45 deletions(-) create mode 100644 packages/electron/runtime-request-headers.mjs create mode 100644 packages/electron/runtime-request-headers.test.mjs diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 27312ef4f0..2a07debde7 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -13,6 +13,7 @@ import updaterPkg from 'electron-updater'; import { ElectronSshManager } from './ssh-manager.mjs'; import { createTrayController } from './tray.mjs'; import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -170,6 +171,7 @@ const state = { localOrigin: null, apiBaseUrl: null, clientToken: null, + requestHeaders: {}, bootOutcome: null, initScript: null, mainWindow: null, @@ -495,10 +497,11 @@ const shouldUseSameOriginDevProxy = (uiUrl, apiBaseUrl) => ( const buildRendererRuntimeConfig = (uiUrl, runtimeConfig = {}) => { const apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || ''); const clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || ''); + const requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}); if (shouldUseSameOriginDevProxy(uiUrl, apiBaseUrl)) { - return { apiBaseUrl: '', clientToken: '' }; + return { apiBaseUrl: '', clientToken: '', requestHeaders: {} }; } - return { apiBaseUrl, clientToken }; + return { apiBaseUrl, clientToken, requestHeaders }; }; const readDesktopLocalClientToken = () => { @@ -520,8 +523,9 @@ const readDesktopHostsConfig = () => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url; - return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}) }; + return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}), ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}) }; }) .filter(Boolean); @@ -544,12 +548,14 @@ const writeDesktopHostsConfig = async (config) => { if (!id || id === LOCAL_HOST_ID || !url) return null; const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url; const clientToken = sanitizeClientTokenForStorage(entry?.clientToken); + const requestHeaders = sanitizeRuntimeRequestHeaders(entry?.requestHeaders); return { id, label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url, url, apiUrl, ...(clientToken ? { clientToken } : {}), + ...(Object.keys(requestHeaders).length > 0 ? { requestHeaders } : {}), }; }) .filter(Boolean) @@ -704,7 +710,7 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => { } }; -const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { +const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}) => { const versionUrl = buildVersionUrl(url); if (!versionUrl) { throw new Error('Invalid URL'); @@ -712,7 +718,7 @@ const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => { const started = Date.now(); try { - const headers = { Accept: 'application/json' }; + const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' }; const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (token) { headers.Authorization = `Bearer ${token}`; @@ -1317,17 +1323,18 @@ const macosMajorVersion = () => { return major === 10 ? minor : major; }; -const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '') => { +const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '', requestHeaders = {}) => { const home = JSON.stringify(os.homedir() || ''); const local = JSON.stringify(localOrigin || ''); const apiBase = JSON.stringify(apiBaseUrl || ''); const token = JSON.stringify(clientToken || ''); + const headers = JSON.stringify(sanitizeRuntimeRequestHeaders(requestHeaders)); const packagedOrigin = JSON.stringify(packagedUiOrigin()); const macVersion = macosMajorVersion(); const outcome = JSON.stringify(bootOutcome ?? null); return [ '(function(){', - `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, + `try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_headers=${headers};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_RUNTIME_HEADERS__=__oc_headers;}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`, '}())', ].join(''); }; @@ -1694,10 +1701,12 @@ const switchToHostById = async (rawId) => { let targetUrl = null; let apiBaseUrl = null; let clientToken = ''; + let requestHeaders = {}; if (id === LOCAL_HOST_ID) { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin); apiBaseUrl = state.sidecarUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; } else { const host = config.hosts.find((entry) => entry.id === id); if (!host) { @@ -1707,6 +1716,7 @@ const switchToHostById = async (rawId) => { targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url; apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); } if (!targetUrl || !apiBaseUrl) { log.warn('[electron] deep-link host has no target URL:', id); @@ -1716,7 +1726,7 @@ const switchToHostById = async (rawId) => { ? { target: 'local', status: 'ok' } : { target: 'remote', status: 'ok', hostId: id, url: apiBaseUrl }; log.info('[electron] switching to host', { id, bootOutcome }); - await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const confirmConnectDeepLink = async (payload) => { @@ -1913,6 +1923,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const rendererRuntimeConfig = buildRendererRuntimeConfig(url, runtimeConfig); const desktopApiBaseUrl = rendererRuntimeConfig.apiBaseUrl; const desktopClientToken = rendererRuntimeConfig.clientToken; + const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32'; @@ -1949,6 +1960,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, `--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`, @@ -1969,8 +1981,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } const browserWindow = new BrowserWindow(options); browserWindow.__ocLabel = label || nextWindowLabel(); - browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken }; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; if (useSaved && saved.maximized) { @@ -2156,16 +2168,19 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = state.localOrigin = localOrigin; state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl; state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : ''; + state.requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || {}); state.bootOutcome = bootOutcome ?? null; const rendererRuntimeConfig = buildRendererRuntimeConfig(url, { apiBaseUrl: state.apiBaseUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }); state.initScript = buildInitScript( localOrigin, state.bootOutcome, rendererRuntimeConfig.apiBaseUrl, rendererRuntimeConfig.clientToken, + rendererRuntimeConfig.requestHeaders, ); const mainWindow = state.mainWindow; @@ -2189,8 +2204,8 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = const openMainWindow = async () => { if (!state.localOrigin) { - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); } const config = readDesktopHostsConfig(); @@ -2200,10 +2215,11 @@ const openMainWindow = async () => { : null; const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || ''; const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || ''; + const requestHeaders = sanitizeRuntimeRequestHeaders(host?.requestHeaders || {}); const targetUrl = host?.url && apiBaseUrl && !state.unreachableHosts.has(apiBaseUrl) ? (shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url) : localUiUrl; - return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken }); + return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); }; const createAdditionalWindow = async (url, runtimeConfig = {}) => { @@ -2242,12 +2258,14 @@ const getWindowRuntimeConfig = (browserWindow) => { const fallback = { apiBaseUrl: state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '', clientToken: state.clientToken || '', + requestHeaders: state.requestHeaders || {}, }; if (!browserWindow || browserWindow.isDestroyed()) return fallback; const config = browserWindow.__ocRuntimeConfig; return { apiBaseUrl: typeof config?.apiBaseUrl === 'string' ? config.apiBaseUrl : fallback.apiBaseUrl, clientToken: typeof config?.clientToken === 'string' ? config.clientToken : fallback.clientToken, + requestHeaders: sanitizeRuntimeRequestHeaders(config?.requestHeaders || fallback.requestHeaders), }; }; @@ -2255,6 +2273,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const effectiveRuntimeConfig = { apiBaseUrl: normalizeHostUrl(runtimeConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''), clientToken: sanitizeClientTokenForStorage(runtimeConfig.clientToken || state.clientToken || ''), + requestHeaders: sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {}), }; const sessionWindowKey = mode === 'session' && sessionId ? miniChatSessionWindowKey(effectiveRuntimeConfig, sessionId) : ''; if (mode === 'session' && sessionId) { @@ -2271,6 +2290,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj const desktopLocalOrigin = state.localOrigin || ''; const desktopApiBaseUrl = effectiveRuntimeConfig.apiBaseUrl || ''; const desktopClientToken = effectiveRuntimeConfig.clientToken || ''; + const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {}; const desktopHome = os.homedir() || ''; const desktopMacosMajor = String(macosMajorVersion()); // macOS vibrancy, on by default; users can disable it (Appearance settings). @@ -2297,6 +2317,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj `--openchamber-local-origin=${desktopLocalOrigin}`, `--openchamber-api-base-url=${desktopApiBaseUrl}`, `--openchamber-client-token=${desktopClientToken}`, + `--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`, `--openchamber-home=${desktopHome}`, `--openchamber-macos-major=${desktopMacosMajor}`, ], @@ -2311,7 +2332,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj }); browserWindow.__ocLabel = nextWindowLabel(); browserWindow.__ocRuntimeConfig = effectiveRuntimeConfig; - browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken); + browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocMiniChat = true; browserWindow.__ocMiniChatSessionId = sessionWindowKey; browserWindow.__ocPinned = false; @@ -2402,9 +2423,11 @@ const resolveMiniChatRuntimeConfig = (browserWindow, args = {}) => { const providedToken = sanitizeClientTokenForStorage(args.clientToken); const storedToken = targetUrl ? resolveStoredClientTokenForUrl(targetUrl) : ''; const windowToken = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.clientToken : ''; + const windowHeaders = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.requestHeaders : {}; return { apiBaseUrl: targetUrl, clientToken: providedToken || windowToken || storedToken || '', + requestHeaders: sanitizeRuntimeRequestHeaders(args.requestHeaders || windowHeaders || {}), }; }; @@ -2430,6 +2453,7 @@ const resolveInitialUrl = async () => { let initialUrl = localUiUrl; let apiBaseUrl = localUrl; let clientToken = readDesktopLocalClientToken(); + let requestHeaders = {}; let remoteProbe = null; const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || ''); @@ -2437,25 +2461,28 @@ const resolveInitialUrl = async () => { if (envTarget) { apiBaseUrl = envTarget; clientToken = ''; + requestHeaders = {}; initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget; } else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); if (host?.url) { apiBaseUrl = host.apiUrl || host.url; clientToken = host.clientToken || ''; + requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {}); initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url; } } if (apiBaseUrl && apiBaseUrl !== localUrl) { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000, clientToken, requestHeaders); if (remoteProbe.status === 'unreachable') { - remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000); + remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000, clientToken, requestHeaders); } if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); apiBaseUrl = localUrl; clientToken = readDesktopLocalClientToken(); + requestHeaders = {}; initialUrl = localUiUrl; } } @@ -2467,7 +2494,7 @@ const resolveInitialUrl = async () => { localAvailable, }); - return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken }; + return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken, requestHeaders }; }; const compareSemver = (left, right) => { @@ -3459,7 +3486,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { config: updatedConfig, localAvailable: Boolean(state.sidecarUrl || state.localOrigin), }); - state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken); + state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {}); log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome); return null; } @@ -3468,7 +3495,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return readDesktopLocalClientToken(); case 'desktop_host_probe': - return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || '')); + return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''), args.requestHeaders || {}); case 'desktop_remote_password_login': return loginRemoteAndIssueClientToken({ @@ -3658,6 +3685,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { let runtimeConfig = { apiBaseUrl: state.sidecarUrl || state.localOrigin || '', clientToken: readDesktopLocalClientToken(), + requestHeaders: {}, }; if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) { const host = config.hosts.find((entry) => entry.id === config.defaultHostId); @@ -3667,6 +3695,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { runtimeConfig = { apiBaseUrl: normalizeHostUrl(apiUrl), clientToken: sanitizeClientTokenForStorage(host.clientToken), + requestHeaders: sanitizeRuntimeRequestHeaders(host.requestHeaders), }; } } @@ -3682,8 +3711,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => { const config = readDesktopHostsConfig(); const providedToken = typeof args.clientToken === 'string' ? args.clientToken : ''; const clientToken = sanitizeClientTokenForStorage(providedToken) || resolveStoredClientTokenForUrl(targetUrl, config); + const requestHeaders = sanitizeRuntimeRequestHeaders(args.requestHeaders || config.hosts.find((host) => normalizeHostUrl(host.apiUrl || host.url) === targetUrl)?.requestHeaders || {}); let windowUrl = targetUrl; - const runtimeConfig = { apiBaseUrl: targetUrl, clientToken }; + const runtimeConfig = { apiBaseUrl: targetUrl, clientToken, requestHeaders }; if (shouldUsePackagedUi()) { windowUrl = buildPackagedUiUrl('/index.html'); } @@ -4468,10 +4498,11 @@ app.whenReady().then(async () => { } if (isBackgroundStart) { - const { localOrigin, bootOutcome } = await resolveInitialUrl(); + const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl(); state.localOrigin = localOrigin; state.bootOutcome = bootOutcome ?? null; - state.initScript = buildInitScript(localOrigin, state.bootOutcome); + state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); + state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders); log.info('[electron] started in background without window'); return; } @@ -4485,8 +4516,8 @@ app.whenReady().then(async () => { const initial = extractInitialDeepLinks(); if (initial.length > 0) handleDeepLinks(initial); - const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl(); - await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken }); + const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); + await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); // Notify renderer on OS wake-from-sleep so the SSE event pipeline can // reconnect immediately instead of waiting for the heartbeat watchdog. diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index bddbe344b1..6f7c792317 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -14,6 +14,7 @@ const readArgValue = (name) => { const localOrigin = readArgValue('--openchamber-local-origin'); const apiBaseUrl = readArgValue('--openchamber-api-base-url'); const clientToken = readArgValue('--openchamber-client-token'); +const runtimeHeadersRaw = readArgValue('--openchamber-runtime-headers'); const homeDirectory = readArgValue('--openchamber-home'); const macosMajorRaw = readArgValue('--openchamber-macos-major'); const macosMajor = Number.parseInt(macosMajorRaw, 10); @@ -61,6 +62,16 @@ if (clientToken && isLocalPage) { contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken); } +if (runtimeHeadersRaw && isLocalPage) { + try { + const runtimeHeaders = JSON.parse(runtimeHeadersRaw); + if (runtimeHeaders && typeof runtimeHeaders === 'object') { + contextBridge.exposeInMainWorld('__OPENCHAMBER_RUNTIME_HEADERS__', runtimeHeaders); + } + } catch { + } +} + // Home directory leaks the OS username — keep local-only. Remote pages // operate on the REMOTE server's filesystem, local home is irrelevant // (and would be misleading if consumed as a workspace hint). diff --git a/packages/electron/runtime-request-headers.mjs b/packages/electron/runtime-request-headers.mjs new file mode 100644 index 0000000000..4fb143bb98 --- /dev/null +++ b/packages/electron/runtime-request-headers.mjs @@ -0,0 +1,16 @@ +const isReservedRuntimeRequestHeaderName = (name) => { + return String(name || '').trim().toLowerCase() === 'authorization'; +}; + +export const sanitizeRuntimeRequestHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (isReservedRuntimeRequestHeaderName(name)) continue; + next[name] = value; + } + return next; +}; diff --git a/packages/electron/runtime-request-headers.test.mjs b/packages/electron/runtime-request-headers.test.mjs new file mode 100644 index 0000000000..9945a6b4cd --- /dev/null +++ b/packages/electron/runtime-request-headers.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs'; + +describe('sanitizeRuntimeRequestHeaders', () => { + test('preserves safe custom headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + ' CF-Access-Client-Id ': ' client-id ', + 'X-Custom-Header': 'value', + })).toEqual({ + 'CF-Access-Client-Id': 'client-id', + 'X-Custom-Header': 'value', + }); + }); + + test('drops invalid and reserved headers', () => { + expect(sanitizeRuntimeRequestHeaders({ + Authorization: 'Bearer proxy-token', + 'authorization': 'Bearer lower-token', + 'Bad:Name': 'value', + 'Bad\nName': 'value', + 'Bad-Value': 'line\nbreak', + Empty: '', + Good: 'ok', + })).toEqual({ Good: 'ok' }); + }); +}); diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 4ba3ccad85..269d4b90f6 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -424,7 +424,7 @@ export function DesktopHostSwitcherDialog({ return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const; } const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || ''); - const res = await desktopHostProbe(url, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const; }) ); @@ -492,7 +492,7 @@ export function DesktopHostSwitcherDialog({ if (!apiOrigin) return; setSwitchingHostId(host.id); const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || ''); - const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ ...prev, [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, @@ -504,7 +504,7 @@ export function DesktopHostSwitcherDialog({ return; } - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); onHostSwitched?.(); setSwitchingHostId(null); return; @@ -598,7 +598,7 @@ export function DesktopHostSwitcherDialog({ if (host.id !== LOCAL_HOST_ID && isDesktopShell()) { setSwitchingHostId(host.id); - const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); + const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 })); setStatusById((prev) => ({ ...prev, [host.id]: { status: probe.status, latencyMs: probe.latencyMs }, @@ -646,7 +646,7 @@ export function DesktopHostSwitcherDialog({ const url = resolved.persistedUrl; const label = (editLabel || redactSensitiveUrl(url)).trim(); - const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h)); + const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url, apiUrl: url } : h)); await persist(nextHosts, defaultHostId); cancelEdit(); if (resolved.redeemUrl) { @@ -663,7 +663,7 @@ export function DesktopHostSwitcherDialog({ const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host); if (!origin) return; const target = toNavigationUrl(origin); - desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null }).catch((err: unknown) => { + desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => { toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), { description: err instanceof Error ? err.message : String(err), }); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 638bfac30a..5bbeea9a5f 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -199,6 +199,36 @@ const formatLogLine = (line: string): string => { return `[${iso}] [${level}] ${message}`; }; +type HeaderDraft = { + id: string; + name: string; + value: string; +}; + +const createHeaderDraft = (name = '', value = ''): HeaderDraft => ({ + id: typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `header-${Date.now()}-${Math.random().toString(16).slice(2)}`, + name, + value, +}); + +const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization'; + +const buildRequestHeaders = (headers: HeaderDraft[]): Record | undefined => { + const next: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + const value = header.value.trim(); + if (name && value && !isReservedRequestHeaderName(name)) next[name] = value; + } + return Object.keys(next).length > 0 ? next : undefined; +}; + +const readRequestHeaderDrafts = (headers: Record | undefined): HeaderDraft[] => { + return Object.entries(headers || {}).map(([name, value]) => createHeaderDraft(name, value)); +}; + const navigateToUrl = (rawUrl: string): void => { const target = rawUrl.trim(); if (!target) { @@ -301,6 +331,7 @@ export const RemoteInstancesPage: React.FC = () => { const [directLabel, setDirectLabel] = React.useState(''); const [directUrl, setDirectUrl] = React.useState(''); const [directToken, setDirectToken] = React.useState(''); + const [directHeaders, setDirectHeaders] = React.useState([]); const [directConnectLink, setDirectConnectLink] = React.useState(''); const [directError, setDirectError] = React.useState(null); const [directAddDialogOpen, setDirectAddDialogOpen] = React.useState(false); @@ -309,6 +340,7 @@ export const RemoteInstancesPage: React.FC = () => { const [directEditLabel, setDirectEditLabel] = React.useState(''); const [directEditUrl, setDirectEditUrl] = React.useState(''); const [directEditToken, setDirectEditToken] = React.useState(''); + const [directEditHeaders, setDirectEditHeaders] = React.useState([]); const [remoteClients, setRemoteClients] = React.useState([]); const [remoteClientsLoading, setRemoteClientsLoading] = React.useState(false); const [remoteClientLabel, setRemoteClientLabel] = React.useState(''); @@ -374,16 +406,18 @@ export const RemoteInstancesPage: React.FC = () => { url, apiUrl: url, ...(directToken.trim() ? { clientToken: directToken.trim() } : {}), + ...(buildRequestHeaders(directHeaders) ? { requestHeaders: buildRequestHeaders(directHeaders) } : {}), }; await persistDirectHosts([host, ...directHosts], directDefaultHostId); setDirectLabel(''); setDirectUrl(''); setDirectToken(''); + setDirectHeaders([]); setDirectAddDialogOpen(false); if (resolved.redeemUrl) { navigateToUrl(resolved.redeemUrl); } - }, [directDefaultHostId, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); + }, [directDefaultHostId, directHeaders, directHosts, directLabel, directToken, directUrl, persistDirectHosts, t]); const importDirectConnectLink = React.useCallback(async () => { const payload = parseClientConnectionPayload(directConnectLink); @@ -427,6 +461,7 @@ export const RemoteInstancesPage: React.FC = () => { setDirectEditLabel(host.label); setDirectEditUrl(host.apiUrl || host.url); setDirectEditToken(host.clientToken || ''); + setDirectEditHeaders(readRequestHeaderDrafts(host.requestHeaders)); setDirectError(null); }, []); @@ -445,6 +480,7 @@ export const RemoteInstancesPage: React.FC = () => { url, apiUrl: url, clientToken: directEditToken.trim() || undefined, + requestHeaders: buildRequestHeaders(directEditHeaders), } : host); await persistDirectHosts(nextHosts, directDefaultHostId); @@ -452,7 +488,7 @@ export const RemoteInstancesPage: React.FC = () => { if (resolved.redeemUrl) { navigateToUrl(resolved.redeemUrl); } - }, [directDefaultHostId, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]); + }, [directDefaultHostId, directEditHeaders, directEditLabel, directEditToken, directEditUrl, directEditingId, directHosts, persistDirectHosts, t]); const createSshInstanceFromDialog = React.useCallback(async () => { const command = sshCommandDraft.trim(); @@ -1095,6 +1131,25 @@ export const RemoteInstancesPage: React.FC = () => { setDirectLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> setDirectToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+
+

{t('settings.remoteInstances.direct.headers.title')}

+

{t('settings.remoteInstances.direct.headers.description')}

+
+ {directHeaders.map((header) => ( +
+ setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} /> + setDirectHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} /> + +
+ ))} + +
@@ -1113,6 +1168,25 @@ export const RemoteInstancesPage: React.FC = () => { setDirectEditLabel(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.labelPlaceholder')} disabled={directSaving} /> setDirectEditUrl(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.urlPlaceholder')} disabled={directSaving} autoFocus /> setDirectEditToken(event.target.value)} placeholder={t('settings.remoteInstances.direct.field.tokenPlaceholder')} type="password" disabled={directSaving} /> +
+
+

{t('settings.remoteInstances.direct.headers.title')}

+

{t('settings.remoteInstances.direct.headers.description')}

+
+ {directEditHeaders.map((header) => ( +
+ setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, name: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.namePlaceholder')} disabled={directSaving} /> + setDirectEditHeaders((headers) => headers.map((item) => item.id === header.id ? { ...item, value: event.target.value } : item))} placeholder={t('settings.remoteInstances.direct.headers.field.valuePlaceholder')} type="password" disabled={directSaving} /> + +
+ ))} + +
diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index d5f4cc75c1..1a24853a40 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -1,5 +1,26 @@ import { describe, expect, test } from 'bun:test'; -import { redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; +import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; + +const withDesktopBridge = async (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + __OPENCHAMBER_DESKTOP__: { + invoke: handler, + }, + }, + }); + try { + return await run(); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } +}; describe('resolveDesktopHostUrl', () => { test('keeps regular host URLs unchanged', () => { @@ -32,3 +53,65 @@ describe('resolveDesktopHostUrl', () => { ); }); }); + +describe('desktop host runtime headers', () => { + test('parses persisted request headers from desktop config', async () => { + await withDesktopBridge(async (cmd) => { + expect(cmd).toBe('desktop_hosts_get'); + return { + hosts: [{ + id: 'remote-1', + label: 'Remote', + url: 'https://remote.example', + requestHeaders: { + ' CF-Access-Client-Id ': ' client-id ', + Authorization: 'Bearer should-not-be-read', + 'Bad:Name': 'bad', + }, + }], + defaultHostId: 'remote-1', + initialHostChoiceCompleted: true, + }; + }, async () => { + const config = await desktopHostsGet(); + expect(config.hosts[0]?.requestHeaders).toEqual({ + 'CF-Access-Client-Id': 'client-id', + }); + }); + }); + + test('passes request headers through host save and probe IPC calls', async () => { + const calls: Array<{ cmd: string; args: Record }> = []; + await withDesktopBridge(async (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === 'desktop_host_probe') return { status: 'ok', latencyMs: 7 }; + return null; + }, async () => { + const requestHeaders = { 'CF-Access-Client-Id': 'client-id' }; + await desktopHostsSet({ + hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders }], + defaultHostId: 'remote-1', + }); + const probe = await desktopHostProbe('https://remote.example', { requestHeaders }); + expect(probe).toEqual({ status: 'ok', latencyMs: 7 }); + }); + + expect(calls[0]).toEqual({ + cmd: 'desktop_hosts_set', + args: { + input: { + hosts: [{ id: 'remote-1', label: 'Remote', url: 'https://remote.example', requestHeaders: { 'CF-Access-Client-Id': 'client-id' } }], + defaultHostId: 'remote-1', + initialHostChoiceCompleted: undefined, + }, + }, + }); + expect(calls[1]).toEqual({ + cmd: 'desktop_host_probe', + args: { + url: 'https://remote.example', + requestHeaders: { 'CF-Access-Client-Id': 'client-id' }, + }, + }); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index 5dc39e9637..a566bddb5a 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -2,6 +2,25 @@ import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop'; type DesktopInvoke = (cmd: string, args?: Record) => Promise; +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const isReservedRequestHeaderName = (name: string): boolean => name.trim().toLowerCase() === 'authorization'; + +const sanitizeRequestHeaders = (headers: unknown): Record | undefined => { + if (!isRecord(headers)) return undefined; + const next: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const name = key.trim(); + const headerValue = typeof value === 'string' ? value.trim() : ''; + if (!name || !headerValue || /[\r\n:]/.test(name) || /[\r\n]/.test(headerValue)) continue; + if (isReservedRequestHeaderName(name)) continue; + next[name] = headerValue; + } + return Object.keys(next).length > 0 ? next : undefined; +}; + export type DesktopHost = { id: string; label: string; @@ -11,6 +30,8 @@ export type DesktopHost = { apiUrl?: string; /** Remote client bearer token for packaged-client API access. */ clientToken?: string; + /** Extra headers for desktop runtime API requests. */ + requestHeaders?: Record; }; export type DesktopHostsConfig = { @@ -135,10 +156,6 @@ export const locationMatchesHost = (locationHref: string, hostUrl: string): bool } }; -const isRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null; -}; - const readString = (obj: Record, key: string): string | null => { const val = obj[key]; return typeof val === 'string' ? val : null; @@ -156,6 +173,7 @@ const parseHost = (value: unknown): DesktopHost | null => { const url = readString(value, 'url'); const apiUrl = readString(value, 'apiUrl') || readString(value, 'api_url'); const clientToken = readString(value, 'clientToken') || readString(value, 'client_token'); + const requestHeaders = sanitizeRequestHeaders(value.requestHeaders); if (!id || !label || !url) return null; return { id, @@ -163,6 +181,7 @@ const parseHost = (value: unknown): DesktopHost | null => { url, ...(apiUrl ? { apiUrl } : {}), ...(clientToken ? { clientToken } : {}), + ...(requestHeaders ? { requestHeaders } : {}), }; }; @@ -226,13 +245,13 @@ export const desktopLocalClientTokenGet = async (): Promise => { return typeof raw === 'string' ? raw.trim() : ''; }; -export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null }): Promise => { +export const desktopHostProbe = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) { return { status: 'unreachable', latencyMs: 0 }; } - const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined }); + const raw = await invoke('desktop_host_probe', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined }); if (!isRecord(raw)) { return { status: 'unreachable', latencyMs: 0 }; } @@ -247,8 +266,8 @@ export const desktopHostProbe = async (url: string, options?: { clientToken?: st return { status, latencyMs }; }; -export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null }): Promise => { +export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientToken?: string | null; requestHeaders?: Record | null }): Promise => { const invoke = getInvoke(); if (!invoke) return; - await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined }); + await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined }); }; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2233aa6519..0ae24ab19d 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -258,6 +258,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Connection token (optional for trusted local servers)', 'settings.remoteInstances.direct.note': 'Connection tokens are saved on this device and used only when this app connects to that server.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Add Server', 'settings.remoteInstances.direct.import.description': 'Paste a connection link from another OpenChamber server.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9d5bd6e41c..7318c40473 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexión (opcional para servidores locales de confianza)", "settings.remoteInstances.direct.note": "Los tokens de conexión se guardan en este dispositivo y solo se usan cuando esta app se conecta a ese servidor.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Añadir servidor", "settings.remoteInstances.direct.import.description": "Pega un enlace de conexión de otro servidor de OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 010e89dc4e..d41c9aaf51 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1729,6 +1729,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://hôte:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token de connexion (facultatif pour les serveurs locaux de confiance)', 'settings.remoteInstances.direct.note': 'Les tokens de connexion sont enregistrés sur cet appareil et utilisés uniquement lorsque cette application se connecte à ce serveur.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Ajouter le serveur', 'settings.remoteInstances.direct.import.description': 'Collez un lien de connexion provenant d’un autre serveur OpenChamber.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index f9b73858c8..6c329cb9b1 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -258,6 +258,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '接続 Token(信頼できるローカルサーバーでは任意)', 'settings.remoteInstances.direct.note': '接続 Token はこのデバイスに保存され、このアプリがそのサーバーに接続するときにのみ使用されます。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'サーバーを追加', 'settings.remoteInstances.direct.import.description': '別の OpenChamber サーバーからの接続リンクを貼り付けてください。', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 152f020509..270ca64074 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '연결 토큰(신뢰할 수 있는 로컬 서버는 선택 사항)', 'settings.remoteInstances.direct.note': '연결 토큰은 이 기기에 저장되며 이 앱이 해당 서버에 연결할 때만 사용됩니다.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '서버 추가', 'settings.remoteInstances.direct.import.description': '다른 OpenChamber 서버에서 만든 연결 링크를 붙여넣으세요.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3daa022470..5734196a3d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1434,6 +1434,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Token połączenia (opcjonalny dla zaufanych serwerów lokalnych)', 'settings.remoteInstances.direct.note': 'Tokeny połączenia są zapisywane na tym urządzeniu i używane tylko wtedy, gdy ta aplikacja łączy się z danym serwerem.', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': 'Dodaj serwer', 'settings.remoteInstances.direct.import.description': 'Wklej link połączenia z innego serwera OpenChamber.', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index f9eb5f0322..13ff5d0e2c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Token de conexão (opcional para servidores locais confiáveis)", "settings.remoteInstances.direct.note": "Os tokens de conexão ficam salvos neste dispositivo e são usados apenas quando este app se conecta a esse servidor.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Adicionar servidor", "settings.remoteInstances.direct.import.description": "Cole um link de conexão de outro servidor OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8b351bfc20..6464e347ca 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { "settings.remoteInstances.direct.field.urlPlaceholder": "https://host:port", "settings.remoteInstances.direct.field.tokenPlaceholder": "Токен підключення (необов’язково для довірених локальних серверів)", "settings.remoteInstances.direct.note": "Токени підключення зберігаються на цьому пристрої й використовуються лише коли цей застосунок підключається до відповідного сервера.", + "settings.remoteInstances.direct.headers.title": "Additional headers", + "settings.remoteInstances.direct.headers.description": "Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.", + "settings.remoteInstances.direct.headers.field.namePlaceholder": "Header name", + "settings.remoteInstances.direct.headers.field.valuePlaceholder": "Header value", + "settings.remoteInstances.direct.headers.actions.add": "Add header", + "settings.remoteInstances.direct.headers.removeAria": "Remove header", "settings.remoteInstances.direct.actions.add": "Додати сервер", "settings.remoteInstances.direct.import.description": "Вставте посилання для підключення з іншого сервера OpenChamber.", "settings.remoteInstances.direct.import.placeholder": "openchamber://connect?...", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index e6a959d59e..ccd1b42abe 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -225,6 +225,12 @@ export const settingsDict = { 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', 'settings.remoteInstances.direct.field.tokenPlaceholder': '连接令牌(受信任的本地服务器可选)', 'settings.remoteInstances.direct.note': '连接令牌会保存在此设备上,并且只在此应用连接到该服务器时使用。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '添加服务器', 'settings.remoteInstances.direct.import.description': '粘贴来自另一个 OpenChamber 服务器的连接链接。', 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 06989c748a..835419c378 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -231,6 +231,12 @@ 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://openchamber.example.com', 'settings.remoteInstances.direct.field.tokenPlaceholder': '用戶端 token(可選)', 'settings.remoteInstances.direct.note': '直接連線假設遠端伺服器已在執行並可從此裝置存取。', + 'settings.remoteInstances.direct.headers.title': 'Additional headers', + 'settings.remoteInstances.direct.headers.description': 'Optional HTTP headers for desktop API requests. Authorization is reserved for the connection token.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header value', + 'settings.remoteInstances.direct.headers.actions.add': 'Add header', + 'settings.remoteInstances.direct.headers.removeAria': 'Remove header', 'settings.remoteInstances.direct.actions.add': '新增直接連線', 'settings.remoteInstances.direct.import.description': '貼上 connect-url 輸出或 openchamber://connect 連結來匯入。', 'settings.remoteInstances.direct.import.placeholder': '貼上連線連結', diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts index 270acc218d..f5cbb58dff 100644 --- a/packages/ui/src/lib/runtime-auth.test.ts +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test'; import { buildRuntimeAuthHeaders, clearRuntimeAuthCredentialProvider, + clearRuntimeUrlAuthToken, getRuntimeBearerTokenSync, + refreshRuntimeUrlAuthToken, setRuntimeAuthCredentialProvider, setRuntimeBearerToken, + setRuntimeExtraHeaders, } from './runtime-auth'; describe('runtime auth headers', () => { @@ -60,4 +63,56 @@ describe('runtime auth headers', () => { } } }); + + test('adds runtime extra headers without overriding bearer authorization', async () => { + try { + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders({ + 'CF-Access-Client-Id': 'client-id', + Authorization: 'Bearer proxy-token', + }); + + const headers = await buildRuntimeAuthHeaders(); + + expect(headers.get('CF-Access-Client-Id')).toBe('client-id'); + expect(headers.get('Authorization')).toBe('Bearer runtime-token'); + } finally { + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); + + test('sends runtime extra headers when minting URL auth tokens', async () => { + const previousFetch = globalThis.fetch; + let seenUrl = ''; + let seenHeaders = new Headers(); + try { + clearRuntimeUrlAuthToken(); + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders({ + 'CF-Access-Client-Id': 'client-id', + Authorization: 'Bearer proxy-token', + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seenUrl = String(input); + seenHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const token = await refreshRuntimeUrlAuthToken('https://runtime.example'); + + expect(token).toBe('url-token'); + expect(seenUrl).toBe('https://runtime.example/auth/url-token'); + expect(seenHeaders.get('CF-Access-Client-Id')).toBe('client-id'); + expect(seenHeaders.get('Authorization')).toBe('Bearer runtime-token'); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); }); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index 811a8232c6..b02fd8f5f0 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -6,6 +6,7 @@ export type RuntimeAuthCredentialProvider = () => RuntimeAuthCredential | Promis let credentialProvider: RuntimeAuthCredentialProvider = () => null; let runtimeBearerToken = ''; +let runtimeExtraHeaders: Record = {}; let runtimeUrlAuthToken = ''; let runtimeUrlAuthTokenExpiresAt = 0; let runtimeUrlAuthRefreshPromise: Promise | null = null; @@ -13,6 +14,18 @@ let runtimeAuthGeneration = 0; const URL_AUTH_REFRESH_SKEW_MS = 10_000; +const isReservedRuntimeExtraHeaderName = (name: string): boolean => name.toLowerCase() === 'authorization'; + +const sanitizeRuntimeExtraHeaders = (headers: Record | null | undefined): Record => { + const next: Record = {}; + for (const [key, value] of Object.entries(headers || {})) { + const name = key.trim(); + const headerValue = value.trim(); + if (name && headerValue && !isReservedRuntimeExtraHeaderName(name)) next[name] = headerValue; + } + return next; +}; + const normalizeBearerToken = (token: string | null | undefined): string => { if (typeof token !== 'string') return ''; return token.trim(); @@ -74,6 +87,20 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void => credentialProvider = () => normalized ? { type: 'bearer', token: normalized } : null; }; +export const setRuntimeExtraHeaders = (headers: Record | null | undefined): void => { + // These headers are for runtime HTTP fetches and URL-token minting. Browser-owned + // realtime transports (EventSource/WebSocket) cannot attach arbitrary headers. + runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers); + resetRuntimeAuthGeneration(); +}; + +export const getRuntimeExtraHeadersSync = (): Record => { + if (Object.keys(runtimeExtraHeaders).length > 0) return runtimeExtraHeaders; + if (typeof window === 'undefined') return {}; + const injected = (window as typeof window & { __OPENCHAMBER_RUNTIME_HEADERS__?: Record }).__OPENCHAMBER_RUNTIME_HEADERS__; + return injected && typeof injected === 'object' ? sanitizeRuntimeExtraHeaders(injected) : {}; +}; + export const getRuntimeBearerTokenSync = (): string => runtimeBearerToken || readInjectedBearerToken(); export const setRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { @@ -127,6 +154,9 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => const refreshPromise = (async () => { const credential = await getRuntimeAuthCredential(); const headers = new Headers(); + for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) { + headers.set(key, value); + } if (credential?.type === 'bearer') { headers.set('Authorization', `Bearer ${credential.token}`); } @@ -257,6 +287,9 @@ export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void) export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise => { const next = new Headers(headers); + for (const [key, value] of Object.entries(getRuntimeExtraHeadersSync())) { + if (!next.has(key)) next.set(key, value); + } if (next.has('Authorization')) { return next; } diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 6d8084d6c9..6cb02dfdc4 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@/lib/runtime-auth'; +import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@/lib/runtime-auth'; import { configureRuntimeUrlResolver } from '@/lib/runtime-url'; export type RuntimeEndpointChangedDetail = { @@ -68,7 +68,7 @@ export const initializeRuntimeEndpoint = (options: { apiBaseUrl?: string | null; activeRuntimeKey = options.runtimeKey?.trim() || (sameOrigin(apiBaseUrl, readInjectedLocalOrigin()) ? 'local' : normalizeRuntimeUrlKey(apiBaseUrl)); }; -export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null }): void => { +export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken?: string | null; runtimeKey?: string | null; requestHeaders?: Record | null }): void => { const apiBaseUrl = options.apiBaseUrl.trim(); const previousApiBaseUrl = getRuntimeApiBaseUrl(); const previousRuntimeKey = getRuntimeKey(); @@ -79,11 +79,14 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken const runtimeWindow = window as typeof window & { __OPENCHAMBER_API_BASE_URL__?: string; __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; }; runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; + runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined; } configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); + setRuntimeExtraHeaders(options.requestHeaders || null); setRuntimeBearerToken(options.clientToken || null); void refreshRuntimeUrlAuthToken(apiBaseUrl).catch(() => {}); if (typeof window !== 'undefined') { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 89032db3ea..7c97b37738 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -404,7 +404,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'remote-instances', titleKey: 'settings.remoteInstances.direct.title', descriptionKey: 'settings.remoteInstances.direct.description', - keywords: ['server url', 'connection token', 'import link', 'host switcher'], + keywords: ['server url', 'connection token', 'import link', 'host switcher', 'additional headers', 'request headers', 'cloudflare access', 'service token'], isAvailable: (ctx) => ctx.isDesktop, }, { diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index 3d18044e4e..ba91ca9b28 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken } from '@openchamber/ui/lib/runtime-auth'; +import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -17,6 +17,7 @@ declare global { interface Window { __OPENCHAMBER_API_BASE_URL__?: string; __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; __OPENCHAMBER_LOCAL_ORIGIN__?: string; } } @@ -41,6 +42,7 @@ export const createConfiguredWebAPIs = () => { runtimeKey: sameOrigin(apiBaseUrl, localOrigin) ? 'local' : null, }); setRuntimeBearerToken(clientToken || null); + setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); installRuntimeFetchBridge(); return createWebAPIs({ urls }); From 181764b00f85370b1a3e57073250daf5243d6b59 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 01:21:42 +0300 Subject: [PATCH 27/88] feat(desktop): proxy realtime requests with runtime headers --- packages/electron/main.mjs | 4 + packages/ui/src/lib/runtime-auth.ts | 70 +++++ packages/ui/src/lib/runtime-url.test.ts | 50 +++- packages/ui/src/lib/runtime-url.ts | 39 ++- packages/web/server/index.js | 23 +- packages/web/server/lib/realtime-proxy.js | 279 ++++++++++++++++++ .../web/server/lib/realtime-proxy.test.js | 259 ++++++++++++++++ packages/web/server/lib/ui-auth/ui-auth.js | 2 + packages/web/src/runtimeConfig.ts | 5 +- 9 files changed, 724 insertions(+), 7 deletions(-) create mode 100644 packages/web/server/lib/realtime-proxy.js create mode 100644 packages/web/server/lib/realtime-proxy.test.js diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 2a07debde7..1272ec3a7a 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1204,6 +1204,10 @@ const spawnLocalServer = async () => { apiOnly: false, onDesktopNotification: (payload) => maybeShowNativeNotification(payload), getIsWindowFocused: isAnyWindowFocused, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl: state.apiBaseUrl || '', + requestHeaders: sanitizeRuntimeRequestHeaders(state.requestHeaders || {}), + }), }); const port = handle.getPort(); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index b02fd8f5f0..efd1cd938e 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -10,6 +10,9 @@ let runtimeExtraHeaders: Record = {}; let runtimeUrlAuthToken = ''; let runtimeUrlAuthTokenExpiresAt = 0; let runtimeUrlAuthRefreshPromise: Promise | null = null; +let localRuntimeUrlAuthToken = ''; +let localRuntimeUrlAuthTokenExpiresAt = 0; +let localRuntimeUrlAuthRefreshPromise: Promise | null = null; let runtimeAuthGeneration = 0; const URL_AUTH_REFRESH_SKEW_MS = 10_000; @@ -58,6 +61,8 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri export const clearRuntimeUrlAuthToken = (): void => { runtimeUrlAuthToken = ''; runtimeUrlAuthTokenExpiresAt = 0; + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; }; const resetRuntimeAuthGeneration = (): void => { @@ -120,6 +125,17 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires } }; +export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => { + const normalized = normalizeBearerToken(token); + if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + return; + } + localRuntimeUrlAuthToken = normalized; + localRuntimeUrlAuthTokenExpiresAt = expiresAt; +}; + const readValidRuntimeUrlAuthTokenSync = (): string => { if (!runtimeUrlAuthToken || runtimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { clearRuntimeUrlAuthToken(); @@ -128,6 +144,15 @@ const readValidRuntimeUrlAuthTokenSync = (): string => { return runtimeUrlAuthToken; }; +const readValidLocalRuntimeUrlAuthTokenSync = (): string => { + if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + return ''; + } + return localRuntimeUrlAuthToken; +}; + export const getRuntimeUrlAuthTokenSync = (): string => { const token = readValidRuntimeUrlAuthTokenSync(); if (!token && (getRuntimeBearerTokenSync() || typeof window !== 'undefined')) { @@ -136,6 +161,14 @@ export const getRuntimeUrlAuthTokenSync = (): string => { return token; }; +export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => { + const token = readValidLocalRuntimeUrlAuthTokenSync(); + if (!token && localOrigin && typeof window !== 'undefined') { + void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); + } + return token; +}; + const getRuntimeAuthCredential = async (): Promise => { const credential = await credentialProvider(); const token = credential?.type === 'bearer' @@ -193,6 +226,37 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise => return runtimeUrlAuthRefreshPromise; }; +const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise => { + if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise; + const refreshPromise = (async () => { + const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), { + method: 'POST', + credentials: 'include', + }); + if (!response.ok) { + localRuntimeUrlAuthToken = ''; + localRuntimeUrlAuthTokenExpiresAt = 0; + throw new Error(`Failed to mint local runtime URL auth token (${response.status})`); + } + const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null; + const token = typeof payload?.token === 'string' ? payload.token.trim() : ''; + const expiresAt = typeof payload?.expiresAt === 'number' ? payload.expiresAt : 0; + if (!token || !Number.isFinite(expiresAt)) { + throw new Error('Local runtime URL auth token response was invalid'); + } + localRuntimeUrlAuthToken = token; + localRuntimeUrlAuthTokenExpiresAt = expiresAt; + return token; + })(); + const trackedPromise = refreshPromise.finally(() => { + if (localRuntimeUrlAuthRefreshPromise === trackedPromise) { + localRuntimeUrlAuthRefreshPromise = null; + } + }); + localRuntimeUrlAuthRefreshPromise = trackedPromise; + return localRuntimeUrlAuthRefreshPromise; +}; + // Returns a valid token without a network call, minting only when the current // token is missing or already inside the skew window. export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise => { @@ -201,6 +265,12 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr return mintRuntimeUrlAuthToken(apiBaseUrl); }; +export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise => { + const existing = readValidLocalRuntimeUrlAuthTokenSync(); + if (existing) return existing; + return mintLocalRuntimeUrlAuthToken(localOrigin); +}; + // ── Proactive URL auth token refresh ────────────────────────────────────── // The url token has a short server TTL. Instead of each consumer minting on its // own timer (and clearing the shared token, which 401s other consumers during diff --git a/packages/ui/src/lib/runtime-url.test.ts b/packages/ui/src/lib/runtime-url.test.ts index 2b30836cc4..fffceec4cd 100644 --- a/packages/ui/src/lib/runtime-url.test.ts +++ b/packages/ui/src/lib/runtime-url.test.ts @@ -5,7 +5,7 @@ import { getRuntimeUrlResolver, setRuntimeUrlResolver, } from './runtime-url'; -import { setRuntimeBearerToken, setRuntimeUrlAuthToken } from './runtime-auth'; +import { setLocalRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders, setRuntimeUrlAuthToken } from './runtime-auth'; describe('createRuntimeUrlResolver', () => { const withWindow = (value: unknown, callback: () => T): T => { @@ -76,6 +76,54 @@ describe('createRuntimeUrlResolver', () => { }); }); + test('routes realtime URLs through local desktop proxy when runtime headers are configured', () => { + setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); + try { + withWindow({ + location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, + __OPENCHAMBER_API_BASE_URL__: 'https://remote.example', + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123', + }, () => { + const urls = createRuntimeUrlResolver({}); + const sse = new URL(urls.sse('/api/global/event')); + const ws = new URL(urls.websocket('/api/global/event/ws')); + + expect(sse.origin).toBe('http://127.0.0.1:57123'); + expect(sse.pathname).toBe('/api/openchamber/realtime-proxy/sse'); + expect(sse.searchParams.get('url')).toBe('https://remote.example/api/global/event'); + expect(ws.origin).toBe('ws://127.0.0.1:57123'); + expect(ws.pathname).toBe('/api/openchamber/realtime-proxy/ws'); + expect(ws.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws'); + }); + } finally { + setRuntimeExtraHeaders(null); + } + }); + + test('adds local URL auth token to desktop realtime proxy URL', () => { + setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' }); + setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000); + setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000); + try { + withWindow({ + location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, + __OPENCHAMBER_API_BASE_URL__: 'https://remote.example', + __OPENCHAMBER_LOCAL_ORIGIN__: 'http://127.0.0.1:57123', + }, () => { + const urls = createRuntimeUrlResolver({}); + const sse = new URL(urls.sse('/api/global/event')); + const target = new URL(sse.searchParams.get('url') || ''); + + expect(sse.searchParams.get('oc_url_token')).toBe('local-url-token'); + expect(target.searchParams.get('oc_url_token')).toBe('remote-url-token'); + }); + } finally { + setRuntimeExtraHeaders(null); + setRuntimeUrlAuthToken(null, null); + setLocalRuntimeUrlAuthToken(null, null); + } + }); + test('reads injected desktop API base URL at call time', () => { withWindow({ location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' }, diff --git a/packages/ui/src/lib/runtime-url.ts b/packages/ui/src/lib/runtime-url.ts index ba3797c0b6..c27930b818 100644 --- a/packages/ui/src/lib/runtime-url.ts +++ b/packages/ui/src/lib/runtime-url.ts @@ -1,4 +1,4 @@ -import { getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; +import { getLocalRuntimeUrlAuthTokenSync, getRuntimeExtraHeadersSync, getRuntimeUrlAuthTokenSync } from '@/lib/runtime-auth'; type QueryValue = string | number | boolean | null | undefined; @@ -39,6 +39,14 @@ const readInjectedApiBaseUrl = (): string => { return normalizeBaseUrl(injected); }; +const readInjectedLocalOrigin = (): string => { + if (typeof window === 'undefined') return ''; + const injected = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__; + return normalizeBaseUrl(injected); +}; + +const hasRuntimeExtraHeaders = (): boolean => Object.keys(getRuntimeExtraHeadersSync()).length > 0; + const currentHref = (config: RuntimeUrlConfig): string => { const configured = config.currentHref?.(); if (configured) return configured; @@ -110,6 +118,25 @@ const toWebSocketUrl = (candidate: string, config: RuntimeUrlConfig): string => return url.toString(); }; +const toRealtimeProxyUrl = (kind: 'sse' | 'ws', targetUrl: string, config: RuntimeUrlConfig): string | null => { + if (!hasRuntimeExtraHeaders()) return null; + const localOrigin = readInjectedLocalOrigin(); + if (!localOrigin) return null; + try { + const proxy = new URL(`/api/openchamber/realtime-proxy/${kind === 'sse' ? 'sse' : 'ws'}`, `${localOrigin}/`); + proxy.searchParams.set('url', targetUrl); + const localToken = getLocalRuntimeUrlAuthTokenSync(localOrigin); + if (localToken) proxy.searchParams.set('oc_url_token', localToken); + if (kind === 'ws') { + proxy.protocol = proxy.protocol === 'https:' ? 'wss:' : 'ws:'; + return toWebSocketUrl(proxy.toString(), config); + } + return proxy.toString(); + } catch { + return null; + } +}; + export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): RuntimeUrlResolver => { const configuredApiBaseUrl = normalizeBaseUrl(config.apiBaseUrl); const configuredRealtimeBaseUrl = normalizeBaseUrl(config.realtimeBaseUrl); @@ -131,8 +158,14 @@ export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): Runtime allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined, outsideFileGrant: options?.outsideFileGrant, }), - sse: (path, query) => withUrlAuth(realtime(path, query)), - websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config), + sse: (path, query) => { + const target = withUrlAuth(realtime(path, query)); + return toRealtimeProxyUrl('sse', target, config) || target; + }, + websocket: (path, query) => { + const target = toWebSocketUrl(withUrlAuth(realtime(path, query)), config); + return toRealtimeProxyUrl('ws', target, config) || target; + }, }; }; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4f7339dc59..21238b5e90 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -84,6 +84,7 @@ import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.j import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js'; import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js'; +import { attachRealtimeProxy } from './lib/realtime-proxy.js'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import webPush from 'web-push'; @@ -135,9 +136,14 @@ const SSE_PATH_PREFIXES = [ '/api/global/event', '/api/notifications/stream', '/api/openchamber/events', + '/api/openchamber/realtime-proxy/sse', ]; function shouldSkipCompression(req, res) { + if (process.env.OPENCHAMBER_RUNTIME === 'desktop') { + return true; + } + if (headerIncludesEventStream(req.headers.accept)) { return true; } @@ -1087,6 +1093,9 @@ async function main(options = {}) { if (typeof options.getIsWindowFocused === 'function') { notificationTriggerRuntime.setGetIsWindowFocused(options.getIsWindowFocused); } + const getDesktopRuntimeConfig = typeof options.getDesktopRuntimeConfig === 'function' + ? options.getDesktopRuntimeConfig + : null; console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`); @@ -1132,6 +1141,7 @@ async function main(options = {}) { })); expressApp = app; server = http.createServer(app); + let realtimeProxyRuntime = { stop: () => {} }; const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, { process, @@ -1202,6 +1212,13 @@ async function main(options = {}) { setAutoAcceptSession, }); uiAuthController = bootstrapResult.uiAuthController; + realtimeProxyRuntime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig, + getUiAuthController: () => uiAuthController, + isRequestOriginAllowed, + }); const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port); const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext; @@ -1341,8 +1358,10 @@ async function main(options = {}) { port: managed ? openCodePort : null, }; }, - stop: (shutdownOptions = {}) => - gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }) + stop: (shutdownOptions = {}) => { + realtimeProxyRuntime.stop(); + return gracefulShutdown({ exitProcess: shutdownOptions.exitProcess ?? false }); + } }; } diff --git a/packages/web/server/lib/realtime-proxy.js b/packages/web/server/lib/realtime-proxy.js new file mode 100644 index 0000000000..7bcf49f24a --- /dev/null +++ b/packages/web/server/lib/realtime-proxy.js @@ -0,0 +1,279 @@ +import { WebSocket, WebSocketServer } from 'ws'; + +const PROXY_SSE_PATH = '/api/openchamber/realtime-proxy/sse'; +const PROXY_WS_PATH = '/api/openchamber/realtime-proxy/ws'; + +const isAllowedSsePath = (pathname) => { + return pathname === '/api/event' + || pathname === '/api/global/event' + || pathname === '/api/openchamber/events' + || pathname === '/api/notifications/stream' + || /^\/api\/terminal\/[^/]+\/stream$/.test(pathname); +}; + +const isAllowedWebSocketPath = (pathname) => { + return pathname === '/api/event/ws' + || pathname === '/api/global/event/ws' + || pathname === '/api/terminal/ws'; +}; + +const normalizeBaseUrl = (value) => { + if (typeof value !== 'string') return ''; + return value.trim().replace(/\/+$/, ''); +}; + +const sanitizeHeaders = (headers) => { + if (!headers || typeof headers !== 'object') return {}; + const next = {}; + for (const [rawName, rawValue] of Object.entries(headers)) { + const name = typeof rawName === 'string' ? rawName.trim() : ''; + const value = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!name || !value || /[\r\n:]/.test(name) || /[\r\n]/.test(value)) continue; + if (name.toLowerCase() === 'authorization') continue; + next[name] = value; + } + return next; +}; + +const hasHeaders = (headers) => Object.keys(headers).length > 0; + +const getTargetParam = (req) => { + let raw = typeof req.query?.url === 'string' ? req.query.url : ''; + if (!raw) { + try { + raw = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('url') || ''; + } catch { + raw = ''; + } + } + if (!raw) return null; + try { + return new URL(raw); + } catch { + return null; + } +}; + +const urlsMatchRuntime = (target, apiBaseUrl) => { + const base = normalizeBaseUrl(apiBaseUrl); + if (!base) return false; + try { + const baseUrl = new URL(base); + const targetForCompare = new URL(target.toString()); + if (targetForCompare.protocol === 'ws:') targetForCompare.protocol = 'http:'; + if (targetForCompare.protocol === 'wss:') targetForCompare.protocol = 'https:'; + return targetForCompare.origin === baseUrl.origin; + } catch { + return false; + } +}; + +const protocolMatchesProxyType = (target, type) => { + if (type === 'ws') return target.protocol === 'ws:' || target.protocol === 'wss:'; + return target.protocol === 'http:' || target.protocol === 'https:'; +}; + +const pathMatchesProxyType = (target, type) => { + return type === 'ws' ? isAllowedWebSocketPath(target.pathname) : isAllowedSsePath(target.pathname); +}; + +const resolveProxyTarget = (req, getDesktopRuntimeConfig, type) => { + const config = typeof getDesktopRuntimeConfig === 'function' ? getDesktopRuntimeConfig() : null; + const requestHeaders = sanitizeHeaders(config?.requestHeaders); + const apiBaseUrl = normalizeBaseUrl(config?.apiBaseUrl); + const target = getTargetParam(req); + if (!target || !apiBaseUrl || !hasHeaders(requestHeaders)) return null; + if (!protocolMatchesProxyType(target, type)) return null; + if (!pathMatchesProxyType(target, type)) return null; + if (!urlsMatchRuntime(target, apiBaseUrl)) return null; + return { target, requestHeaders }; +}; + +const safeHeader = (headers, name) => { + const value = headers?.[name.toLowerCase()]; + if (Array.isArray(value)) return value.find((item) => typeof item === 'string' && item.trim()) || ''; + return typeof value === 'string' ? value.trim() : ''; +}; + +const buildSseRequestHeaders = (req, requestHeaders) => { + const headers = {}; + const accept = safeHeader(req.headers, 'accept'); + const lastEventId = safeHeader(req.headers, 'last-event-id'); + if (accept) headers.Accept = accept; + if (lastEventId) headers['Last-Event-ID'] = lastEventId; + return { ...headers, ...requestHeaders }; +}; + +const rejectWebSocketUpgrade = (socket, statusCode, message) => { + socket.write(`HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\n\r\n`); + socket.destroy(); +}; + +export const buildRealtimeProxySseUrl = (localOrigin, targetUrl) => { + const url = new URL(PROXY_SSE_PATH, localOrigin); + url.searchParams.set('url', targetUrl); + return url.toString(); +}; + +export const buildRealtimeProxyWsUrl = (localOrigin, targetUrl) => { + const url = new URL(PROXY_WS_PATH, localOrigin); + url.searchParams.set('url', targetUrl); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + return url.toString(); +}; + +export const attachRealtimeProxy = ({ app, server, getDesktopRuntimeConfig, getUiAuthController, isRequestOriginAllowed }) => { + if (!app || !server || typeof getDesktopRuntimeConfig !== 'function') { + return { stop: () => {} }; + } + + const originAllowed = async (req) => { + if (typeof isRequestOriginAllowed !== 'function') return false; + try { + return await isRequestOriginAllowed(req); + } catch { + return false; + } + }; + + const ensureAuthenticated = async (req, res) => { + const controller = typeof getUiAuthController === 'function' ? getUiAuthController() : null; + if (typeof controller?.ensureSessionToken !== 'function') return false; + const response = res || { setHeader: () => {} }; + const token = await controller.ensureSessionToken(req, response); + return Boolean(token); + }; + + app.get(PROXY_SSE_PATH, async (req, res) => { + if (!await ensureAuthenticated(req, res)) { + res.status(401).json({ error: 'UI authentication required' }); + return; + } + if (!await originAllowed(req)) { + res.status(403).json({ error: 'Realtime proxy origin is not allowed' }); + return; + } + const resolved = resolveProxyTarget(req, getDesktopRuntimeConfig, 'sse'); + if (!resolved) { + res.status(404).json({ error: 'Realtime proxy is unavailable' }); + return; + } + + const abort = new AbortController(); + req.on('close', () => abort.abort()); + try { + const response = await fetch(resolved.target.toString(), { + headers: buildSseRequestHeaders(req, resolved.requestHeaders), + signal: abort.signal, + }); + if (!response.ok || !response.body) { + res.status(response.status || 502).end(); + return; + } + + res.status(response.status); + res.setHeader('Content-Type', response.headers.get('content-type') || 'text/event-stream'); + res.setHeader('Cache-Control', response.headers.get('cache-control') || 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + for await (const chunk of response.body) { + if (abort.signal.aborted) break; + res.write(chunk); + } + res.end(); + } catch (error) { + if (!abort.signal.aborted && !res.headersSent) { + res.status(502).json({ error: error instanceof Error ? error.message : 'Realtime proxy failed' }); + } else if (!res.destroyed) { + res.end(); + } + } + }); + + const wsServer = new WebSocketServer({ noServer: true }); + + wsServer.on('connection', (client, request) => { + const resolved = resolveProxyTarget(request, getDesktopRuntimeConfig, 'ws'); + if (!resolved) { + client.close(1008, 'Realtime proxy is unavailable'); + return; + } + + const upstream = new WebSocket(resolved.target.toString(), { + headers: resolved.requestHeaders, + }); + const pending = []; + + const flush = () => { + while (pending.length > 0 && upstream.readyState === WebSocket.OPEN) { + const [data, isBinary] = pending.shift(); + upstream.send(data, { binary: isBinary }); + } + }; + + client.on('message', (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + return; + } + if (upstream.readyState === WebSocket.CONNECTING) { + pending.push([data, isBinary]); + } + }); + upstream.on('open', flush); + upstream.on('message', (data, isBinary) => { + if (client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + upstream.on('close', (code, reason) => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.close(code || 1000, reason); + } + }); + upstream.on('error', () => { + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.close(1011, 'Realtime proxy upstream error'); + } + }); + client.on('close', () => { + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(); + } + }); + }); + + const upgradeHandler = (req, socket, head) => { + const pathname = (() => { + try { return new URL(req.url || '/', 'http://127.0.0.1').pathname; } catch { return ''; } + })(); + if (pathname !== PROXY_WS_PATH) return; + void ensureAuthenticated(req, null).then((authenticated) => { + if (!authenticated) { + rejectWebSocketUpgrade(socket, 401, 'Unauthorized'); + return; + } + void originAllowed(req).then((allowed) => { + if (!allowed) { + rejectWebSocketUpgrade(socket, 403, 'Forbidden'); + return; + } + wsServer.handleUpgrade(req, socket, head, (ws) => { + wsServer.emit('connection', ws, req); + }); + }).catch(() => { + rejectWebSocketUpgrade(socket, 403, 'Forbidden'); + }); + }).catch(() => { + rejectWebSocketUpgrade(socket, 401, 'Unauthorized'); + }); + }; + + server.on('upgrade', upgradeHandler); + return { + stop: () => { + server.off('upgrade', upgradeHandler); + wsServer.close(); + }, + }; +}; diff --git a/packages/web/server/lib/realtime-proxy.test.js b/packages/web/server/lib/realtime-proxy.test.js new file mode 100644 index 0000000000..ce25992bc1 --- /dev/null +++ b/packages/web/server/lib/realtime-proxy.test.js @@ -0,0 +1,259 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import http from 'node:http'; +import { WebSocket, WebSocketServer } from 'ws'; + +import { attachRealtimeProxy, buildRealtimeProxySseUrl, buildRealtimeProxyWsUrl } from './realtime-proxy.js'; +import { createUiAuth } from './ui-auth/ui-auth.js'; + +const servers = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const closeServer = async (server) => { + await new Promise((resolve) => server.close(() => resolve())); +}; + +const startProxyServer = async ({ apiBaseUrl, authToken = 'ui-token', originAllowed = true } = {}) => { + const app = express(); + const server = http.createServer(app); + const runtime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl, + requestHeaders: { 'X-Proxy-Auth': 'secret' }, + }), + getUiAuthController: () => ({ + ensureSessionToken: async () => authToken, + }), + isRequestOriginAllowed: async () => originAllowed, + }); + const origin = await listen(server); + return { origin, runtime }; +}; + +const startProxyServerWithAuthController = async ({ apiBaseUrl, uiAuthController, originAllowed = true } = {}) => { + const app = express(); + const server = http.createServer(app); + const runtime = attachRealtimeProxy({ + app, + server, + getDesktopRuntimeConfig: () => ({ + apiBaseUrl, + requestHeaders: { 'X-Proxy-Auth': 'secret' }, + }), + getUiAuthController: () => uiAuthController, + isRequestOriginAllowed: async () => originAllowed, + }); + const origin = await listen(server); + return { origin, runtime }; +}; + +const startSseUpstream = async ({ path = '/api/global/event' } = {}) => { + const requests = []; + const server = http.createServer((req, res) => { + requests.push({ url: req.url, headers: req.headers }); + if (new URL(req.url || '/', 'http://127.0.0.1').pathname !== path) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }); + res.write('data: first\n\n'); + res.end('data: second\n\n'); + }); + const origin = await listen(server); + return { origin, requests }; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await closeServer(server); + } +}); + +describe('realtime proxy URL builders', () => { + it('builds local SSE proxy URLs with target URL encoded as query data', () => { + const url = new URL(buildRealtimeProxySseUrl('http://127.0.0.1:57123', 'https://remote.example/api/global/event?x=1')); + + expect(url.origin).toBe('http://127.0.0.1:57123'); + expect(url.pathname).toBe('/api/openchamber/realtime-proxy/sse'); + expect(url.searchParams.get('url')).toBe('https://remote.example/api/global/event?x=1'); + }); + + it('builds local WebSocket proxy URLs with ws protocol', () => { + const url = new URL(buildRealtimeProxyWsUrl('https://127.0.0.1:57123', 'wss://remote.example/api/global/event/ws')); + + expect(url.protocol).toBe('wss:'); + expect(url.host).toBe('127.0.0.1:57123'); + expect(url.pathname).toBe('/api/openchamber/realtime-proxy/ws'); + expect(url.searchParams.get('url')).toBe('wss://remote.example/api/global/event/ws'); + }); +}); + +describe('realtime proxy', () => { + it('streams SSE chunks and forwards safe SSE headers with configured runtime headers', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { + Accept: 'text/event-stream', + 'Last-Event-ID': 'evt-42', + Origin: 'openchamber-ui://app', + }, + }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('data: first\n\ndata: second\n\n'); + expect(upstream.requests).toHaveLength(1); + expect(upstream.requests[0].headers.accept).toBe('text/event-stream'); + expect(upstream.requests[0].headers['last-event-id']).toBe('evt-42'); + expect(upstream.requests[0].headers['x-proxy-auth']).toBe('secret'); + } finally { + runtime.stop(); + } + }); + + it('rejects unauthenticated SSE proxy requests', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, authToken: null }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(401); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects SSE proxy requests from disallowed origins', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin, originAllowed: false }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'https://evil.example' }, + }); + + expect(response.status).toBe(403); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects targets outside the active runtime origin', async () => { + const upstream = await startSseUpstream(); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: 'https://different.example' }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/global/event`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(404); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('rejects targets outside the realtime path allowlist', async () => { + const upstream = await startSseUpstream({ path: '/api/config/settings' }); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstream.origin }); + + try { + const response = await fetch(buildRealtimeProxySseUrl(origin, `${upstream.origin}/api/config/settings`), { + headers: { Origin: 'openchamber-ui://app' }, + }); + + expect(response.status).toBe(404); + expect(upstream.requests).toHaveLength(0); + } finally { + runtime.stop(); + } + }); + + it('proxies WebSocket upgrades using query params from the raw upgrade request URL', async () => { + let upstreamRequest = null; + const upstreamServer = http.createServer(); + const upstreamWs = new WebSocketServer({ server: upstreamServer }); + upstreamWs.on('connection', (socket, request) => { + upstreamRequest = request; + socket.on('message', (data, isBinary) => { + socket.send(isBinary ? data : `echo:${data.toString()}`, { binary: isBinary }); + }); + }); + const upstreamOrigin = await listen(upstreamServer); + const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstreamOrigin }); + + try { + const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws?lastEventId=evt-1`; + const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), { + headers: { Origin: 'openchamber-ui://app' }, + }); + await new Promise((resolve, reject) => { + client.once('open', resolve); + client.once('error', reject); + }); + + const message = await new Promise((resolve) => { + client.once('message', (data) => resolve(data.toString())); + client.send('ping'); + }); + + expect(message).toBe('echo:ping'); + expect(upstreamRequest?.url).toBe('/api/global/event/ws?lastEventId=evt-1'); + expect(upstreamRequest?.headers['x-proxy-auth']).toBe('secret'); + client.close(); + upstreamWs.close(); + } finally { + runtime.stop(); + } + }); + + it('allows first passwordless WebSocket proxy upgrade without an existing cookie', async () => { + const upstreamServer = http.createServer(); + const upstreamWs = new WebSocketServer({ server: upstreamServer }); + upstreamWs.on('connection', (socket) => { + socket.send('ready'); + }); + const upstreamOrigin = await listen(upstreamServer); + const uiAuthController = createUiAuth({ password: '' }); + const { origin, runtime } = await startProxyServerWithAuthController({ apiBaseUrl: upstreamOrigin, uiAuthController }); + + try { + const target = `${upstreamOrigin.replace(/^http:/, 'ws:')}/api/global/event/ws`; + const client = new WebSocket(buildRealtimeProxyWsUrl(origin, target), { + headers: { Origin: 'openchamber-ui://app' }, + }); + const message = await new Promise((resolve, reject) => { + client.once('message', (data) => resolve(data.toString())); + client.once('error', reject); + }); + + expect(message).toBe('ready'); + client.close(); + upstreamWs.close(); + } finally { + runtime.stop(); + uiAuthController.dispose?.(); + } + }); +}); diff --git a/packages/web/server/lib/ui-auth/ui-auth.js b/packages/web/server/lib/ui-auth/ui-auth.js index 926aaae214..4862bd651c 100644 --- a/packages/web/server/lib/ui-auth/ui-auth.js +++ b/packages/web/server/lib/ui-auth/ui-auth.js @@ -295,6 +295,7 @@ const isUrlAuthReadableHttpPath = (pathname) => { return pathname === '/api/event' || pathname === '/api/global/event' || pathname === '/api/openchamber/events' + || pathname === '/api/openchamber/realtime-proxy/sse' || pathname === '/api/notifications/stream' || pathname === '/api/fs/raw' || pathname === '/api/fs/serve' @@ -307,6 +308,7 @@ const isUrlAuthReadableHttpPath = (pathname) => { const isUrlAuthWebSocketPath = (pathname) => { return pathname === '/api/event/ws' || pathname === '/api/global/event/ws' + || pathname === '/api/openchamber/realtime-proxy/ws' || pathname === '/api/terminal/ws' || pathname.startsWith('/api/preview/proxy/'); }; diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index ba91ca9b28..de16d298d1 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -44,6 +44,9 @@ export const createConfiguredWebAPIs = () => { setRuntimeBearerToken(clientToken || null); setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); + if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin)) { + void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); + } installRuntimeFetchBridge(); return createWebAPIs({ urls }); }; From a7cfa4a2ff311b31268e800d832e7c2fdec18771 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 02:48:01 +0300 Subject: [PATCH 28/88] fix: restore desktop remote authentication Fixes switching and unlocking password-protected remote instances Stores SSH forwarded host client tokens from saved UI passwords Avoids unnecessary auth churn when no runtime headers are configured --- packages/electron/main.mjs | 6 +- packages/electron/ssh-manager.mjs | 70 +++++++++++++++- packages/electron/ssh-manager.test.mjs | 70 ++++++++++++++++ .../src/components/auth/SessionAuthGate.tsx | 82 ++++++++++++++++--- packages/ui/src/lib/runtime-auth.test.ts | 30 +++++++ packages/ui/src/lib/runtime-auth.ts | 10 ++- packages/ui/src/lib/runtime-switch.test.ts | 65 +++++++++++++++ packages/ui/src/lib/runtime-switch.ts | 23 +++++- packages/web/src/runtimeConfig.ts | 4 +- 9 files changed, 338 insertions(+), 22 deletions(-) create mode 100644 packages/electron/ssh-manager.test.mjs create mode 100644 packages/ui/src/lib/runtime-switch.test.ts diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 1272ec3a7a..91ec2f780d 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1517,9 +1517,10 @@ const extractCookieHeader = (response) => { .join('; '); }; -const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => { +const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requestHeaders }) => { const baseUrl = normalizeHostUrl(String(url || '')); const candidatePassword = typeof password === 'string' ? password : ''; + const safeRequestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); if (!baseUrl) throw new Error('Invalid URL'); if (!candidatePassword) throw new Error('Password is required'); @@ -1527,6 +1528,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -1559,6 +1561,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', Cookie: cookie, @@ -3506,6 +3509,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { url: args.url, password: args.password, trustDevice: args.trustDevice === true, + requestHeaders: args.requestHeaders || {}, }); case 'desktop_set_window_theme': { diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index 38c14bae0a..7075b5524f 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -700,19 +700,84 @@ export class ElectronSshManager { } async updateHostUrl(instanceId, label, localUrl) { + return this.updateHostRuntime(instanceId, label, localUrl, ''); + } + + async updateHostRuntime(instanceId, label, localUrl, clientToken = '') { const root = readJsonRoot(this.settingsFilePath); const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : []; const existing = hosts.find((entry) => entry?.id === instanceId); + const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (existing) { existing.label = label; existing.url = localUrl; + existing.apiUrl = localUrl; + if (token) existing.clientToken = token; } else { - hosts.push({ id: instanceId, label, url: localUrl }); + hosts.push({ id: instanceId, label, url: localUrl, apiUrl: localUrl, ...(token ? { clientToken: token } : {}) }); } root.desktopHosts = hosts; await writeJsonRoot(this.settingsFilePath, root); } + async issueClientToken(localUrl, openchamberPassword) { + const password = typeof openchamberPassword === 'string' ? openchamberPassword.trim() : ''; + if (!password) return ''; + + const loginResponse = await fetch(new URL('/auth/session', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + trustDevice: true, + issueClientToken: true, + clientLabel: 'OpenChamber Desktop SSH', + }), + }); + if (!loginResponse.ok) { + throw new Error(`Configured OpenChamber UI password was rejected by forwarded server (status ${loginResponse.status})`); + } + + const payload = await loginResponse.json().catch(() => null); + const token = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + if (token) return token; + + const cookie = this.extractCookieHeader(loginResponse); + if (!cookie) return ''; + + const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ label: 'OpenChamber Desktop SSH' }), + }); + if (!tokenResponse.ok) return ''; + const tokenPayload = await tokenResponse.json().catch(() => null); + return typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : ''; + } + + extractCookieHeader(response) { + const getSetCookie = typeof response.headers?.getSetCookie === 'function' + ? response.headers.getSetCookie.bind(response.headers) + : null; + const cookies = getSetCookie ? getSetCookie() : []; + const rawCookies = cookies.length > 0 + ? cookies + : String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/); + return rawCookies + .map((cookie) => String(cookie || '').split(';')[0].trim()) + .filter(Boolean) + .join('; '); + } + async persistLocalPort(instanceId, localPort) { const root = readJsonRoot(this.settingsFilePath); const instances = Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : []; @@ -1090,7 +1155,8 @@ export class ElectronSshManager { const localUrl = `http://127.0.0.1:${localPort}`; const label = instance.nickname?.trim() || parsed.destination || id; - await this.updateHostUrl(id, label, localUrl); + const clientToken = await this.issueClientToken(localUrl, this.configuredOpenChamberPassword(instance)); + await this.updateHostRuntime(id, label, localUrl, clientToken); if (instance.localForward?.preferredLocalPort !== localPort) { await this.persistLocalPort(id, localPort); } diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs new file mode 100644 index 0000000000..f31c90f877 --- /dev/null +++ b/packages/electron/ssh-manager.test.mjs @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { ElectronSshManager } from './ssh-manager.mjs'; + +const servers = []; +const tempDirs = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const readBody = async (req) => { + let body = ''; + for await (const chunk of req) body += chunk.toString(); + return body; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await new Promise((resolve) => server.close(() => resolve())); + } + while (tempDirs.length > 0) { + await fsp.rm(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe('ElectronSshManager', () => { + test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => { + let loginPayload = null; + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/auth/session') { + loginPayload = JSON.parse(await readBody(req)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ authenticated: true, clientToken: 'ssh-client-token' })); + return; + } + res.writeHead(404).end(); + }); + const localUrl = await listen(server); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-manager-test-')); + tempDirs.push(tempDir); + const settingsFilePath = path.join(tempDir, 'settings.json'); + const manager = new ElectronSshManager({ + settingsFilePath, + appVersion: '0.0.0-test', + emit: () => undefined, + }); + + const token = await manager.issueClientToken(localUrl, 'ui-secret'); + await manager.updateHostRuntime('ssh-1', 'SSH Host', localUrl, token); + + const settings = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8')); + expect(loginPayload).toMatchObject({ + password: 'ui-secret', + trustDevice: true, + issueClientToken: true, + }); + expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 9f4e23b123..8dbe460bec 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,6 +12,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; import { @@ -129,20 +130,30 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => { return isDesktopShell() && !isLocalDesktopRuntime(); }; -const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { +type DesktopPasswordLoginResult = { + token: string; + status?: number; +}; + +const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { if (!isDesktopShell() || typeof window === 'undefined') { - return ''; + return null; } const response = await invokeDesktop('desktop_remote_password_login', { url: getRuntimeApiBaseUrl(), password, trustDevice, + requestHeaders: getRuntimeExtraHeadersSync(), }).catch(() => null); if (!response || typeof response !== 'object') { - return ''; + return null; } const token = (response as { token?: unknown }).token; - return typeof token === 'string' ? token.trim() : ''; + const status = (response as { status?: unknown }).status; + return { + token: typeof token === 'string' ? token.trim() : '', + ...(typeof status === 'number' ? { status } : {}), + }; }; const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise => { @@ -180,8 +191,13 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string const applyDesktopClientToken = async (clientToken: string): Promise => { if (!clientToken) return; const apiBaseUrl = getRuntimeApiBaseUrl(); + const requestHeaders = getRuntimeExtraHeadersSync(); await persistDesktopClientToken(apiBaseUrl, clientToken); - switchRuntimeEndpoint({ apiBaseUrl, clientToken }); + switchRuntimeEndpoint({ + apiBaseUrl, + clientToken, + requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null, + }); }; const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -486,15 +502,43 @@ export const SessionAuthGate: React.FC = ({ children }) => setErrorMessage(''); try { + if (shouldUseDesktopShellPasswordLogin()) { + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + if (shellLogin?.token) { + setPassword(''); + setIsTunnelLocked(false); + await applyDesktopClientToken(shellLogin.token); + setState('authenticated'); + return; + } + if (shellLogin?.status === 401) { + setErrorMessage(t('sessionAuth.error.incorrectPassword')); + setIsTunnelLocked(false); + setState('locked'); + return; + } + if (shellLogin?.status === 429) { + setRetryAfter(undefined); + setIsTunnelLocked(false); + setState('rate-limited'); + return; + } + } + const response = await submitPassword(password, trustDevice); if (response.ok) { const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; const shouldUseClientToken = shouldIssueDesktopClientToken(); - const clientToken = shouldUseClientToken - ? (typeof payload?.clientToken === 'string' && payload.clientToken.trim() + let clientToken = ''; + if (shouldUseClientToken) { + clientToken = typeof payload?.clientToken === 'string' && payload.clientToken.trim() ? payload.clientToken.trim() - : await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken()) - : ''; + : ''; + if (!clientToken) { + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + clientToken = shellLogin?.token || await issueDesktopClientToken(); + } + } setPassword(''); setIsTunnelLocked(false); if (clientToken) { @@ -541,16 +585,28 @@ export const SessionAuthGate: React.FC = ({ children }) => setState('error'); } catch (error) { console.warn('Failed to submit UI password:', error); - const clientToken = shouldUseDesktopShellPasswordLogin() + const shellLogin = shouldUseDesktopShellPasswordLogin() ? await issueDesktopClientTokenViaShell(password, trustDevice) - : ''; - if (clientToken) { + : null; + if (shellLogin?.token) { setPassword(''); setIsTunnelLocked(false); - await applyDesktopClientToken(clientToken); + await applyDesktopClientToken(shellLogin.token); setState('authenticated'); return; } + if (shellLogin?.status === 401) { + setErrorMessage(t('sessionAuth.error.incorrectPassword')); + setIsTunnelLocked(false); + setState('locked'); + return; + } + if (shellLogin?.status === 429) { + setRetryAfter(undefined); + setIsTunnelLocked(false); + setState('rate-limited'); + return; + } setErrorMessage(t('sessionAuth.error.networkRetry')); setIsTunnelLocked(false); setState('error'); diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts index f5cbb58dff..58858dfa5b 100644 --- a/packages/ui/src/lib/runtime-auth.test.ts +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -115,4 +115,34 @@ describe('runtime auth headers', () => { clearRuntimeAuthCredentialProvider(); } }); + + test('does not remint URL auth token when setting equivalent empty runtime headers', async () => { + const previousFetch = globalThis.fetch; + let fetchCount = 0; + try { + clearRuntimeUrlAuthToken(); + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders(null); + globalThis.fetch = (async () => { + fetchCount += 1; + return new Response(JSON.stringify({ token: `url-token-${fetchCount}`, expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const firstToken = await refreshRuntimeUrlAuthToken('https://runtime.example'); + setRuntimeExtraHeaders({}); + const secondToken = await refreshRuntimeUrlAuthToken('https://runtime.example'); + + expect(firstToken).toBe('url-token-1'); + expect(secondToken).toBe('url-token-1'); + expect(fetchCount).toBe(1); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); }); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index efd1cd938e..6aa181db88 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -29,6 +29,12 @@ const sanitizeRuntimeExtraHeaders = (headers: Record | null | un return next; }; +const runtimeExtraHeadersEqual = (left: Record, right: Record): boolean => { + const leftEntries = Object.entries(left); + if (leftEntries.length !== Object.keys(right).length) return false; + return leftEntries.every(([key, value]) => right[key] === value); +}; + const normalizeBearerToken = (token: string | null | undefined): string => { if (typeof token !== 'string') return ''; return token.trim(); @@ -95,7 +101,9 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void => export const setRuntimeExtraHeaders = (headers: Record | null | undefined): void => { // These headers are for runtime HTTP fetches and URL-token minting. Browser-owned // realtime transports (EventSource/WebSocket) cannot attach arbitrary headers. - runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers); + const next = sanitizeRuntimeExtraHeaders(headers); + if (runtimeExtraHeadersEqual(runtimeExtraHeaders, next)) return; + runtimeExtraHeaders = next; resetRuntimeAuthGeneration(); }; diff --git a/packages/ui/src/lib/runtime-switch.test.ts b/packages/ui/src/lib/runtime-switch.test.ts new file mode 100644 index 0000000000..666b845b5c --- /dev/null +++ b/packages/ui/src/lib/runtime-switch.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; +import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from './runtime-switch'; +import { clearRuntimeUrlAuthToken, setRuntimeExtraHeaders } from './runtime-auth'; + +describe('runtime endpoint switching', () => { + test('does not throw when Electron preload globals are read-only', () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousFetch = globalThis.fetch; + const runtimeWindow = { + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, + }; + + try { + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + globalThis.fetch = (async () => new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', { + configurable: true, + value: 'http://127.0.0.1:3000', + writable: false, + }); + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', { + configurable: true, + value: '', + writable: false, + }); + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', { + configurable: true, + value: {}, + writable: false, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: runtimeWindow, + }); + + let thrown: unknown = null; + try { + switchRuntimeEndpoint({ + apiBaseUrl: 'https://remote.example', + clientToken: 'client-token', + requestHeaders: null, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeNull(); + expect(getRuntimeApiBaseUrl()).toBe('https://remote.example'); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); +}); diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 6cb02dfdc4..b8cfdada31 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -13,6 +13,23 @@ const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed'; let activeApiBaseUrl = ''; let activeRuntimeKey = ''; +const setWindowRuntimeValue = ( + runtimeWindow: typeof window & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; + }, + key: K, + value: (typeof runtimeWindow)[K], +): void => { + try { + runtimeWindow[key] = value; + } catch { + // Electron preload exposes some initial globals through contextBridge, which + // makes them read-only. Runtime switching must still update in-memory state. + } +}; + const normalizeRuntimeUrlKey = (value: string): string => { try { const url = new URL(value); @@ -81,9 +98,9 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken __OPENCHAMBER_CLIENT_TOKEN__?: string; __OPENCHAMBER_RUNTIME_HEADERS__?: Record; }; - runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; - runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; - runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined; + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', apiBaseUrl); + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', options.clientToken || undefined); + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', options.requestHeaders || undefined); } configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); setRuntimeExtraHeaders(options.requestHeaders || null); diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index de16d298d1..7622e6d4dc 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -44,7 +44,7 @@ export const createConfiguredWebAPIs = () => { setRuntimeBearerToken(clientToken || null); setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); - if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin)) { + if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin) && Object.keys(getRuntimeExtraHeadersSync()).length > 0) { void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); } installRuntimeFetchBridge(); From fb4fc492a78bd898b6811b51f143170a0605b08b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 02:56:33 +0300 Subject: [PATCH 29/88] chore: update changelog for desktop remote instances --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7637c2e0..73b75e4b90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Desktop: remote instances can now save additional request headers for proxy-auth setups such as Cloudflare Access, including for live updates and terminal streams. +- Desktop: SSH remote instances with a saved UI password no longer ask for that UI password again after the tunnel connects. + ## [1.13.8] - 2026-06-29 - Startup: launching the app no longer hangs for around 20 seconds before you can open a session, load a diff, or send a message — GitHub pull request status checks no longer tie up the connection to the server during startup. From 34808b70acc4fa80f93430d969ede3f409e4c0fd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 00:16:10 +0300 Subject: [PATCH 30/88] fix(chat): hold bottom on first session open while late async content lands On the first open of a session, late async data (most visibly a task/subagent tool whose nested rows are fetched from the child session after entry) grew the timeline a beat after the one-shot entry pin, stranding the viewport mid-history. The steady-state idle gate intentionally ignores that growth, so re-pinning could not recover it. Add a short, gesture-cancellable entry-stick window that forces the bottom on every growth until content quiesces (or the user scrolls), covering both the ResizeObserver and the structural notifyContentChange path. --- packages/ui/src/hooks/useChatAutoFollow.ts | 98 ++++++++++++++++++++-- 1 file changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index f8b543b697..0ef2fdc308 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -79,6 +79,17 @@ const AUTO_MATCH_TOLERANCE_PX = 2; // After streaming stops, keep following the bottom for a short window so the // final content can settle into place. const SETTLE_MS = 300; +// Entry-stick window. On the FIRST open of a session, late async data (most +// visibly a task/subagent tool whose nested rows are fetched from the child +// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the +// timeline a beat or two AFTER we have already pinned to the bottom, leaving the +// viewport stranded mid-history. The steady-state idle gate deliberately ignores +// that growth (it can't tell entry from a user reading idle history). So instead +// of weakening the gate, we open a short, gesture-cancellable window on entry +// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after +// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture. +const ENTRY_STICK_QUIESCENCE_MS = 600; +const ENTRY_STICK_MAX_MS = 8000; const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); @@ -173,6 +184,12 @@ export const useChatAutoFollow = ({ const autoRef = React.useRef<{ top: number; time: number } | null>(null); const autoTimerRef = React.useRef | null>(null); + // Entry-stick window state (see ENTRY_STICK_* above). + const entryStickRef = React.useRef(false); + const entryStickQuietTimerRef = React.useRef | null>(null); + const entryStickCapTimerRef = React.useRef | null>(null); + const entryStickLastHeightRef = React.useRef(0); + const saveTimerRef = React.useRef | null>(null); const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null); // When restoreSnapshot is invoked while ChatViewport is still hydrating @@ -234,6 +251,48 @@ export const useChatAutoFollow = ({ return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX; }, []); + // ── entry-stick window ─────────────────────────────────────────────────── + const endEntryStick = React.useCallback(() => { + entryStickRef.current = false; + if (entryStickQuietTimerRef.current) { + clearTimeout(entryStickQuietTimerRef.current); + entryStickQuietTimerRef.current = null; + } + if (entryStickCapTimerRef.current) { + clearTimeout(entryStickCapTimerRef.current); + entryStickCapTimerRef.current = null; + } + }, []); + + // (Re)arm the quiescence timer: the window closes this long after the last + // growth. Called once on begin and again on every growth-driven re-pin. + const armEntryStickQuiet = React.useCallback(() => { + if (entryStickQuietTimerRef.current) { + clearTimeout(entryStickQuietTimerRef.current); + } + entryStickQuietTimerRef.current = setTimeout(() => { + entryStickQuietTimerRef.current = null; + endEntryStick(); + }, ENTRY_STICK_QUIESCENCE_MS); + }, [endEntryStick]); + + const beginEntryStick = React.useCallback(() => { + const el = scrollRef.current; + if (!el) return; + entryStickRef.current = true; + entryStickLastHeightRef.current = el.scrollHeight; + armEntryStickQuiet(); + // Reset the absolute cap fresh on every entry (e.g. session switch) so a + // stale cap from a previous open can't cut this window short. + if (entryStickCapTimerRef.current) { + clearTimeout(entryStickCapTimerRef.current); + } + entryStickCapTimerRef.current = setTimeout(() => { + entryStickCapTimerRef.current = null; + endEntryStick(); + }, ENTRY_STICK_MAX_MS); + }, [armEntryStickQuiet, endEntryStick]); + // ── overflow / scroll-to-bottom button ────────────────────────────────── const updateOverflowAndButton = React.useCallback(() => { const container = scrollRef.current; @@ -322,8 +381,11 @@ export const useChatAutoFollow = ({ }, [setStateValue, updateOverflowAndButton]); const releaseFromUserIntent = React.useCallback(() => { + // A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry + // window immediately so we never fight the user's read position. + endEntryStick(); stop(); - }, [stop]); + }, [endEntryStick, stop]); // ── per-session snapshot persistence (kept; restore still goes to bottom) ─ const flushSave = React.useCallback(() => { @@ -389,9 +451,13 @@ export const useChatAutoFollow = ({ // history measures in, so there is no smooth scroll-from-mid artifact. setStateValue('following'); scrollToBottom(true); + // Hold the bottom across late async growth (e.g. task/subagent child + // session data landing a beat after entry) until content quiesces or the + // user scrolls. + beginEntryStick(); updateOverflowAndButton(); return false; - }, [scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]); // ── session change ─────────────────────────────────────────────────────── React.useEffect(() => { @@ -570,6 +636,18 @@ export const useChatAutoFollow = ({ return; } updateOverflowAndButton(); + // Entry-stick window: on first session open, FORCE the bottom on + // every growth so late async data (task/subagent child rows, code + // highlight, mermaid) can't strand the viewport mid-history. Force + // overrides any false `released` from the growth itself; only a real + // user gesture clears the window (releaseFromUserIntent). + if (entryStickRef.current && el) { + const grew = el.scrollHeight > entryStickLastHeightRef.current + 1; + entryStickLastHeightRef.current = el.scrollHeight; + scrollToBottom(true); + if (grew) armEntryStickQuiet(); + return; + } // Idle resize = layout churn (virtualizer re-measurement, async // tool/code rendering), NOT live growth. Never re-pin when idle, or // tall items re-measuring as the user scrolls cause an endless @@ -584,7 +662,7 @@ export const useChatAutoFollow = ({ observer.observe(inner); } return () => observer.disconnect(); - }, [containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); + }, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]); React.useEffect(() => { updateOverflowAndButton(); @@ -593,10 +671,19 @@ export const useChatAutoFollow = ({ const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => { void _reason; updateOverflowAndButton(); + // Entry-stick window: late structural growth (notably the task/subagent + // summary landing from the child session — ToolPart emits 'structural' + // here) must keep us pinned and refresh the quiescence timer, even though + // the session is idle. + if (entryStickRef.current) { + scrollToBottom(true); + armEntryStickQuiet(); + return; + } if (stateRef.current === 'following') { scrollToBottom(false); } - }, [scrollToBottom, updateOverflowAndButton]); + }, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]); const animationHandlersRef = React.useRef>(new Map()); @@ -635,13 +722,14 @@ export const useChatAutoFollow = ({ clearTimeout(settleTimerRef.current); settleTimerRef.current = null; } + endEntryStick(); flushSave(); if (saveTimerRef.current !== null) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } }; - }, [flushSave]); + }, [endEntryStick, flushSave]); React.useEffect(() => { if (!onActiveTurnChange) return; From 78ac35d2f682389ae6f54ea7a2eea1700bc71fc4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 00:20:55 +0300 Subject: [PATCH 31/88] fix(chat): disable markdown file-reference probing on mobile The file-reference annotation pass issues filesystem stat probes (fileReferenceExists -> /api/fs/stat) to decide which inline-code/link tokens become openable file links. On mobile surfaces this feature is disabled entirely: gate the annotation effect on !isMobileSurfaceRuntime() so the pass short-circuits before scheduling, guaranteeing no probe requests are ever sent from a mobile runtime. --- .../ui/src/components/chat/MarkdownRendererImpl.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index b050fd25eb..5b5252f223 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -16,6 +16,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; @@ -570,6 +571,11 @@ const useFileReferenceInteractions = ({ } let cancelled = false; const fileReferenceLinkLimit = getFileReferenceLinkLimit(); + // On mobile surfaces, file-reference highlighting is disabled entirely — not + // just visually. The annotation pass is what issues the filesystem `stat` + // probes (fileReferenceExists → /api/fs/stat), so skipping it here guarantees + // no probe requests are ever sent from a mobile runtime. + const fileReferencesEnabled = enabled && !isMobileSurfaceRuntime(); const clearFileLinkAttributes = (candidate: HTMLElement) => { candidate.removeAttribute('data-openchamber-file-link'); @@ -592,7 +598,7 @@ const useFileReferenceInteractions = ({ unwrapBlockCodePathTokens(container); }; - if (!enabled) { + if (!fileReferencesEnabled) { clearAnnotatedFileLinks(); return; } @@ -616,7 +622,7 @@ const useFileReferenceInteractions = ({ }; const annotateFileLinks = () => { - if (enabled) { + if (fileReferencesEnabled) { wrapBlockCodePathTokens(container); } const candidates = container.querySelectorAll( From a3c0296f8932a7b225c99a9c5e135ae3a5a32b17 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 30 Jun 2026 03:05:15 +0300 Subject: [PATCH 32/88] fix(chat): stop Thinking stream from fighting chat scroll Render a streaming Thinking block inline instead of inside a capped, independently-scrollable max-height box (the cap now applies only to finished thinking, for compact review). The nested scroll box was capturing the wheel and auto-pinning to its own bottom, so the chat could not be scrolled while thinking streamed. With it gone the chat's own auto-follow owns the scroll. Two auto-follow refinements make that solid: - Direction-aware bottom-zone re-engage: scrolling UP into the bottom spacer zone no longer re-arms follow (which the next growth would yank back). Follow resumes only when the user arrives at the bottom by scrolling down, is already following, or is at the true bottom. Kills the dead-zone fight near the bottom. - Animation guard: while a Thinking block COLLAPSE animation runs, transient geometry / trailing async scroll events are treated as our own and never trigger a false release. Genuine user gestures still release instantly. --- .../chat/message/parts/ReasoningPart.tsx | 84 ++++++++++++------- packages/ui/src/hooks/useChatAutoFollow.ts | 63 ++++++++++++-- 2 files changed, 106 insertions(+), 41 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index ee0443f38a..59e1fc5b82 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -118,10 +118,14 @@ export const ReasoningTimelineBlock: React.FC = ({ : expansion.expanded; const [shouldRenderExpandedContent, setShouldRenderExpandedContent] = React.useState(defaultExpanded === true || canAutoExpand); const contentId = React.useId(); - const scrollRef = React.useRef(null); const contentRef = React.useRef(null); const contentAnimationRef = React.useRef(null); const contentMountedRef = React.useRef(false); + // Stable handle to onContentChange so the height-animation layout effect can + // signal auto-follow without taking onContentChange as a dependency (which + // would risk re-running — and thus restarting — the animation on re-render). + const onContentChangeRef = React.useRef(onContentChange); + onContentChangeRef.current = onContentChange; const summary = React.useMemo(() => getReasoningSummary(text), [text]); const toggleAriaLabel = isExpanded @@ -160,12 +164,6 @@ export const ReasoningTimelineBlock: React.FC = ({ onContentChange?.('structural'); }, [onContentChange, text]); - React.useEffect(() => { - if (isStreaming && isExpanded && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [text, isStreaming, isExpanded]); - React.useEffect(() => { if (isExpanded || isStreaming) { setShouldRenderExpandedContent(true); @@ -239,6 +237,11 @@ export const ReasoningTimelineBlock: React.FC = ({ element.style.height = '0px'; } else { element.style.height = `${element.scrollHeight}px`; + // Only the COLLAPSE animation needs the guard: it shrinks the + // timeline and the trailing async scroll events can be misread as a + // user scroll-away. Expansion grows the timeline and re-pins cleanly, + // and guarding it caused a faint scroll fight while thinking streams. + onContentChangeRef.current?.('animation'); } const animation = animate( @@ -280,6 +283,27 @@ export const ReasoningTimelineBlock: React.FC = ({ return null; } + const reasoningBody = ( + <> +
+ +
+ {actions ? ( +
+
+ {actions} +
+
+ ) : null} + + ); + return (
= ({ className="pointer-events-none absolute left-0 top-0 bottom-0 w-px" style={{ backgroundColor: 'var(--tools-border)' }} /> - -
- + {isStreaming ? ( + // While streaming, let the thinking grow inline — no + // capped, independently-scrollable box. The chat's own + // auto-follow then handles following / releasing, so the + // box never captures the wheel or fights the user's + // scroll. The max-height scroll box is applied only once + // the thinking has finished (the branch below). +
+ {reasoningBody}
- {actions ? ( -
-
- {actions} -
-
- ) : null} - + ) : ( + + {reasoningBody} + + )}
) : null} diff --git a/packages/ui/src/hooks/useChatAutoFollow.ts b/packages/ui/src/hooks/useChatAutoFollow.ts index 0ef2fdc308..f873053065 100644 --- a/packages/ui/src/hooks/useChatAutoFollow.ts +++ b/packages/ui/src/hooks/useChatAutoFollow.ts @@ -6,7 +6,7 @@ import { useViewportStore } from '@/sync/viewport-store'; type AutoFollowState = 'following' | 'released'; -export type ContentChangeReason = 'text' | 'structural' | 'permission'; +export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation'; export interface AnimationHandlers { onChunk: () => void; @@ -76,6 +76,17 @@ const TOUCH_FINGER_DOWN_THRESHOLD = 2; // a user scroll. const AUTO_MARK_TTL_MS = 1500; const AUTO_MATCH_TOLERANCE_PX = 2; +// While a tracked height animation runs (e.g. a Thinking block auto-collapsing +// mid-stream), the timeline shrinks/grows over a couple hundred ms and the +// virtualizer re-measures, producing transient geometry. Browsers dispatch the +// resulting `scroll` events asynchronously, so a stale event can land after we +// have already re-pinned — its position matching neither the bottom zone nor the +// freshly-moved auto marker — and be misread as a user scroll-away. During this +// guard window we treat any `following`-state scroll event as our own and never +// release via the heuristic. GENUINE user gestures still release instantly +// through releaseFromUserIntent, so this is not glue. Sized to the reasoning +// animation (200ms) plus headroom for trailing async scroll events. +const ANIMATION_GUARD_MS = 350; // After streaming stops, keep following the bottom for a short window so the // final content can settle into place. const SETTLE_MS = 300; @@ -184,6 +195,15 @@ export const useChatAutoFollow = ({ const autoRef = React.useRef<{ top: number; time: number } | null>(null); const autoTimerRef = React.useRef | null>(null); + // Timestamp until which a tracked height animation is in flight (see + // ANIMATION_GUARD_MS). 0 = no animation guard active. + const animationGuardUntilRef = React.useRef(0); + + // Last observed scrollTop, used to derive scroll DIRECTION in the scroll + // handler so the bottom-zone re-engage only fires when arriving at the bottom + // by scrolling down — never when a user scrolling UP merely lands in the zone. + const lastScrollTopRef = React.useRef(0); + // Entry-stick window state (see ENTRY_STICK_* above). const entryStickRef = React.useRef(false); const entryStickQuietTimerRef = React.useRef | null>(null); @@ -251,6 +271,10 @@ export const useChatAutoFollow = ({ return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX; }, []); + const isAnimationGuardActive = React.useCallback((): boolean => { + return now() < animationGuardUntilRef.current; + }, []); + // ── entry-stick window ─────────────────────────────────────────────────── const endEntryStick = React.useCallback(() => { entryStickRef.current = false; @@ -522,6 +546,10 @@ export const useChatAutoFollow = ({ const el = scrollRef.current; if (!el) return; + const previousTop = lastScrollTopRef.current; + lastScrollTopRef.current = el.scrollTop; + const scrollingDown = el.scrollTop > previousTop + 0.5; + updateOverflowAndButton(); if (!canScroll(el)) { @@ -530,16 +558,25 @@ export const useChatAutoFollow = ({ } // Within the bottom zone → (re-)pin to following. This is how scrolling - // back down to the bottom resumes auto-follow. + // back DOWN to the bottom resumes auto-follow. Crucially, re-engage only + // when the user arrives by scrolling down (or is already following, or is + // essentially at the true bottom). A user scrolling UP that merely lands + // in the bottom spacer zone must NOT be yanked back into follow — that is + // the dead-zone fight that made small upward scrolls impossible while + // content streams. if (isNearBottom(el, isMobileRef.current)) { - setStateValue('following'); + const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX; + if (scrollingDown || stateRef.current === 'following' || atTrueBottom) { + setStateValue('following'); + } queueSave(); return; } - // Our own programmatic write that landed at the bottom but where content - // grew between the write and this event — keep following, don't release. - if (stateRef.current === 'following' && isAuto(el)) { + // Our own geometry change (a programmatic write that landed at the bottom + // but where content grew between the write and this event, OR a tracked + // height animation in flight) — keep following, don't release. + if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) { scrollToBottom(false); queueSave(); return; @@ -548,12 +585,14 @@ export const useChatAutoFollow = ({ // Genuine user scroll away from the bottom. stop(); queueSave(); - }, [isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]); + }, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]); React.useEffect(() => { const container = containerEl; if (!container) return; + lastScrollTopRef.current = container.scrollTop; + const handleWheel = (event: WheelEvent) => { if (event.deltaY >= 0) return; if (nestedScrollableCanConsumeUp(container, event.target)) return; @@ -668,8 +707,14 @@ export const useChatAutoFollow = ({ updateOverflowAndButton(); }, [sessionMessageCount, updateOverflowAndButton]); - const notifyContentChange = React.useCallback((_reason?: ContentChangeReason) => { - void _reason; + const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => { + // A tracked height animation (e.g. Thinking auto-collapse) opens a guard + // window so its transient geometry / async scroll events are not misread + // as a user scroll-away. Real gestures still release through + // releaseFromUserIntent, so the user can always scroll up freely. + if (reason === 'animation') { + animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS; + } updateOverflowAndButton(); // Entry-stick window: late structural growth (notably the task/subagent // summary landing from the child session — ToolPart emits 'structural' From f2f2498190fba4c3ffece61e61da330035f6f766 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Tue, 30 Jun 2026 04:47:52 -0400 Subject: [PATCH 33/88] perf(stores): defer safeStorage writes off the interaction path (#1941) * perf(stores): defer safeStorage writes off the interaction path Session switches funnel every persisted store slice through safeStorage.setItem, and doing those large JSON.stringify writes synchronously blocked the main thread for over a second. Add a write-behind buffer that: - Defers each setItem/removeItem to a later task via setTimeout(0) so the click-to-paint path is not blocked. - Coalesces repeated writes to the same key into a single backing flush. - Serves pending values from memory so read-after-write stays consistent within the deferral window. - Flushes synchronously on pagehide/beforeunload/visibilitychange/freeze so deferred state survives tab close, reload, and the mobile freeze lifecycle. Adds a test covering write deferral, coalescing, and pending read serving. * fix(stores): defer persisted JSON serialization * fix(stores): defer direct safeStorage writes --------- Co-authored-by: Bohdan Triapitsyn --- .../src/components/session/SessionSidebar.tsx | 10 +- .../components/update/OpenCodeUpdateToast.tsx | 6 +- packages/ui/src/hooks/usePwaInstallPrompt.ts | 6 +- packages/ui/src/lib/directoryShowHidden.ts | 6 +- .../ui/src/lib/filesViewShowGitignored.ts | 6 +- packages/ui/src/stores/contextStore.ts | 6 +- packages/ui/src/stores/messageQueueStore.ts | 6 +- packages/ui/src/stores/permissionStore.ts | 6 +- packages/ui/src/stores/useAgentsStore.ts | 6 +- packages/ui/src/stores/useAutoReviewStore.ts | 6 +- packages/ui/src/stores/useCommandsStore.ts | 6 +- packages/ui/src/stores/useConfigStore.test.ts | 16 ++ packages/ui/src/stores/useConfigStore.ts | 6 +- packages/ui/src/stores/useDirectoryStore.ts | 4 +- .../ui/src/stores/useFilesViewTabsStore.ts | 6 +- .../ui/src/stores/useGitHubPrStatusStore.ts | 6 +- .../ui/src/stores/useGitIdentitiesStore.ts | 6 +- packages/ui/src/stores/useGitStore.ts | 6 +- .../src/stores/useInlineCommentDraftStore.ts | 6 +- packages/ui/src/stores/useMcpConfigStore.ts | 6 +- packages/ui/src/stores/usePluginsStore.ts | 6 +- packages/ui/src/stores/useProjectsStore.ts | 4 +- .../src/stores/useSessionFoldersStore.test.ts | 1 + .../ui/src/stores/useSessionFoldersStore.ts | 4 +- .../ui/src/stores/useSessionPinnedStore.ts | 4 +- packages/ui/src/stores/useSkillsStore.ts | 6 +- .../ui/src/stores/useTodosPersistStore.ts | 6 +- packages/ui/src/stores/useUIStore.ts | 6 +- .../ui/src/stores/utils/safeStorage.test.ts | 113 ++++++++++ packages/ui/src/stores/utils/safeStorage.ts | 193 ++++++++++++++++++ packages/ui/src/sync/selection-store.ts | 6 +- packages/ui/src/sync/session-ui-store.ts | 8 +- 32 files changed, 406 insertions(+), 83 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index eefb1666bf..af841951bc 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -13,7 +13,7 @@ import { useSync } from '@/sync/use-sync'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { NewWorktreeDialog } from './NewWorktreeDialog'; @@ -188,7 +188,7 @@ export const SessionSidebar: React.FC = ({ const [editTitle, setEditTitle] = React.useState(''); const [editingProjectDialogId, setEditingProjectDialogId] = React.useState(null); const [expandedParents, setExpandedParents] = React.useState>(new Set()); - const safeStorage = React.useMemo(() => getSafeStorage(), []); + const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []); const [collapsedProjects, setCollapsedProjects] = React.useState>(new Set()); const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); @@ -207,7 +207,7 @@ export const SessionSidebar: React.FC = ({ const togglePinnedSession = useSessionPinnedStore((state) => state.toggle); const [collapsedGroups, setCollapsedGroups] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY); if (!raw) { return new Set(); } @@ -219,7 +219,7 @@ export const SessionSidebar: React.FC = ({ }); const [groupOrderByProject, setGroupOrderByProject] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY); if (!raw) { return new Map(); } @@ -237,7 +237,7 @@ export const SessionSidebar: React.FC = ({ }); const [activeSessionByProject, setActiveSessionByProject] = React.useState>(() => { try { - const raw = getSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); + const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY); if (!raw) { return new Map(); } diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index 7a4a321cbf..8d1a3732f2 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { updateDesktopSettings } from '@/lib/persistence'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { resolveOpenCodeUpdateVersion, resolveOpenCodeUpgradeStatusVersion, @@ -100,7 +100,7 @@ export const OpenCodeUpdateToast: React.FC = () => { } const decision = shouldShowOpenCodeUpdateToast({ version, - dismissedVersion: getSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), + dismissedVersion: getDeferredSafeStorage().getItem(UPDATE_TOAST_DISMISSED_VERSION_KEY), seenVersions: seenVersionsRef.current, }); if (!decision) { @@ -119,7 +119,7 @@ export const OpenCodeUpdateToast: React.FC = () => { cancel: { label: t('opencodeUpdate.toast.actions.dismiss'), onClick: () => { - getSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); + getDeferredSafeStorage().setItem(UPDATE_TOAST_DISMISSED_VERSION_KEY, version); void updateDesktopSettings({ openCodeUpdateToastDismissedVersion: version }); toast.dismiss(UPDATE_TOAST_ID); }, diff --git a/packages/ui/src/hooks/usePwaInstallPrompt.ts b/packages/ui/src/hooks/usePwaInstallPrompt.ts index 7f2f829c7c..5c424cb191 100644 --- a/packages/ui/src/hooks/usePwaInstallPrompt.ts +++ b/packages/ui/src/hooks/usePwaInstallPrompt.ts @@ -3,7 +3,7 @@ import { toast } from '@/components/ui'; import { isWebRuntime } from '@/lib/desktop'; import { usePwaDetection } from '@/hooks/usePwaDetection'; import { useI18n } from '@/lib/i18n'; -import { getSafeSessionStorage, getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage, getSafeSessionStorage } from '@/stores/utils/safeStorage'; import { shouldShowPwaInstallToast } from '@/components/update/openCodeUpdateDedup'; type InstallPromptOutcome = 'accepted' | 'dismissed'; @@ -66,7 +66,7 @@ export const usePwaInstallPrompt = () => { installEvent.preventDefault(); deferredPrompt = installEvent; - const localStorage = getSafeStorage(); + const localStorage = getDeferredSafeStorage(); const sessionStorage = getSafeSessionStorage(); const decision = shouldShowPwaInstallToast({ dismissed: localStorage.getItem(INSTALL_TOAST_DISMISSED_KEY), @@ -90,7 +90,7 @@ export const usePwaInstallPrompt = () => { cancel: { label: tRef.current('pwa.installPrompt.dismiss'), onClick: () => { - getSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); + getDeferredSafeStorage().setItem(INSTALL_TOAST_DISMISSED_KEY, 'true'); dismissInstallToast(); }, }, diff --git a/packages/ui/src/lib/directoryShowHidden.ts b/packages/ui/src/lib/directoryShowHidden.ts index 47b9c116ea..0282b1b812 100644 --- a/packages/ui/src/lib/directoryShowHidden.ts +++ b/packages/ui/src/lib/directoryShowHidden.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { updateDesktopSettings } from '@/lib/persistence'; const SHOW_HIDDEN_STORAGE_KEY = 'directoryTreeShowHidden'; @@ -10,7 +10,7 @@ const readStoredShowHidden = (): boolean => { return true; } try { - const stored = getSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); + const stored = getDeferredSafeStorage().getItem(SHOW_HIDDEN_STORAGE_KEY); if (stored === null) { return true; } @@ -35,7 +35,7 @@ export const setDirectoryShowHidden = ( return; } try { - getSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); + getDeferredSafeStorage().setItem(SHOW_HIDDEN_STORAGE_KEY, value ? 'true' : 'false'); notifyDirectoryShowHiddenChanged(); } catch { // ignore storage errors diff --git a/packages/ui/src/lib/filesViewShowGitignored.ts b/packages/ui/src/lib/filesViewShowGitignored.ts index 416d667ed8..210e517fe7 100644 --- a/packages/ui/src/lib/filesViewShowGitignored.ts +++ b/packages/ui/src/lib/filesViewShowGitignored.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { updateDesktopSettings } from '@/lib/persistence'; const SHOW_GITIGNORED_STORAGE_KEY = 'filesViewShowGitignored'; @@ -11,7 +11,7 @@ const readStoredShowGitignored = (): boolean => { return false; } try { - const stored = getSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); + const stored = getDeferredSafeStorage().getItem(SHOW_GITIGNORED_STORAGE_KEY); return stored === 'true'; } catch { return false; @@ -33,7 +33,7 @@ export const setFilesViewShowGitignored = ( return; } try { - getSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); + getDeferredSafeStorage().setItem(SHOW_GITIGNORED_STORAGE_KEY, value ? 'true' : 'false'); notifyFilesViewShowGitignoredChanged(); } catch { // ignore storage errors diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index 09c3738362..ad824f1513 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { EditPermissionMode } from "./types/sessionTypes"; import { getAgentDefaultEditPermission } from "./utils/permissionUtils"; import { extractTokensFromMessage } from "./utils/tokenUtils"; import { calculateContextUsage } from "./utils/contextUtils"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; interface ContextUsage { totalTokens: number; @@ -449,7 +449,7 @@ export const useContextStore = create()( }), { name: "context-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ sessionModelSelections: Array.from(state.sessionModelSelections.entries()), sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()), diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index d27862a663..ae964febae 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { updateDesktopSettings } from '@/lib/persistence'; @@ -201,7 +201,7 @@ export const useMessageQueueStore = create()( { name: 'message-queue-store', version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ queuedMessages: state.queuedMessages, followUpBehavior: state.followUpBehavior, diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 6f54c5e20d..6ac01416ec 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk/v2/client"; import { autoRespondsPermission, type PermissionAutoAcceptMap, } from "./utils/permissionAutoAccept"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs"; import { opencodeClient } from "@/lib/opencode/client"; import { respondToPermission } from "@/sync/session-actions"; @@ -201,7 +201,7 @@ const autoRespondsPermissionBySession = ( }); }; -const getStorage = () => createJSONStorage(() => getSafeStorage()); +const getStorage = () => createDeferredSafeJSONStorage(); export const usePermissionStore = create()( devtools( diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index d9bd2077b1..0b9cfef93b 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync"; @@ -9,7 +9,7 @@ import { finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useConfigStore } from "@/stores/useConfigStore"; import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; @@ -556,7 +556,7 @@ export const useAgentsStore = create()( }), { name: "agents-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedAgentName: state.selectedAgentName, }), diff --git a/packages/ui/src/stores/useAutoReviewStore.ts b/packages/ui/src/stores/useAutoReviewStore.ts index b5da3f19b7..fe4f5acba6 100644 --- a/packages/ui/src/stores/useAutoReviewStore.ts +++ b/packages/ui/src/stores/useAutoReviewStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer'; type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error'; @@ -88,7 +88,7 @@ export const useAutoReviewStore = create()( }), { name: 'auto-review-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }), }, ), diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 517a056793..9e1dc60879 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import { opencodeClient } from "@/lib/opencode/client"; import { startConfigUpdate, @@ -8,7 +8,7 @@ import { updateConfigUpdateMessage, } from "@/lib/configUpdate"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { runtimeFetch } from "@/lib/runtime-fetch"; @@ -427,7 +427,7 @@ export const useCommandsStore = create()( }), { name: "commands-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedCommandName: state.selectedCommandName, }), diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index ca4562c0bb..0b2b18c67d 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -129,7 +129,23 @@ const deferred = () => { }; mock.module('@/stores/utils/safeStorage', () => ({ + getDeferredSafeStorage: () => makeStorage(), getSafeStorage: () => makeStorage(), + createDeferredSafeJSONStorage: () => { + const testStorage = makeStorage(); + return { + getItem: (name: string) => { + const value = testStorage.getItem(name); + return value === null ? null : JSON.parse(value); + }, + setItem: (name: string, value: unknown) => { + testStorage.setItem(name, JSON.stringify(value)); + }, + removeItem: (name: string) => { + testStorage.removeItem(name); + }, + }; + }, })); mock.module('@/stores/useProjectsStore', () => ({ diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index ae0f3131bc..88b808a81d 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "@/lib/opencode/client"; import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import type { ModelMetadata } from "@/types"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { filterVisibleAgents } from "./useAgentsStore"; import { isPrimaryMode } from "@/components/chat/mobileControlsUtils"; import { useSessionUIStore } from "@/sync/session-ui-store"; @@ -3283,7 +3283,7 @@ export const useConfigStore = create()( }), { name: "config-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), merge: (persistedState, currentState) => hydrateActiveDirectorySnapshot({ ...currentState, diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index ea7d59ffaa..b0b32af5f8 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -6,7 +6,7 @@ import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; interface DirectoryStore { @@ -28,7 +28,7 @@ interface DirectoryStore { let cachedHomeDirectory: string | null = null; let homeResolveGeneration = 0; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); const persistedLastDirectory = safeStorage.getItem('lastDirectory'); const initialHasPersistedDirectory = typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0; diff --git a/packages/ui/src/stores/useFilesViewTabsStore.ts b/packages/ui/src/stores/useFilesViewTabsStore.ts index 1f391cf0f5..b069f5074c 100644 --- a/packages/ui/src/stores/useFilesViewTabsStore.ts +++ b/packages/ui/src/stores/useFilesViewTabsStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; type RootTabsState = { openPaths: string[]; @@ -451,7 +451,7 @@ export const useFilesViewTabsStore = create()( { name: 'files-view-tabs-store', version: 2, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), migrate: (persistedState) => { if (!persistedState || typeof persistedState !== 'object') { return { byRoot: {} }; diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 9293375058..0dd0b24d0f 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types'; import { mapWithConcurrency } from '@/lib/concurrency'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; const PR_REVALIDATE_TTL_MS = 90_000; const PR_REVALIDATE_INTERVAL_MS = 15_000; @@ -647,7 +647,7 @@ export const useGitHubPrStatusStore = create()( }), { name: PR_STATUS_STORAGE_KEY, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ entries: Object.fromEntries( Object.entries(state.entries) diff --git a/packages/ui/src/stores/useGitIdentitiesStore.ts b/packages/ui/src/stores/useGitIdentitiesStore.ts index 917a049a9a..d43b2cda3e 100644 --- a/packages/ui/src/stores/useGitIdentitiesStore.ts +++ b/packages/ui/src/stores/useGitIdentitiesStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; -import { getSafeStorage } from "./utils/safeStorage"; +import { devtools, persist } from "zustand/middleware"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { getGitIdentities, createGitIdentity, @@ -271,7 +271,7 @@ export const useGitIdentitiesStore = create()( }), { name: "git-identities-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedProfileId: state.selectedProfileId, }), diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 87aebe8cf9..05a5648b5d 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -7,7 +7,7 @@ import type { GitLogResponse, GitIdentitySummary, } from '@/lib/api/types'; -import { getSafeStorage } from '@/stores/utils/safeStorage'; +import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -155,7 +155,7 @@ const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache'; const readBranchCache = (): Record => { try { - const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); + const raw = getDeferredSafeStorage().getItem(GIT_BRANCH_CACHE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; return parsed && typeof parsed === 'object' ? parsed : {}; @@ -169,7 +169,7 @@ const writeCachedBranches = (directory: string, branches: GitBranch): void => { try { const cache = readBranchCache(); cache[directory] = branches; - getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); + getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache)); } catch { // quota / serialization — ignore; live fetch still refreshes the store } diff --git a/packages/ui/src/stores/useInlineCommentDraftStore.ts b/packages/ui/src/stores/useInlineCommentDraftStore.ts index e6c481290e..4148b3abe7 100644 --- a/packages/ui/src/stores/useInlineCommentDraftStore.ts +++ b/packages/ui/src/stores/useInlineCommentDraftStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation'; @@ -207,7 +207,7 @@ export const useInlineCommentDraftStore = create()( }), { name: 'openchamber-inline-comment-drafts', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), version: 1, migrate: (persistedState: unknown) => { if (!persistedState || typeof persistedState !== 'object') { diff --git a/packages/ui/src/stores/useMcpConfigStore.ts b/packages/ui/src/stores/useMcpConfigStore.ts index 41a4be33f5..48a25a25ea 100644 --- a/packages/ui/src/stores/useMcpConfigStore.ts +++ b/packages/ui/src/stores/useMcpConfigStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, @@ -350,7 +350,7 @@ export const useMcpConfigStore = create()( }), { name: 'mcp-config-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedMcpName: state.selectedMcpName }), }, ), diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 2a6aa5e213..364da00088 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { devtools, persist } from 'zustand/middleware'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, @@ -352,7 +352,7 @@ export const usePluginsStore = create()( }), { name: 'plugins-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedId: state.selectedId }), }, ), diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index d5460f51fc..bb3f258fcb 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -6,7 +6,7 @@ import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; import { createProjectIdFromPath } from '@/lib/projectId'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { PROJECT_COLORS } from '@/lib/projectMeta'; @@ -64,7 +64,7 @@ interface ProjectsStore { getActiveProject: () => ProjectEntry | null; } -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); const PROJECTS_STORAGE_KEY = 'projects'; const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId'; diff --git a/packages/ui/src/stores/useSessionFoldersStore.test.ts b/packages/ui/src/stores/useSessionFoldersStore.test.ts index 4226408184..95b8a9a4ad 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.test.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.test.ts @@ -22,6 +22,7 @@ const safeStorage = { } as Storage; mock.module('./utils/safeStorage', () => ({ + getDeferredSafeStorage: () => safeStorage, getSafeStorage: () => safeStorage, })); diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 7b61d65ff4..c4254873e1 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; import { isVSCodeRuntime } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -46,7 +46,7 @@ const SESSION_FOLDERS_API_PATH = '/api/session-folders'; const DISK_WRITE_DEBOUNCE_MS = 250; const ARCHIVED_SCOPE_PREFIX = '__archived__:'; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); let diskWriteTimer: ReturnType | null = null; let diskHydrated = false; let diskHydrationInFlight = false; diff --git a/packages/ui/src/stores/useSessionPinnedStore.ts b/packages/ui/src/stores/useSessionPinnedStore.ts index 1f60304b55..56b42f71ed 100644 --- a/packages/ui/src/stores/useSessionPinnedStore.ts +++ b/packages/ui/src/stores/useSessionPinnedStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { getSafeStorage } from './utils/safeStorage'; +import { getDeferredSafeStorage } from './utils/safeStorage'; const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned'; @@ -29,7 +29,7 @@ type SessionPinnedStore = { toggle: (sessionId: string) => void; }; -const safeStorage = getSafeStorage(); +const safeStorage = getDeferredSafeStorage(); export const useSessionPinnedStore = create((set, get) => ({ ids: readPinned(safeStorage), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index f2e3dae069..f94c1f2a19 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -1,13 +1,13 @@ import { create } from "zustand"; import type { StoreApi, UseBoundStore } from "zustand"; -import { devtools, persist, createJSONStorage } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync"; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage, } from "@/lib/configUpdate"; -import { getSafeStorage } from "./utils/safeStorage"; +import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { opencodeClient } from '@/lib/opencode/client'; @@ -490,7 +490,7 @@ export const useSkillsStore = create()( }), { name: "skills-store", - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ selectedSkillName: state.selectedSkillName, }), diff --git a/packages/ui/src/stores/useTodosPersistStore.ts b/packages/ui/src/stores/useTodosPersistStore.ts index b9d394fb22..0a5ba49fff 100644 --- a/packages/ui/src/stores/useTodosPersistStore.ts +++ b/packages/ui/src/stores/useTodosPersistStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { Todo } from '@opencode-ai/sdk/v2/client'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; const MAX_SESSIONS = 50; @@ -55,7 +55,7 @@ export const useTodosPersistStore = create()( { name: 'openchamber-session-todos', version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ sessions: state.sessions }), }, ), diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index e4db27f1e6..b8abc30ca2 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { devtools, persist, createJSONStorage } from 'zustand/middleware'; +import { devtools, persist } from 'zustand/middleware'; import type { SidebarSection } from '@/constants/sidebar'; -import { getSafeStorage } from './utils/safeStorage'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography'; import type { ShortcutCombo } from '@/lib/shortcuts'; import type { DraftStarterRef } from '@/lib/draftStarters'; @@ -2106,7 +2106,7 @@ export const useUIStore = create()( }), { name: 'ui-store', - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), version: 10, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { diff --git a/packages/ui/src/stores/utils/safeStorage.test.ts b/packages/ui/src/stores/utils/safeStorage.test.ts index 31301261f9..359d2ed10b 100644 --- a/packages/ui/src/stores/utils/safeStorage.test.ts +++ b/packages/ui/src/stores/utils/safeStorage.test.ts @@ -4,6 +4,24 @@ const importSafeStorage = async () => { return await import(`./safeStorage.ts?test=${Date.now()}-${Math.random()}`) as typeof import('./safeStorage'); }; +const createFakeStorage = (): Storage => { + const store = new Map(); + return { + getItem: (k) => (store.has(k) ? store.get(k)! : null), + setItem: (k, v) => { + store.set(k, String(v)); + }, + removeItem: (k) => { + store.delete(k); + }, + clear: () => store.clear(), + key: (i) => Array.from(store.keys())[i] ?? null, + get length() { + return store.size; + }, + } as Storage; +}; + describe('safeStorage', () => { test('falls back to memory when storage getters throw', async () => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); @@ -45,4 +63,99 @@ describe('safeStorage', () => { } } }); + + test('defers persisted JSON serialization and serves pending reads', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousStringify = JSON.stringify; + const stringifyCalls: unknown[] = []; + const backingStorage = createFakeStorage(); + const fakeWindow = { + localStorage: backingStorage, + sessionStorage: createFakeStorage(), + addEventListener: () => {}, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }); + + try { + JSON.stringify = ((value: unknown, replacer?: Parameters[1], space?: Parameters[2]) => { + stringifyCalls.push(value); + return previousStringify(value, replacer, space); + }) as typeof JSON.stringify; + + const { createDeferredSafeJSONStorage } = await importSafeStorage(); + const storage = createDeferredSafeJSONStorage<{ value: string }>(); + + expect(Boolean(storage)).toBe(true); + if (!storage) throw new Error('storage unavailable'); + + storage.setItem('k', { state: { value: 'v' } }); + + // Neither serialization nor the backing write runs on the call site... + expect(stringifyCalls).toHaveLength(0); + expect(backingStorage.getItem('k')).toBeNull(); + // ...but read-after-write still returns the pending value. + expect(storage.getItem('k')).toEqual({ state: { value: 'v' } }); + + // Coalesce: a second write to the same key should not produce two + // stringifications/backing writes, and the latest value wins. + storage.setItem('k', { state: { value: 'v2' } }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(stringifyCalls).toEqual([{ state: { value: 'v2' } }]); + expect(backingStorage.getItem('k')).toBe('{"state":{"value":"v2"}}'); + expect(storage.getItem('k')).toEqual({ state: { value: 'v2' } }); + } finally { + JSON.stringify = previousStringify; + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } + }); + + test('defers direct storage writes and flushes on pagehide', async () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const backingStorage = createFakeStorage(); + const listeners = new Map void>>(); + const fakeWindow = { + localStorage: backingStorage, + sessionStorage: createFakeStorage(), + addEventListener: (event: string, listener: () => void) => { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + }, + }; + + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: fakeWindow, + }); + + try { + const { getDeferredSafeStorage } = await importSafeStorage(); + const storage = getDeferredSafeStorage(); + + storage.setItem('direct-k', 'direct-v'); + + expect(backingStorage.getItem('direct-k')).toBeNull(); + expect(storage.getItem('direct-k')).toBe('direct-v'); + + for (const listener of listeners.get('pagehide') ?? []) { + listener(); + } + + expect(backingStorage.getItem('direct-k')).toBe('direct-v'); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } + }); }); diff --git a/packages/ui/src/stores/utils/safeStorage.ts b/packages/ui/src/stores/utils/safeStorage.ts index 3a568aa849..8389d089e6 100644 --- a/packages/ui/src/stores/utils/safeStorage.ts +++ b/packages/ui/src/stores/utils/safeStorage.ts @@ -1,5 +1,191 @@ +import type { PersistStorage, StateStorage, StorageValue } from 'zustand/middleware'; + let safeStorageInstance: Storage | null = null; let safeSessionStorageInstance: Storage | null = null; +let deferredSafeStorageInstance: Storage | null = null; + +const deferredFlushers = new Set<() => void>(); +let deferredFlushListenersRegistered = false; + +type JsonStorageOptions = { + reviver?: (key: string, value: unknown) => unknown; + replacer?: (key: string, value: unknown) => unknown; +}; + +const registerDeferredFlusher = (flush: () => void) => { + deferredFlushers.add(flush); + if (deferredFlushListenersRegistered || typeof window === 'undefined') return; + + deferredFlushListenersRegistered = true; + const flushAll = () => { + for (const flushDeferredStorage of deferredFlushers) { + flushDeferredStorage(); + } + }; + + try { + window.addEventListener('pagehide', flushAll, { capture: true }); + window.addEventListener('beforeunload', flushAll, { capture: true }); + window.addEventListener('visibilitychange', () => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushAll(); + }); + window.addEventListener('freeze', flushAll); + } catch { + // Restricted environments can reject listeners; timers still flush. + } +}; + +const createDeferredJSONStorage = ( + getStorage: () => StateStorage, + options?: JsonStorageOptions, +): PersistStorage | undefined => { + let storage: StateStorage; + try { + storage = getStorage(); + } catch { + return undefined; + } + + const pendingWrites = new Map>(); + const pendingDeletes = new Set(); + let flushTimer: ReturnType | undefined; + + const flush = () => { + flushTimer = undefined; + if (pendingWrites.size === 0 && pendingDeletes.size === 0) return; + + const writes = Array.from(pendingWrites.entries()); + const deletes = Array.from(pendingDeletes); + pendingWrites.clear(); + pendingDeletes.clear(); + + for (const [name, value] of writes) { + try { + storage.setItem(name, JSON.stringify(value, options?.replacer)); + } catch (error) { + console.error('Failed to persist deferred storage value', error); + } + } + for (const name of deletes) { + try { + storage.removeItem(name); + } catch (error) { + console.error('Failed to remove deferred storage value', error); + } + } + }; + + const scheduleFlush = () => { + if (flushTimer !== undefined) return; + flushTimer = setTimeout(flush, 0); + }; + + registerDeferredFlusher(flush); + + return { + getItem: (name) => { + if (pendingWrites.has(name)) { + return pendingWrites.get(name) ?? null; + } + if (pendingDeletes.has(name)) { + return null; + } + + const parse = (value: string | null): StorageValue | null => { + if (value === null) return null; + return JSON.parse(value, options?.reviver) as StorageValue; + }; + const value = storage.getItem(name); + if (value instanceof Promise) { + return value.then(parse); + } + return parse(value); + }, + setItem: (name, value) => { + pendingWrites.set(name, value); + pendingDeletes.delete(name); + scheduleFlush(); + }, + removeItem: (name) => { + pendingWrites.delete(name); + pendingDeletes.add(name); + scheduleFlush(); + }, + }; +}; + +export const createDeferredSafeJSONStorage = (options?: JsonStorageOptions) => ( + createDeferredJSONStorage(() => getSafeStorage(), options) +); + +const createDeferredStorage = (storage: Storage): Storage => { + const pendingWrites = new Map(); + const pendingDeletes = new Set(); + let flushTimer: ReturnType | undefined; + + const flush = () => { + flushTimer = undefined; + if (pendingWrites.size === 0 && pendingDeletes.size === 0) return; + + const writes = Array.from(pendingWrites.entries()); + const deletes = Array.from(pendingDeletes); + pendingWrites.clear(); + pendingDeletes.clear(); + + for (const [key, value] of writes) { + try { + storage.setItem(key, value); + } catch (error) { + console.error('Failed to persist deferred storage value', error); + } + } + for (const key of deletes) { + try { + storage.removeItem(key); + } catch (error) { + console.error('Failed to remove deferred storage value', error); + } + } + }; + + const scheduleFlush = () => { + if (flushTimer !== undefined) return; + flushTimer = setTimeout(flush, 0); + }; + + registerDeferredFlusher(flush); + + return { + getItem: (key) => { + if (pendingWrites.has(key)) return pendingWrites.get(key) ?? null; + if (pendingDeletes.has(key)) return null; + return storage.getItem(key); + }, + setItem: (key, value) => { + pendingWrites.set(key, value); + pendingDeletes.delete(key); + scheduleFlush(); + }, + removeItem: (key) => { + pendingWrites.delete(key); + pendingDeletes.add(key); + scheduleFlush(); + }, + clear: () => { + pendingWrites.clear(); + pendingDeletes.clear(); + if (flushTimer !== undefined) { + clearTimeout(flushTimer); + flushTimer = undefined; + } + storage.clear(); + }, + key: (index) => storage.key(index), + get length() { + return storage.length; + }, + } as Storage; +}; const getWindowStorage = (key: 'localStorage' | 'sessionStorage'): Storage | null => { if (typeof window === 'undefined') { @@ -135,6 +321,13 @@ export const getSafeStorage = (): Storage => { return safeStorageInstance; }; +export const getDeferredSafeStorage = (): Storage => { + if (!deferredSafeStorageInstance) { + deferredSafeStorageInstance = createDeferredStorage(getSafeStorage()); + } + return deferredSafeStorageInstance; +}; + const createSafeSessionStorage = (): Storage => { const baseStorage = getWindowStorage('sessionStorage'); diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts index 7b3058e349..a8c1cb21ce 100644 --- a/packages/ui/src/sync/selection-store.ts +++ b/packages/ui/src/sync/selection-store.ts @@ -4,8 +4,8 @@ */ import { create } from "zustand" -import { persist, createJSONStorage } from "zustand/middleware" -import { getSafeStorage } from "@/stores/utils/safeStorage" +import { persist } from "zustand/middleware" +import { createDeferredSafeJSONStorage } from "@/stores/utils/safeStorage" type ModelSelection = { providerId: string; modelId: string } type LastUsedProvider = { providerID: string; modelID: string } @@ -126,7 +126,7 @@ export const useSelectionStore = create()( { name: "selection-store", version: 1, - storage: createJSONStorage(() => getSafeStorage()), + storage: createDeferredSafeJSONStorage(), partialize: (state) => { // Convert Maps to arrays and slice to keep only the most recent MAX_PERSISTED_SESSIONS const models = Array.from(state.sessionModelSelections.entries()).slice(-MAX_PERSISTED_SESSIONS) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index dabad03c8f..9b09c711a7 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -25,7 +25,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore" import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore" import { useCommandsStore } from "@/stores/useCommandsStore" import { useSkillsStore } from "@/stores/useSkillsStore" -import { getSafeStorage } from "@/stores/utils/safeStorage" +import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" @@ -327,7 +327,7 @@ const resolveDirectoryKey = (session: Session): string | null => { ?? normalizePath(sessionRecord.project?.worktree ?? null) } -const safeStorage = getSafeStorage() +const safeStorage = getDeferredSafeStorage() const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" type PersistedDraftTarget = { projectId: string | null; directory: string | null } @@ -515,7 +515,7 @@ const WORKTREE_MAP_STORAGE_KEY = 'oc.worktreeMap' const loadPersistedWorktreeMap = (): Map => { try { - const raw = getSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) + const raw = getDeferredSafeStorage().getItem(WORKTREE_MAP_STORAGE_KEY) if (!raw) return new Map() const entries = JSON.parse(raw) as Array<[string, WorktreeMetadata[]]> if (!Array.isArray(entries)) return new Map() @@ -529,7 +529,7 @@ const loadPersistedWorktreeMap = (): Map => { const persistWorktreeMap = (map: Map): void => { try { - getSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) + getDeferredSafeStorage().setItem(WORKTREE_MAP_STORAGE_KEY, JSON.stringify([...map.entries()])) } catch { // quota / serialization error — ignore; discovery still refreshes at runtime } From 868f72a035fc4200565000b7bb51a659ffa1723d Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Tue, 30 Jun 2026 21:42:59 -0400 Subject: [PATCH 34/88] ci: add merge-conflict label automation workflow (#1942) Adds label-merge-conflict workflow using eps1lon/actions-label-merge-conflict to label PRs with merge-conflict:true when they have conflicts. Triggers on push to main, pull_request_target (opened/synchronize/reopened), and manual dispatch. Uses the bot app token for label writes and is scoped to the openchamber/openchamber repo. --- .github/workflows/label-merge-conflict.yml | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/label-merge-conflict.yml diff --git a/.github/workflows/label-merge-conflict.yml b/.github/workflows/label-merge-conflict.yml new file mode 100644 index 0000000000..8d61194e12 --- /dev/null +++ b/.github/workflows/label-merge-conflict.yml @@ -0,0 +1,31 @@ +name: label-merge-conflict + +on: + push: + branches: [main] + pull_request_target: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: {} + +jobs: + label: + if: ${{ github.repository == 'openchamber/openchamber' }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Generate bot app token + id: app-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ secrets.OC_REVIEW_APP_ID }} + private-key: ${{ secrets.OC_REVIEW_APP_PRIVATE_KEY }} + + - name: Label pull requests with merge conflicts + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 + with: + dirtyLabel: "merge-conflict:true" + repoToken: ${{ steps.app-token.outputs.token }} From bf8f39f36c14ea711d150da74d19c144e855212b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 09:55:41 +0300 Subject: [PATCH 35/88] feat: native iOS & Android mobile apps (Capacitor) (#1954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): add Capacitor native shell * docs: add serve-sim workflow guidance * docs(mobile): add implementation handoff * chore(mobile): clean up generated defaults * feat(mobile): add connection onboarding * feat(mobile): manage saved instances * feat(mobile): refine connection management UI * chore(mobile): upgrade Capacitor 8 * fix(mobile): reliable saved-instance auth with secure token storage - store client tokens in the OS secure store (iOS Keychain / Android Keystore) per instance URL via direct native plugin calls; keep only token-less metadata in localStorage. Bound every secure call so a stalled bridge can't hang unlock. - bypass the secure-storage JS wrapper's lazy platform load (which stalled in the webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly. - harden the shared connect/unlock controller (health + session + progressive password) and drop the heavy pre-connect hydration that stalled no-token hosts. - await token persistence before switching runtime endpoints (no fire-and-forget). - sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8. * fix(mobile): keep UI stable across connection churn (no transport hardcoding) The "reload every ~10s" was a UX bug, not a transport one: - MobileSurfaceShell received a fresh inline onClose each parent render, so any re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first element — stealing focus from the active input and collapsing the keyboard mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on `open`. Fixes all sheets (Instances/Files/Changes/Settings). - Gate the mobile shell on connectionPhase, not the live isConnected flag, so a transient reconnect keeps MobileShell mounted instead of flashing the loader. - Instances form: populate fields imperatively on edit/cancel/save instead of via an effect keyed on the derived connection, so list churn can't wipe input. Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so WS-only Quick Tunnels and SSE-capable proxies both keep working. * feat(mobile): add native QR pairing-code scanner Wire the connection onboarding + Instances scan buttons to a real native scanner via @capacitor-mlkit/barcode-scanning, which registers as the BarcodeScanner plugin the existing mobileQrScan helper already resolves at runtime. Add NSCameraUsageDescription and bump the iOS deployment target to 15.5 (GoogleMLKit 8 requirement). * fix(cli): repair connect-url host resolution Define the missing isWildcardBindHost helper that connect-url called but was never declared, which crashed any link generation that reached host resolution. Also treat a full http(s) --host value as a public server URL so '--host https://example.com' produces a correct link instead of 'http://https://example.com:port'. * fix(mobile): make input follow the keyboard across all surfaces Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which fires at the start of the iOS keyboard animation. A transition tuned to the native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes the layout rise together with the keyboard instead of snapping into place after the built-in 'native' resize finished (~1.5s lag). The inset is consumed by every surface that can hold a focused input: - chat shell shrinks its height; - portal sheets/overlays raise their bottom edge; - the full-screen connect/login view caps its height so it actually scrolls (and is now generally scrollable for long saved-connection lists). * feat(mobile): rounder chat composer + native bottom safe area Round the mobile chat composer corners a touch more (1rem), and reserve a small app-level bottom safe area for the native shell via the --oc-app-bottom-safe token so controls clear the phone's rounded hardware corners. The reservation folds into the keyboard inset (no gap above the keyboard), and the composer's own bottom padding tightens while the keyboard is open. * fix(mobile): remove iOS 26 dark status-bar band; polish composer The dark band behind the status bar in system Dark Mode was iOS 26's automatic scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the status bar — appearance-coloured, so it tracked the system theme regardless of the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed web background shows under the overlaid status bar. Also: re-assert the status-bar overlay on resume, paint the document canvas with the theme background in the native shell, round the composer corners to 1.5rem, and enlarge the app-level bottom safe area so controls clear the rounded corners. * feat(mobile): logo splash until first paint is final (no FOUT / layout shift) Cold start flashed the fallback font and then reflowed once the real font and persisted appearance prefs landed, and text jumped a frame after mount because the mobile typography classes were applied from a hook effect. Fix it on three fronts: - apply device classes (device-mobile / mobile-pointer) synchronously in renderMobileApp before the first React paint, so mobile --text-* sizes are in effect from the start; - hold a logo splash (useFontsReady) until the UI web font has loaded; - gate that splash on appBootReady too, resolved once async appearance/typography preferences are applied, plus a double rAF so styles commit before reveal. All under a 2.5s safety timeout so a slow/offline CDN can't block startup. * feat(mobile): native local notifications; APNs implemented but frozen The native app now delivers agent ready/error/question/permission events as iOS (and Android) Local Notifications: a native notifications API backed by @capacitor/local-notifications replaces the Web Notifications API (which doesn't display in a WKWebView), driven by the notification SSE stream now subscribed in the mobile app. Tapping a notification opens its session. Also fix the settings toggle, which treated the Capacitor app as a browser and gated 'Enable Notifications' on the absent Web Notification permission, leaving it un-toggleable. Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT server runtime, token routes, client registration, iOS native config) but kept dormant: config-gated so it never fires, client registration not wired, and the aps-environment entitlement / background mode removed so the app builds with no Apple push setup. It will be reused once OpenChamber ships its own encrypted relay so users don't each configure APNs. See notifications/APNS.md. WKWebView can't use web push (unlike an installed PWA), so true background-when-suspended delivery on native requires APNs via that relay. * feat(mobile): APNs relay-mode background push Deliver native iOS background push through the central relay: the server posts device tokens + generic, model-based text to api.openchamber.dev/v1/push/send (default), which holds the single APNs key and signs+sends; dead tokens (410) are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload is generic only (model + scenario) so no session content crosses the relay. Re-enable the client token registration (useNativePushRegistration) and the aps-environment entitlement (alert pushes need no background mode). Wired into the same fanout as web push; focus-suppressed and only when tokens exist. * fix(mobile): APNs-only native notifications, generic templates, no foreground Make APNs the single notification channel for the native app and fix delivery: - Remove local notifications entirely (the @capacitor/local-notifications plugin and the SSE-driven path). A WKWebView can't tell foreground from background (document.hasFocus() is unreliable), so local notifications leaked while the app was open; the in-app dispatch is no-op'd on native. - Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden' before iOS suspends it, which dropped background push. Instead always send and let iOS suppress the foreground banner (PushNotifications presentationOptions: []). - Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger before any push was sent. - Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs your input' / 'needs permission' / 'hit an error') + the session name, no model or message content. - Hide the focus toggle, templates, and test button in mobile notification settings. * feat(push): sign relay requests + bind tokens per server Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings, like the VAPID keys) and uses it to: - bind every newly-seen device token to the server on the relay (POST /v1/push/register-token, signed), and - sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title). The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and only delivers to tokens bound to that server. Result: a leaked device token alone can no longer be used to push to a device — the sender also needs the server's private key. Stays zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer. * docs(push): describe relay data-confidentiality model Document that the push payload is not application-encrypted (TLS-in-transit only), what the relay and Apple can see (generic scenario title + session name, plus token/sessionId), that the signature is authentication rather than encryption, and what an end-to-end encrypted payload would require. * fix: invalid skill description * feat(push): app-icon badge for native notifications Send an absolute aps.badge with each native push = the count of distinct collapse-ids (tag) pushed since the app was last foregrounded, mirroring the lock-screen banner stack. Cleared server-side on user engagement (session view, message-sent, visibility beacon) and on-device via sceneDidBecomeActive. * feat(mobile): auto-connect last instance on launch + notification deep-links Cold launch silently reconnects to the most-recent saved instance (when reachable and a token is saved), holding the splash instead of flashing the connect screen; falls back to the connect screen when there's no saved instance, it's unreachable, or it needs a re-login. Notification-tap deep-links are now captured unconditionally (even before connect / on cold launch) and applied once the app is ready, so a tap opens the target session instead of being lost on the login screen. * fix(mobile): resolve theme background before first paint on cold launch The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold launch flashed the WebView's default light canvas, then the baked design-system default (.dark { --background: #151313 }) via body.bg-background, before React's theme system injected the real theme vars. Add a blocking script that resolves dark/light from the persisted theme + system preference and sets --background (plus color-scheme and the element background) inline on the root, so the very first paint matches the resolved theme. Falls back to the default flexoki backgrounds when no theme has been persisted yet. * feat(mobile): openchamber:// deep-link foundation + arm64 simulator build Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent) and a single native navigation layer (deepLinkNavigation.ts) that handles both the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities, external links) and notification taps, normalising each into an intent. Session and new-session resolve against the store; shell surfaces (sessions/settings/ views/changes) register handlers. Cold-launch intents stash until the app is ready. Replaces the push-only useNativePushDeepLink and keeps backwards compatibility with bare sessionId payloads. Register the openchamber:// scheme in Info.plist. Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new ios-sim-build script that temporarily drops the MLKit barcode-scanning pod (no arm64-simulator slice) so the app builds an arm64 binary installable on Apple Silicon simulators, then restores the Podfile + Pods for device builds. QR scanning already degrades cleanly when the native plugin is absent. * feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension (OpenChamberNotificationService), wired into the Xcode project, sharing an App Group with the app. Widgets: - Overview (medium): recent sessions with read/unread dots + four quick actions (new, status, instances, settings). - Sessions (large): session list with per-session project label, attention count and a new-session button in the header. - Quick Actions (small): New chat pill + status/instances. - Lock Screen (accessoryCircular x2): brand logo to new session, attention counter. - Control Center control: brand logo (custom SF Symbol) to new session. Data: the app writes a session-overview snapshot (attention count + recent sessions with project labels) to the App Group on scene activate/resign; the NSE refreshes it from each push (aps.badge + sessionId) so widgets update even when the app is closed (needs aps mutable-content, added to the server + relay). Deep links: add openchamber://status (session status panel) and reuse view/instances; all widget taps route through the existing deep-link channel. * feat(mobile): large Sessions widget lists 6 sessions with project labels * feat(mobile): edge-swipe to switch sessions with directional slide+fade * fix(mobile): keep widgets in sync via reload-on-change + periodic refresh Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the large Sessions widget stale (no unread dot / attention count) while medium updated. Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when the session overview actually changed (so we don't burn the budget on every scene activate/resign), and give each widget a periodic timeline refresh so a missed reload self-corrects. * feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan Cosmetics: - Status bar: on Android inset the WebView below the bar (overlay:false) and paint it with the resolved theme background + correct content Style, since Android doesn't feed env(safe-area-inset-top) to CSS. - Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes natively, so applying it double-counted and floated the composer); declare windowSoftInputMode=adjustResize and disable the shell height transition on Android so the header no longer bounces on keyboard open. Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable on Android (events only arrive once a run finishes). Forced in sync-context and the other options are disabled in the Chat settings UI. Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications register() needs Firebase/FCM (not configured) and crashes at launch. QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and install/await the Google barcode scanner module (with a post-install retry) before scanning so the first scan works without a manual retry. Icon: Android adaptive launcher icon generated from the cube logo (full-bleed white background, no edge artifact on One UI). Source assets under mobile/assets. Tooling: adb-based android-device.mjs + android:* scripts for device deploy. * feat(notifications): presence-aware push routing (don't spam the phone) Only push to a device when the notification would otherwise be missed there. A notification is suppressed on devices where the user is already present. - Tag every client's visibility beacon and web-push subscription with a platform ('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform(). - Server tracks visibility per client (keyed by oc_ui_session) with the platform, and exposes isAnyInteractiveClientVisible() = any visible non-mobile client. - Native push (APNs) and mobile PWA web-push are now suppressed when an interactive (desktop/web/vscode) client is visible — it already shows the in-app notification. Gated on the desktop's visibility (reliable), never the phone's own (a backgrounded WKWebView can't report "hidden"). - Desktop/web web-push keeps the any-visible gate (a visible client absorbs it). - Skipping APNs also skips the badge increment so it doesn't drift. Fixes the case where every session on a shared instance pushed to the phone even while the user was actively working on desktop. * feat(mobile): Android FCM push notifications Enable native background push on Android via Firebase Cloud Messaging, in parallel with the existing iOS APNs path. - Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google Services Gradle plugin is applied when the file is present, so register() returns an FCM token instead of crashing. - Un-gate native push registration to iOS OR Android, and tag the registered token with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM. - Server stores the platform per device token and binds it to the relay (platform included in the signed register message). - Notification small icon: monochrome cube silhouette with a mark on the top face, set as the FCM default_notification_icon so the status-bar icon reads as the logo. Relay-side FCM sending ships in openchamber-website. * docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap * chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag) - Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App Group snapshot shared with the widget + notification service extension) and wire it into the App target's resources — Apple requires an app-level privacy manifest. - Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt. - HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items). Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy bundled into App.app. * refactor(mobile): dedupe capacitor detection + make beacon guard explicit Addresses non-blocking PR review notes: - Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation, usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform. - usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes. --- .agents/skills/locale-ui-patterns/SKILL.md | 8 +- .agents/skills/serve-sim/SKILL.md | 69 ++ AGENTS.md | 1 + bun.lock | 167 ++- package.json | 18 + packages/mobile/HANDOFF.md | 218 ++++ packages/mobile/README.md | 71 ++ packages/mobile/android/.gitignore | 101 ++ packages/mobile/android/app/.gitignore | 2 + packages/mobile/android/app/build.gradle | 50 + .../mobile/android/app/capacitor.build.gradle | 24 + .../mobile/android/app/google-services.json | 29 + .../mobile/android/app/proguard-rules.pro | 21 + .../android/app/src/main/AndroidManifest.xml | 56 + .../com/openchamber/app/MainActivity.java | 5 + .../main/res/drawable-land-hdpi/splash.png | Bin 0 -> 7705 bytes .../main/res/drawable-land-mdpi/splash.png | Bin 0 -> 4040 bytes .../main/res/drawable-land-xhdpi/splash.png | Bin 0 -> 9251 bytes .../main/res/drawable-land-xxhdpi/splash.png | Bin 0 -> 13984 bytes .../main/res/drawable-land-xxxhdpi/splash.png | Bin 0 -> 17683 bytes .../main/res/drawable-port-hdpi/splash.png | Bin 0 -> 7934 bytes .../main/res/drawable-port-mdpi/splash.png | Bin 0 -> 4096 bytes .../main/res/drawable-port-xhdpi/splash.png | Bin 0 -> 9875 bytes .../main/res/drawable-port-xxhdpi/splash.png | Bin 0 -> 13346 bytes .../main/res/drawable-port-xxxhdpi/splash.png | Bin 0 -> 17489 bytes .../drawable-v24/ic_launcher_foreground.xml | 34 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../src/main/res/drawable/ic_stat_notify.xml | 22 + .../app/src/main/res/drawable/splash.png | Bin 0 -> 4040 bytes .../app/src/main/res/layout/activity_main.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 7 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 7 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2398 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 240 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 2915 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3662 bytes .../src/main/res/mipmap-ldpi/ic_launcher.png | Bin 0 -> 764 bytes .../mipmap-ldpi/ic_launcher_background.png | Bin 0 -> 157 bytes .../mipmap-ldpi/ic_launcher_foreground.png | Bin 0 -> 1235 bytes .../res/mipmap-ldpi/ic_launcher_round.png | Bin 0 -> 1630 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1231 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 176 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 1816 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2303 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 3693 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 308 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 3929 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 4903 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 6619 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 444 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 6176 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7804 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 9968 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 608 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 8340 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 10605 bytes .../res/values/ic_launcher_background.xml | 4 + .../app/src/main/res/values/strings.xml | 7 + .../app/src/main/res/values/styles.xml | 22 + .../app/src/main/res/xml/file_paths.xml | 5 + packages/mobile/android/build.gradle | 36 + .../mobile/android/capacitor.settings.gradle | 21 + packages/mobile/android/gradle.properties | 22 + .../android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + packages/mobile/android/gradlew | 252 ++++ packages/mobile/android/gradlew.bat | 94 ++ packages/mobile/android/settings.gradle | 5 + packages/mobile/android/variables.gradle | 13 + packages/mobile/assets/icon-background.png | Bin 0 -> 20607 bytes packages/mobile/assets/icon-foreground.png | Bin 0 -> 68840 bytes packages/mobile/assets/icon-only.png | Bin 0 -> 75485 bytes packages/mobile/capacitor.config.ts | 33 + packages/mobile/ios/.gitignore | 13 + .../ios/App/App.xcodeproj/project.pbxproj | 738 ++++++++++++ .../xcshareddata/xcschemes/App.xcscheme | 78 ++ .../xcschemes/OpenChamberWidget.xcscheme | 123 ++ .../App.xcworkspace/contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + packages/mobile/ios/App/App/App.entitlements | 18 + packages/mobile/ios/App/App/AppDelegate.swift | 162 +++ .../AppIcon.appiconset/AppIcon-512@2x.png | Bin 0 -> 110522 bytes .../AppIcon.appiconset/Contents.json | 14 + .../ios/App/App/Assets.xcassets/Contents.json | 6 + .../Splash.imageset/Contents.json | 23 + .../Splash.imageset/splash-2732x2732-1.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732-2.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732.png | Bin 0 -> 41273 bytes .../App/Base.lproj/LaunchScreen.storyboard | 32 + .../ios/App/App/Base.lproj/Main.storyboard | 19 + packages/mobile/ios/App/App/Info.plist | 92 ++ .../mobile/ios/App/App/PrivacyInfo.xcprivacy | 30 + .../OpenChamberNotificationService/Info.plist | 29 + .../NotificationService.swift | 71 ++ ...penChamberNotificationService.entitlements | 11 + .../Assets.xcassets/Contents.json | 6 + .../OCLogoSymbol.symbolset/Contents.json | 13 + .../OCLogoSymbol.symbolset/oclogo-symbol.svg | 55 + .../ios/App/OpenChamberWidget/Info.plist | 27 + .../OpenChamberControl.swift | 38 + .../OpenChamberWidget.entitlements | 11 + .../OpenChamberWidgets.swift | 321 +++++ .../App/OpenChamberWidget/WidgetShared.swift | 137 +++ packages/mobile/ios/App/Podfile | 40 + packages/mobile/ios/App/Podfile.lock | 134 +++ packages/mobile/package.json | 47 + packages/mobile/scripts/android-device.mjs | 93 ++ packages/mobile/scripts/ios-sim-build.mjs | 69 ++ packages/mobile/scripts/ios-sim.mjs | 95 ++ .../mobile/scripts/prepare-web-assets.mjs | 16 + packages/mobile/scripts/with-mobile-env.mjs | 48 + packages/mobile/tsconfig.json | 15 + packages/ui/package.json | 6 + packages/ui/src/App.tsx | 23 +- packages/ui/src/apps/MobileApp.tsx | 1042 ++++++++++++++++- packages/ui/src/apps/MobileSurfaceShell.tsx | 15 +- packages/ui/src/apps/appBootReady.ts | 17 + packages/ui/src/apps/deepLinkNavigation.ts | 198 ++++ packages/ui/src/apps/deepLinks.ts | 169 +++ packages/ui/src/apps/mobileConnections.ts | 680 +++++++++++ packages/ui/src/apps/mobileQrScan.ts | 176 +++ packages/ui/src/apps/mobileWidgetSnapshot.ts | 122 ++ packages/ui/src/apps/renderMobileApp.tsx | 33 +- packages/ui/src/apps/runtimeEndpointReset.ts | 31 + .../ui/src/apps/useEdgeSwipeSessionSwitch.ts | 125 ++ packages/ui/src/apps/useFontsReady.ts | 51 + .../ui/src/apps/useNativePushRegistration.ts | 101 ++ packages/ui/src/components/chat/ChatInput.tsx | 5 +- .../openchamber/NotificationSettings.tsx | 23 +- .../openchamber/OpenChamberVisualSettings.tsx | 10 +- .../src/components/ui/MobileOverlayPanel.tsx | 2 +- .../ui/src/hooks/usePushVisibilityBeacon.ts | 57 +- packages/ui/src/lib/api/types.ts | 13 +- packages/ui/src/lib/i18n/messages/en.ts | 39 + packages/ui/src/lib/i18n/messages/es.ts | 39 + packages/ui/src/lib/i18n/messages/fr.ts | 39 + packages/ui/src/lib/i18n/messages/ja.ts | 39 + packages/ui/src/lib/i18n/messages/ko.ts | 39 + packages/ui/src/lib/i18n/messages/pl.ts | 39 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 39 + packages/ui/src/lib/i18n/messages/uk.ts | 39 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 39 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 39 + packages/ui/src/lib/opencode/client.ts | 24 +- packages/ui/src/lib/platform.ts | 26 + packages/ui/src/styles/mobile.css | 85 ++ packages/ui/src/sync/sync-context.tsx | 10 +- packages/web/bin/lib/commands-connect-url.js | 10 + packages/web/mobile.html | 59 + packages/web/server/index.js | 43 +- packages/web/server/lib/notifications/APNS.md | 131 +++ .../server/lib/notifications/DOCUMENTATION.md | 13 + .../server/lib/notifications/apns-runtime.js | 512 ++++++++ .../lib/notifications/apns-runtime.test.js | 196 ++++ .../server/lib/notifications/push-runtime.js | 53 +- .../lib/notifications/push-runtime.test.js | 34 + .../web/server/lib/notifications/routes.js | 63 +- .../web/server/lib/notifications/runtime.js | 95 +- .../server/lib/opencode/bootstrap-runtime.js | 6 + .../server/lib/security/request-security.js | 2 +- .../lib/security/request-security.test.js | 10 +- packages/web/src/api/push.ts | 24 +- 162 files changed, 8842 insertions(+), 98 deletions(-) create mode 100644 .agents/skills/serve-sim/SKILL.md create mode 100644 packages/mobile/HANDOFF.md create mode 100644 packages/mobile/README.md create mode 100644 packages/mobile/android/.gitignore create mode 100644 packages/mobile/android/app/.gitignore create mode 100644 packages/mobile/android/app/build.gradle create mode 100644 packages/mobile/android/app/capacitor.build.gradle create mode 100644 packages/mobile/android/app/google-services.json create mode 100644 packages/mobile/android/app/proguard-rules.pro create mode 100644 packages/mobile/android/app/src/main/AndroidManifest.xml create mode 100644 packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-hdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-mdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-xhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png create mode 100644 packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml create mode 100644 packages/mobile/android/app/src/main/res/drawable/splash.png create mode 100644 packages/mobile/android/app/src/main/res/layout/activity_main.xml create mode 100644 packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 packages/mobile/android/app/src/main/res/values/strings.xml create mode 100644 packages/mobile/android/app/src/main/res/values/styles.xml create mode 100644 packages/mobile/android/app/src/main/res/xml/file_paths.xml create mode 100644 packages/mobile/android/build.gradle create mode 100644 packages/mobile/android/capacitor.settings.gradle create mode 100644 packages/mobile/android/gradle.properties create mode 100644 packages/mobile/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 packages/mobile/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 packages/mobile/android/gradlew create mode 100644 packages/mobile/android/gradlew.bat create mode 100644 packages/mobile/android/settings.gradle create mode 100644 packages/mobile/android/variables.gradle create mode 100644 packages/mobile/assets/icon-background.png create mode 100644 packages/mobile/assets/icon-foreground.png create mode 100644 packages/mobile/assets/icon-only.png create mode 100644 packages/mobile/capacitor.config.ts create mode 100644 packages/mobile/ios/.gitignore create mode 100644 packages/mobile/ios/App/App.xcodeproj/project.pbxproj create mode 100644 packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme create mode 100644 packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme create mode 100644 packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata create mode 100644 packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 packages/mobile/ios/App/App/App.entitlements create mode 100644 packages/mobile/ios/App/App/AppDelegate.swift create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Contents.json create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png create mode 100644 packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png create mode 100644 packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard create mode 100644 packages/mobile/ios/App/App/Base.lproj/Main.storyboard create mode 100644 packages/mobile/ios/App/App/Info.plist create mode 100644 packages/mobile/ios/App/App/PrivacyInfo.xcprivacy create mode 100644 packages/mobile/ios/App/OpenChamberNotificationService/Info.plist create mode 100644 packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift create mode 100644 packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg create mode 100644 packages/mobile/ios/App/OpenChamberWidget/Info.plist create mode 100644 packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift create mode 100644 packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements create mode 100644 packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift create mode 100644 packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift create mode 100644 packages/mobile/ios/App/Podfile create mode 100644 packages/mobile/ios/App/Podfile.lock create mode 100644 packages/mobile/package.json create mode 100644 packages/mobile/scripts/android-device.mjs create mode 100644 packages/mobile/scripts/ios-sim-build.mjs create mode 100644 packages/mobile/scripts/ios-sim.mjs create mode 100644 packages/mobile/scripts/prepare-web-assets.mjs create mode 100644 packages/mobile/scripts/with-mobile-env.mjs create mode 100644 packages/mobile/tsconfig.json create mode 100644 packages/ui/src/apps/appBootReady.ts create mode 100644 packages/ui/src/apps/deepLinkNavigation.ts create mode 100644 packages/ui/src/apps/deepLinks.ts create mode 100644 packages/ui/src/apps/mobileConnections.ts create mode 100644 packages/ui/src/apps/mobileQrScan.ts create mode 100644 packages/ui/src/apps/mobileWidgetSnapshot.ts create mode 100644 packages/ui/src/apps/runtimeEndpointReset.ts create mode 100644 packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts create mode 100644 packages/ui/src/apps/useFontsReady.ts create mode 100644 packages/ui/src/apps/useNativePushRegistration.ts create mode 100644 packages/ui/src/lib/platform.ts create mode 100644 packages/web/server/lib/notifications/APNS.md create mode 100644 packages/web/server/lib/notifications/apns-runtime.js create mode 100644 packages/web/server/lib/notifications/apns-runtime.test.js diff --git a/.agents/skills/locale-ui-patterns/SKILL.md b/.agents/skills/locale-ui-patterns/SKILL.md index ff57255bca..9315356239 100644 --- a/.agents/skills/locale-ui-patterns/SKILL.md +++ b/.agents/skills/locale-ui-patterns/SKILL.md @@ -11,10 +11,16 @@ User-facing UI text must go through `@/lib/i18n`; do not hardcode English string Use this skill for any React UI change that adds or edits visible text, accessible labels, placeholders, tooltips, toasts, dialogs, settings labels, navigation labels, or empty/error states. +## Translate everything immediately (no English placeholders) + +Every key you add to a non-English dictionary MUST contain a real translation in that language — never the English source string as a stand-in. There is NO "leave it in English for now" convention in this project; if an agent told you there was, it was wrong. Copying the English value into `es.ts`/`fr.ts`/`ko.ts`/`pl.ts`/`pt-BR.ts`/`uk.ts`/`zh-CN.ts`/`zh-TW.ts` is a defect, not a deferral. The app ships every locale at once, so an untranslated key is a visible bug for those users. + +If you genuinely cannot translate a language, say so explicitly to the user instead of silently pasting English. Do not invent a fallback policy. + ## Required Flow 1. Add or reuse a key in `packages/ui/src/lib/i18n/messages/en.ts`. -2. Add the same key to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. +2. Add the same key — fully translated, not the English text — to every non-English dictionary in `packages/ui/src/lib/i18n/messages/`. 3. In components, call `const { t } = useI18n()` from `@/lib/i18n` and render `t('key')`. 4. For locale names or language picker labels, use `label(locale)` from `useI18n()`. 5. Keep locale state in `packages/ui/src/lib/i18n/*`; do not add locale fields to broad stores like `useUIStore`. diff --git a/.agents/skills/serve-sim/SKILL.md b/.agents/skills/serve-sim/SKILL.md new file mode 100644 index 0000000000..cd18d986a1 --- /dev/null +++ b/.agents/skills/serve-sim/SKILL.md @@ -0,0 +1,69 @@ +--- +name: serve-sim +description: Use when working with the OpenChamber iOS Simulator app without opening Xcode - boot/install/launch the Capacitor iOS app, start a browser stream, tap/type/gesture/rotate, inspect accessibility, or hand a simulator URL to the user. +--- + +# serve-sim + +Use `serve-sim` to stream and control a booted Apple Simulator from the terminal. It captures the simulator framebuffer, serves a browser preview, and exposes CLI controls for taps, typing, gestures, hardware buttons, rotation, memory warnings, permissions, camera injection, and accessibility inspection. + +## OpenChamber Defaults + +- Mobile package: `packages/mobile` +- iOS bundle id: `com.openchamber.app` +- Headless env wrapper: `packages/mobile/scripts/with-mobile-env.mjs` +- iOS simulator helper: `packages/mobile/scripts/ios-sim.mjs` +- Preferred scripts: + - `bun run mobile:build:ios:simulator` + - `bun run mobile:sim:run` + - `bun run mobile:sim:serve` + - `bun run mobile:sim:list` + - `bun run mobile:sim:kill` + +## Workflow + +1. Build the simulator app without opening Xcode: + ```sh + bun run mobile:build:ios:simulator + ``` + +2. Boot a simulator if needed, install, and launch the app: + ```sh + bun run mobile:sim:run + ``` + +3. Start the browser stream in detached JSON mode: + ```sh + bun run mobile:sim:serve + ``` + Surface the returned `url` to the user. It normally starts at `http://localhost:3200`. + +4. Stop helpers when finished unless the user asks to keep them running: + ```sh + bun run mobile:sim:kill + ``` + +## Direct CLI Controls + +- Tap normalized coordinates: `bunx serve-sim tap 0.5 0.5` +- Type focused text: `bunx serve-sim type "hello"` +- Hardware home: `bunx serve-sim button home` +- Rotate: `bunx serve-sim rotate portrait` +- List streams: `bunx serve-sim --list -q` +- Accessibility tree: `curl http://localhost:3100/ax` + +Coordinates are normalized `0..1`, not pixels. Prefer `tap` for simple taps; do not emulate taps using separate `gesture` begin/end commands because that can register as long press. + +## Preconditions + +- macOS host. +- Xcode installed; use `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer` if `xcode-select` points at CommandLineTools. +- Node 18+. +- At least one simulator can be booted with `xcrun simctl`. + +## Anti-Patterns + +- Do not open Xcode just to build/install/launch during agent work; use the scripts above. +- Do not parse human output from `serve-sim`; use `-q` for JSON. +- Do not leave helper streams running unintentionally. +- Do not guess coordinates after accessibility lookup fails; report the missing target instead. diff --git a/AGENTS.md b/AGENTS.md index 263dd21d21..9d21944a35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,6 +340,7 @@ Project skills live under `.agents/skills/*/SKILL.md`. Before editing, agents ** | User-facing UI text: labels, buttons, placeholders, aria labels, empty/error/loading states, toasts, dialogs, settings copy, or navigation labels | `skill({ name: "locale-ui-patterns" })` | | Settings pages, settings dialogs, configuration UI, or visual/layout changes inside Settings | `skill({ name: "settings-ui-patterns" })` | | Drag-to-reorder, sortable lists/chips/grids, or `@dnd-kit` behavior including touch/mobile and wrapping variable-width items | `skill({ name: "drag-to-reorder" })` | +| iOS Simulator preview/control for the mobile app, `serve-sim`, simulator taps/typing/gestures/rotation, or headless install/launch workflows outside Xcode | `skill({ name: "serve-sim" })` | Skill docs are the source of truth for detailed patterns. Do not duplicate their full guidance here; load the skill and follow it before making matching changes. diff --git a/bun.lock b/bun.lock index 20ce1bac85..72a8a905a5 100644 --- a/bun.lock +++ b/bun.lock @@ -112,11 +112,38 @@ "electron-builder": "^26.0.0", }, }, + "packages/mobile": { + "name": "@openchamber/mobile", + "version": "1.13.2", + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0", + }, + }, "packages/ui": { "name": "@openchamber/ui", "version": "1.13.4", "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", @@ -335,6 +362,8 @@ "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@aparajita/capacitor-secure-storage": ["@aparajita/capacitor-secure-storage@8.0.0", "", { "dependencies": { "@capacitor/android": "^8.0.2", "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.0.2", "@capacitor/ios": "^8.0.2", "@capacitor/keyboard": "^8.0.0" } }, "sha512-oYnwSjdIh23aRNgz8982+TmFvQH/2yZkEdw1iIg+H2ziFJoOVELPTc7u6Ez2HwOuDIW5AGqBX75GvrzQ+D70Qg=="], + "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.6", "", { "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA=="], "@azu/format-text": ["@azu/format-text@1.0.2", "", {}, "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg=="], @@ -551,6 +580,24 @@ "@base-ui/utils": ["@base-ui/utils@0.2.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA=="], + "@capacitor-mlkit/barcode-scanning": ["@capacitor-mlkit/barcode-scanning@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-lhOYHZINLOCT0i5YSbSMkouik3zh0BncJouumNTgXCT0/533Z4733jAX9zv+nEd++bE8QHeUl2g0NrewreumnQ=="], + + "@capacitor/android": ["@capacitor/android@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-igtDCJ7QQn0P2qHFD9p4KXaa6V1b2PRNt+MxjVwtjTm/BJvqmiazOJq6rPjwFSZnfHm6iFoZk8TfzHd44pyBGw=="], + + "@capacitor/app": ["@capacitor/app@8.1.0", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg=="], + + "@capacitor/cli": ["@capacitor/cli@8.4.1", "", { "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", "@ionic/utils-terminal": "^2.3.5", "commander": "^12.1.0", "debug": "^4.4.0", "env-paths": "^2.2.0", "fs-extra": "^11.2.0", "kleur": "^4.1.5", "native-run": "^2.0.3", "open": "^8.4.0", "plist": "^3.1.0", "prompts": "^2.4.2", "rimraf": "^6.0.1", "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", "xml2js": "^0.6.2" }, "bin": { "cap": "bin/capacitor", "capacitor": "bin/capacitor" } }, "sha512-t7F2s7fFHCq113xgrggrmK6ctV0/8E5YfLNVLfPHp4GCTDO+tly9fZvWPf2/sOI8lMm18dLT43qbXLRTz/OZgw=="], + + "@capacitor/core": ["@capacitor/core@8.4.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg=="], + + "@capacitor/ios": ["@capacitor/ios@8.4.1", "", { "peerDependencies": { "@capacitor/core": "^8.4.0" } }, "sha512-EgcAk7NYheHMTyP3CUrA65qKs4D2UEAKgw44HI6Uk3dTw6KjLQkdLOQWvbeRncHaZ2gklfOojUoc5DlSw2lhYg=="], + + "@capacitor/keyboard": ["@capacitor/keyboard@8.0.5", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-oFXygC4eKYA5l2MdpTR06L2M/4x6e2SLD5yS1T9+UBDKTkzyvhWKEhbYLUaTIBPpLKqlfGudJw1X73S1H9eUzQ=="], + + "@capacitor/push-notifications": ["@capacitor/push-notifications@8.1.1", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA=="], + + "@capacitor/status-bar": ["@capacitor/status-bar@8.0.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A=="], + "@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="], "@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="], @@ -835,6 +882,22 @@ "@internationalized/string": ["@internationalized/string@3.2.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-D4OHBjrinH+PFZPvfCXvG28n2LSykWcJ7GIioQL+ok0LON15SdfoUssoHzzOUmVZLbRoREsQXVzA6r8JKsbP6A=="], + "@ionic/cli-framework-output": ["@ionic/cli-framework-output@2.2.8", "", { "dependencies": { "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g=="], + + "@ionic/utils-array": ["@ionic/utils-array@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg=="], + + "@ionic/utils-fs": ["@ionic/utils-fs@3.1.7", "", { "dependencies": { "@types/fs-extra": "^8.0.0", "debug": "^4.0.0", "fs-extra": "^9.0.0", "tslib": "^2.0.1" } }, "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA=="], + + "@ionic/utils-object": ["@ionic/utils-object@2.1.6", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww=="], + + "@ionic/utils-process": ["@ionic/utils-process@2.1.12", "", { "dependencies": { "@ionic/utils-object": "2.1.6", "@ionic/utils-terminal": "2.3.5", "debug": "^4.0.0", "signal-exit": "^3.0.3", "tree-kill": "^1.2.2", "tslib": "^2.0.1" } }, "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg=="], + + "@ionic/utils-stream": ["@ionic/utils-stream@3.1.7", "", { "dependencies": { "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w=="], + + "@ionic/utils-subprocess": ["@ionic/utils-subprocess@3.0.1", "", { "dependencies": { "@ionic/utils-array": "2.1.6", "@ionic/utils-fs": "3.1.7", "@ionic/utils-process": "2.1.12", "@ionic/utils-stream": "3.1.7", "@ionic/utils-terminal": "2.3.5", "cross-spawn": "^7.0.3", "debug": "^4.0.0", "tslib": "^2.0.1" } }, "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A=="], + + "@ionic/utils-terminal": ["@ionic/utils-terminal@2.3.5", "", { "dependencies": { "@types/slice-ansi": "^4.0.0", "debug": "^4.0.0", "signal-exit": "^3.0.3", "slice-ansi": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "tslib": "^2.0.1", "untildify": "^4.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -937,6 +1000,8 @@ "@openchamber/electron": ["@openchamber/electron@workspace:packages/electron"], + "@openchamber/mobile": ["@openchamber/mobile@workspace:packages/mobile"], + "@openchamber/ui": ["@openchamber/ui@workspace:packages/ui"], "@openchamber/web": ["@openchamber/web@workspace:packages/web"], @@ -1329,6 +1394,8 @@ "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/slice-ansi": ["@types/slice-ansi@4.0.0", "", {}, "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ=="], + "@types/superagent": ["@types/superagent@8.1.9", "", { "dependencies": { "@types/cookiejar": "^2.1.5", "@types/methods": "^1.1.4", "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ=="], "@types/supertest": ["@types/supertest@7.2.0", "", { "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" } }, "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw=="], @@ -1519,6 +1586,8 @@ "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], @@ -1537,6 +1606,8 @@ "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1643,7 +1714,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1731,7 +1802,7 @@ "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], @@ -1809,6 +1880,8 @@ "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], + "elementtree": ["elementtree@0.1.7", "", { "dependencies": { "sax": "1.1.4" } }, "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg=="], + "elkjs": ["elkjs@0.11.0", "", {}, "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -2135,10 +2208,12 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "inspect-webkit": ["inspect-webkit@0.0.5", "", { "peerDependencies": { "typescript": "^5" }, "bin": { "inspect-webkit": "dist/cli.js" } }, "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], @@ -2297,6 +2372,8 @@ "klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -2543,6 +2620,8 @@ "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "native-run": ["native-run@2.0.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.7", "@ionic/utils-terminal": "^2.3.4", "bplist-parser": "^0.3.2", "debug": "^4.3.4", "elementtree": "^0.1.7", "ini": "^4.1.1", "plist": "^3.1.0", "split2": "^4.2.0", "through2": "^4.0.2", "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "bin": { "native-run": "bin/native-run" } }, "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], @@ -2707,6 +2786,8 @@ "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], @@ -2843,7 +2924,7 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "rimraf": ["rimraf@6.1.3", "", { "dependencies": { "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA=="], "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], @@ -2885,6 +2966,8 @@ "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "^2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serve-sim": ["serve-sim@0.1.43", "", { "dependencies": { "inspect-webkit": "^0.0.5", "ws": "^8.21.0" }, "bin": { "serve-sim": "dist/serve-sim.js" } }, "sha512-kLcWWucVZxPD2+73EAhku6iThATYXUTYlt8M4+sw1ZHZYkfFNhqCuhy8g+Z5JHCNUTf93t2qwCHPOPqvbYKRrw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], @@ -2967,6 +3050,8 @@ "spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], @@ -3065,6 +3150,8 @@ "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], @@ -3181,6 +3268,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "untildify": ["untildify@4.0.0", "", {}, "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw=="], + "unused-filename": ["unused-filename@4.0.1", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "path-exists": "^5.0.0" } }, "sha512-ZX6U1J04K1FoSUeoX1OicAhw4d0aro2qo+L8RhJkiGTNtBNkd/Fi1Wxoc9HzcVu6HfOzm0si/N15JjxFmD1z6A=="], "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], @@ -3301,9 +3390,9 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], - "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], - "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -3345,6 +3434,14 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@capacitor/cli/fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], + + "@capacitor/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "@capacitor/cli/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -3375,6 +3472,12 @@ "@heroui/theme/tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "@ionic/utils-fs/@types/fs-extra": ["@types/fs-extra@8.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ=="], + + "@ionic/utils-fs/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "@ionic/utils-terminal/slice-ansi": ["slice-ansi@4.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ=="], + "@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -3383,6 +3486,8 @@ "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "@openchamber/web/cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3437,15 +3542,13 @@ "@textlint/linter-formatter/pluralize": ["pluralize@2.0.0", "", {}, "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw=="], - "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "@vscode/vsce/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "@xenova/transformers/sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="], @@ -3475,6 +3578,8 @@ "cacache/p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + "cacache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3499,6 +3604,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -3539,6 +3646,8 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], @@ -3601,14 +3710,16 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], @@ -3623,10 +3734,12 @@ "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -3685,10 +3798,22 @@ "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@azure/identity/open/define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@capacitor/cli/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "@capacitor/cli/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "@capacitor/cli/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "@capacitor/cli/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -3697,6 +3822,8 @@ "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@npmcli/move-file/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@secretlint/config-loader/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -3705,6 +3832,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "@vscode/vsce/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "@xenova/transformers/sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -3727,6 +3856,8 @@ "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "cacache/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -3767,6 +3898,8 @@ "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], "node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -3809,6 +3942,12 @@ "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "rehype-katex/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "rimraf/glob/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "rimraf/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "source-map/whatwg-url/tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "source-map/whatwg-url/webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], @@ -3961,6 +4100,8 @@ "qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -3971,6 +4112,8 @@ "qrcode/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "node-gyp/make-fetch-happen/cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/package.json b/package.json index 5da57494b6..3d6f9a0c98 100644 --- a/package.json +++ b/package.json @@ -26,14 +26,17 @@ "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", "build:electron": "bun run --cwd packages/electron build", + "build:mobile": "bun run --cwd packages/mobile build", "type-check": "bun run --filter '*' type-check", "type-check:web": "bun run --cwd packages/web type-check", "type-check:ui": "bun run --cwd packages/ui type-check", "type-check:electron": "bun run --cwd packages/electron type-check", + "type-check:mobile": "bun run --cwd packages/mobile type-check", "lint": "bun run --filter '*' lint", "lint:web": "bun run --cwd packages/web lint", "lint:ui": "bun run --cwd packages/ui lint", "lint:electron": "bun run --cwd packages/electron lint", + "lint:mobile": "bun run --cwd packages/mobile lint", "clean": "bun run --filter '*' clean", "changelog-card": "node scripts/changelog-card/generate.mjs", "postinstall": "node ./fix-deprecation.js && patch-package", @@ -46,6 +49,21 @@ "electron:dev": "node ./packages/electron/scripts/electron-dev.mjs", "electron:dev:bundled": "OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1 node ./packages/electron/scripts/electron-dev.mjs", "electron:build": "bun run --cwd packages/electron package", + "mobile:build": "bun run --cwd packages/mobile build", + "mobile:sync": "bun run --cwd packages/mobile sync", + "mobile:add:ios": "bun run --cwd packages/mobile add:ios", + "mobile:add:android": "bun run --cwd packages/mobile add:android", + "mobile:build:android:debug": "bun run --cwd packages/mobile build:android:debug", + "mobile:build:ios:simulator": "bun run --cwd packages/mobile build:ios:simulator", + "mobile:sim:boot": "bun run --cwd packages/mobile sim:boot", + "mobile:sim:install": "bun run --cwd packages/mobile sim:install", + "mobile:sim:launch": "bun run --cwd packages/mobile sim:launch", + "mobile:sim:run": "bun run --cwd packages/mobile sim:run", + "mobile:sim:serve": "bun run --cwd packages/mobile sim:serve", + "mobile:sim:list": "bun run --cwd packages/mobile sim:list", + "mobile:sim:kill": "bun run --cwd packages/mobile sim:kill", + "mobile:open:ios": "bun run --cwd packages/mobile open:ios", + "mobile:open:android": "bun run --cwd packages/mobile open:android", "vscode:dev": "node ./scripts/dev-vscode.mjs", "vscode:build": "bun run --cwd packages/vscode build", "vscode:package": "bun run --cwd packages/vscode package", diff --git a/packages/mobile/HANDOFF.md b/packages/mobile/HANDOFF.md new file mode 100644 index 0000000000..92fa01e743 --- /dev/null +++ b/packages/mobile/HANDOFF.md @@ -0,0 +1,218 @@ +# OpenChamber Mobile Handoff + +Status and process reference for the native iOS/Android apps. Written so work can continue after +merge — either by finishing CI/release automation, or by adding features as follow-up fixes. The +apps are feature-complete for a first public/TestFlight-style test; CI/signing is the main gap. + +## What this package is + +`packages/mobile` is a Capacitor workspace that wraps the **hosted mobile web UI** (the `MobileApp` +renderer), not the desktop shell. The native app is a WKWebView (iOS) / Android WebView loading a +bundled copy of the web build; native capabilities are added via Capacitor plugins and two iOS app +extensions. + +- App id / package: `com.openchamber.app`; app name `OpenChamber`. +- Capacitor config: `capacitor.config.ts` (Keyboard `resize: 'none'`, StatusBar overlay, Push + `presentationOptions: []`). +- Renderer: the web build's `mobile.html` entry (`MobileApp`), copied into `dist/` and served by + Capacitor. Mobile-only surfaces (connection onboarding, `Instances`, QR pairing, widgets) exist + only in the Capacitor shell — hosted `mobile.html` in a plain browser does not expose them. + +## Build pipeline (how a native build is produced) + +``` +bun run --cwd packages/web build # web/dist + → scripts/prepare-web-assets.mjs # copy web/dist → mobile/dist, mobile.html → index.html + → cap sync # copy dist → native, sync plugins/config + → xcodebuild / gradle assembleDebug # native binary +``` + +`sync` (in `packages/mobile/package.json`) runs `bun run build && cap sync` inside the mobile env +wrapper. Everything native-facing goes through `scripts/with-mobile-env.mjs`. + +### `with-mobile-env.mjs` (toolchain wrapper — read this before debugging build env issues) + +Every build/deploy script runs through it. It sets, with env overrides honored first: + +- `DEVELOPER_DIR` — `$DEVELOPER_DIR` → `xcode-select -p` → `/Applications/Xcode.app/...`. It + intentionally honors `xcode-select` so an Xcode beta / non-default install is used (hardcoding + the path previously forced builds onto the wrong Xcode / Command Line Tools). +- `JAVA_HOME` — `$JAVA_HOME` → `/opt/homebrew/opt/openjdk@21`. +- `ANDROID_HOME` / `ANDROID_SDK_ROOT` — `$ANDROID_HOME` → `/opt/homebrew/share/android-commandlinetools`. +- `PATH` — prepends `$JAVA_HOME/bin` and `$ANDROID_HOME/platform-tools` (so `adb` resolves). + +On another machine, override these env vars rather than editing the script. `xcode-select` may +point at Command Line Tools; the wrapper's `DEVELOPER_DIR` handling covers that for mobile commands. + +## Commands + +Root aliases (from repo root): + +```sh +bun run mobile:build # web build + prepare-web-assets +bun run mobile:sync # build + cap sync +bun run mobile:build:android:debug # sync + gradle assembleDebug +bun run mobile:build:ios:simulator # simulator build (strips MLKit pod, see quirks) +bun run mobile:open:ios # open in Xcode +bun run mobile:open:android # open in Android Studio +bun run type-check:mobile +bun run lint:mobile +``` + +Android physical-device deploy (adb-based, in `scripts/android-device.mjs`) — **not aliased at +root**, run from the package: + +```sh +bun run --cwd packages/mobile android:devices # list adb devices (want `device`, not `unauthorized`) +bun run --cwd packages/mobile android:install # adb install -r the debug APK +bun run --cwd packages/mobile android:launch # am start MainActivity +bun run --cwd packages/mobile android:run # install + launch +bun run --cwd packages/mobile android:logcat # app logs +``` + +Typical device iteration: `bun run --cwd packages/mobile build:android:debug` then +`android:run`. APK path: `android/app/build/outputs/apk/debug/app-debug.apk`. + +iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (see +`scripts/ios-sim.mjs`; `serve-sim` for a browser preview of the simulator). + +## Native capabilities implemented + +- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token + issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on + launch. Deleting the active instance resets the runtime to the connect screen. +- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is + downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it + and retries. CAMERA permission + `NSCameraUsageDescription` declared. +- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens. +- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`) + used by notification taps, widgets, and Control Center. Cold-launch intents are stashed. +- **Push notifications** — iOS APNs + Android FCM (see below). Presence-aware routing suppresses a + device's push when an interactive (desktop/web) client is visible. +- **iOS widgets + Control Center + Notification Service Extension** — WidgetKit extension + (`OpenChamberWidget`), a Control Center control, and an NSE (`OpenChamberNotificationService`) + that refreshes widgets from push. All share the App Group `group.com.openchamber.app`. +- **Native chrome** — status bar (iOS overlay + safe-area; Android inset + themed background), + keyboard handling (iOS CSS inset; Android native `adjustResize`), edge-swipe session switch, + back-button handling, app-icon badge. +- **App icons** — iOS `AppIcon`; Android adaptive launcher icon; notification small icon + (`ic_stat_notify`). + +## Push / notifications architecture + +- Registration: on launch the app registers a device token — **iOS → APNs, Android → FCM** — and + sends it to the connected server tagged with `platform` (`ios`/`android`). +- The server forwards notification-worthy events to a signed **relay**; the relay routes each token + to APNs or FCM by its bound platform. The app itself only needs to obtain and register the token. +- **Presence-aware suppression**: each client reports foreground visibility + its platform; a + mobile push is skipped while an interactive (desktop/web/vscode) client is visible (it already + shows the in-app notification). Gated on the desktop's visibility, never the phone's own. +- Foreground behavior: iOS suppresses the banner via `presentationOptions: []`; the web/PWA service + worker suppresses when a window is focused. + +## Platform config specifics + +### iOS (`ios/App`) + +- Extensions: `OpenChamberWidget` (WidgetKit, deployment 17.0) and `OpenChamberNotificationService` + (NSE, 15.5), both hand-wired into `App.xcodeproj/project.pbxproj` and embedded via a copy phase. +- App Group `group.com.openchamber.app` in all three targets' entitlements (app + widget + NSE). +- `Info.plist`: `CFBundleURLTypes` scheme `openchamber`, `NSCameraUsageDescription`. +- Push entitlement (aps-environment) required. +- APNs `mutable-content: 1` (set server/relay side) wakes the NSE to refresh widgets. + +### Android (`android/app`) + +- `google-services.json` (committed; Firebase project `openchamber-8bf7e`). The Google Services + Gradle plugin is applied conditionally when the file exists; `@capacitor/push-notifications` + brings `firebase-messaging`. +- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS` + (Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`. + ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM + `default_notification_icon=@drawable/ic_stat_notify`. +- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under + `packages/mobile/assets/`, regenerable with `@capacitor/assets`). + +## Quirks / gotchas + +- **iOS Simulator + MLKit**: `GoogleMLKit` barcode has no arm64-simulator slice, so + `scripts/ios-sim-build.mjs` temporarily strips the `CapacitorMlkitBarcodeScanning` pod, builds, + then restores it. Device builds include it normally. +- **Android WebView version**: the UI uses `color-mix()` (Tailwind v4 + theme) which needs + Chromium **111+**. An outdated Android System WebView renders translucency/selection wrong — tell + testers to keep Android System WebView updated (or use a device with a current one). +- **Capacitor stream transport is locked to SSE** on the native apps (native WebSocket streaming is + unreliable on Android). The Chat transport setting shows SSE selected and disables the others in + the Capacitor shell. +- **Android push needs the app rebuilt with `google-services.json`**; without it `register()` used + to crash ("Default FirebaseApp is not initialized"). Registration is gated to iOS/Android natives. + +## Validation + +```sh +bun run type-check:mobile +bun run lint:mobile +bun run mobile:build:android:debug +bun run mobile:build:ios:simulator +``` + +Web-inherited build warnings (KaTeX font URLs, `onnxruntime-web` eval, chunk-size) are expected and +non-fatal. + +## The gap: CI / release automation (next work) + +The apps build and deploy locally; there is no CI/signing/publishing yet. To take them to +TestFlight / Play internal testing: + +### iOS + +- Apple Developer account; App IDs for the app **and** both extensions + (`com.openchamber.app`, `.OpenChamberWidget`, `.OpenChamberNotificationService`), each enabled for + the **App Group** and (app) **Push**. +- Signing certificate + provisioning profiles for all three targets (extensions need their own). +- App Store Connect API key for non-interactive TestFlight upload (`xcodebuild archive` + + `notarytool`/`altool`, or fastlane `gym`+`pilot`). +- Runner: macOS with the same Xcode as `DEVELOPER_DIR`. + +### Android + +- Release keystore (kept as a CI secret); build a signed **AAB** (`bundleRelease`) — the debug + scripts here produce an unsigned debug APK. +- Play Console app + internal testing track; a Play service account for automated upload (fastlane + `supply` or the Play Developer API). +- `google-services.json` is committed, so FCM builds in CI without extra setup. +- Runner: Linux with the Android SDK + `openjdk@21`. + +### Notes for CI + +- Reuse `with-mobile-env.mjs`'s env contract (`DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`) — set + them in the workflow instead of relying on local Homebrew paths. +- Relay/push secrets (APNs key, FCM service account) live in the relay infrastructure, not app CI. +- Version/build-number bumping is not automated yet. + +## Store review readiness + +Xcode build warnings do not block review; the concrete items are store requirements, not code +quality. Done in-repo vs. to-do at release time: + +**Done in-repo (this branch):** + +- iOS app **Privacy Manifest** (`ios/App/App/PrivacyInfo.xcprivacy`) — declares no tracking and the + required-reason UserDefaults API (App Group snapshot). Bundled SDKs ship their own manifests. +- iOS **`ITSAppUsesNonExemptEncryption = false`** in `Info.plist` (skips the per-build export- + compliance prompt). +- iOS camera + local-network usage strings; Android SDK levels (`target/compile 35`, `min 24`) meet + Play's current requirements. + +**To-do at release (console / infra, not code):** + +- **Privacy policy URL** — required by both stores because the app uses camera + notifications. +- iOS **App Privacy nutrition label** (App Store Connect) and Android **Data Safety** form — declare + what's collected (device push token; the app otherwise talks only to the user's own server). +- **Production APNs** for App Store / TestFlight builds: the app's `aps-environment` must be + `production` in the release build, and the relay must send to production APNs (not sandbox). +- **Demo instance + credentials** for reviewers — the app connects to a user's server, so review + needs a reachable test instance (App Store 2.1 / Play). +- **Guideline 4.2 (minimum functionality)** — WebView-wrapper apps can be scrutinized; cite the + native features (push, widgets, Control Center, QR pairing) in the review notes. +- Signing/upload as covered in the CI section above (all three iOS targets; signed Android AAB). diff --git a/packages/mobile/README.md b/packages/mobile/README.md new file mode 100644 index 0000000000..6f21cb5f70 --- /dev/null +++ b/packages/mobile/README.md @@ -0,0 +1,71 @@ +# OpenChamber Mobile + +Capacitor shell for the dedicated OpenChamber mobile web surface. + +The mobile package reuses the web build, then rewrites `mobile.html` to `index.html` in `packages/mobile/dist` so native iOS/Android always launch `MobileApp` instead of the hosted surface selector. + +## Runtime Model + +- The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server. +- On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server. +- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. +- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. + +## Commands + +Run these from `packages/mobile`, or use the root `mobile:*` aliases. + +- `bun run build`: builds `packages/web` and prepares mobile web assets. +- `bun run sync`: prepares assets and runs `cap sync`. +- `bun run add:ios`: creates the native iOS project. +- `bun run add:android`: creates the native Android project. +- `bun run build:android:debug`: builds a debug Android APK without launching an emulator. +- `bun run build:ios:simulator`: builds an iOS Simulator app without launching Xcode or Simulator. +- `bun run sim:run`: boots a simulator if needed, installs the built iOS app, and launches it. +- `bun run sim:serve`: starts `serve-sim` in detached JSON mode and prints the browser preview URL. +- `bun run sim:list`: lists running `serve-sim` streams. +- `bun run sim:kill`: stops running `serve-sim` streams. +- `bun run open:ios`: opens the iOS project. +- `bun run open:android`: opens the Android project. + +## Headless Quickstart + +```sh +bun run build +bun run sync +bun run build:ios:simulator +bun run build:android:debug +``` + +These commands build and sync the native projects without launching Xcode, Android Studio, Simulator, or an emulator. + +## Local Tooling + +The default scripts assume the local Homebrew/Xcode paths prepared for this workspace: + +- Xcode: `/Applications/Xcode.app/Contents/Developer` +- JDK 21: `/opt/homebrew/opt/openjdk@21` +- Android SDK: `/opt/homebrew/share/android-commandlinetools` + +Override `DEVELOPER_DIR`, `JAVA_HOME`, `ANDROID_HOME`, or `ANDROID_SDK_ROOT` when using a different local setup. + +Required local tools: + +- Xcode with iOS Simulator support. +- CocoaPods for iOS dependency installation. +- JDK 21 for Android Gradle builds. +- Android SDK command-line tools with platform/build-tools 35. + +## Troubleshooting + +- If `xcodebuild` reports that the active developer directory is Command Line Tools, keep using the provided scripts or set `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer`. +- If Android builds fail with `Unable to locate a Java Runtime` or `source release: 21`, install/use JDK 21 and set `JAVA_HOME` accordingly. +- If Android SDK packages are missing, install `platform-tools`, `platforms;android-35`, and `build-tools;35.0.0`, then accept SDK licenses. +- If CocoaPods cannot find Capacitor pods after reinstalling dependencies, run `bun install` from the workspace root, then rerun `bun run sync`. +- If connecting to a remote OpenChamber server fails from the app while `/health` works in curl, check that the server build includes the packaged-client CORS allowlist for `capacitor://localhost` and local dev origins. +- If `serve-sim` preview says the stream is not producing frames, check the raw MJPEG stream before assuming the simulator stopped. In prior testing the raw stream worked while the browser preview UI stayed stale. + +## Generated Assets + +The native projects currently use Capacitor-generated launcher and splash assets. Replace them before release branding work. diff --git a/packages/mobile/android/.gitignore b/packages/mobile/android/.gitignore new file mode 100644 index 0000000000..48354a3dfc --- /dev/null +++ b/packages/mobile/android/.gitignore @@ -0,0 +1,101 @@ +# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Capacitor +capacitor-cordova-android-plugins + +# Copied web assets +app/src/main/assets/public + +# Generated Config files +app/src/main/assets/capacitor.config.json +app/src/main/assets/capacitor.plugins.json +app/src/main/res/xml/config.xml diff --git a/packages/mobile/android/app/.gitignore b/packages/mobile/android/app/.gitignore new file mode 100644 index 0000000000..043df802a2 --- /dev/null +++ b/packages/mobile/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle new file mode 100644 index 0000000000..d86e8dd7ba --- /dev/null +++ b/packages/mobile/android/app/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'com.android.application' + +android { + namespace "com.openchamber.app" + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + applicationId "com.openchamber.app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0" + aaptOptions { + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +repositories { + flatDir{ + dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" + implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation project(':capacitor-android') + implementation project(':capacitor-cordova-android-plugins') +} + +apply from: 'capacitor.build.gradle' + +try { + def servicesJSON = file('google-services.json') + if (servicesJSON.text) { + apply plugin: 'com.google.gms.google-services' + } +} catch(Exception e) { + logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +} diff --git a/packages/mobile/android/app/capacitor.build.gradle b/packages/mobile/android/app/capacitor.build.gradle new file mode 100644 index 0000000000..32b2e26bc3 --- /dev/null +++ b/packages/mobile/android/app/capacitor.build.gradle @@ -0,0 +1,24 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN + +android { + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" +dependencies { + implementation project(':aparajita-capacitor-secure-storage') + implementation project(':capacitor-mlkit-barcode-scanning') + implementation project(':capacitor-app') + implementation project(':capacitor-keyboard') + implementation project(':capacitor-push-notifications') + implementation project(':capacitor-status-bar') + +} + + +if (hasProperty('postBuildExtras')) { + postBuildExtras() +} diff --git a/packages/mobile/android/app/google-services.json b/packages/mobile/android/app/google-services.json new file mode 100644 index 0000000000..3d2719119e --- /dev/null +++ b/packages/mobile/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "519320768353", + "project_id": "openchamber-8bf7e", + "storage_bucket": "openchamber-8bf7e.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:519320768353:android:e70fd113d740a86c233f20", + "android_client_info": { + "package_name": "com.openchamber.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyADEQ6yRHXBMlwbaG6y8Vb1elC2q7mx6-A" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/packages/mobile/android/app/proguard-rules.pro b/packages/mobile/android/app/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/packages/mobile/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..0229e44375 --- /dev/null +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java new file mode 100644 index 0000000000..0d8e2a721a --- /dev/null +++ b/packages/mobile/android/app/src/main/java/com/openchamber/app/MainActivity.java @@ -0,0 +1,5 @@ +package com.openchamber.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..e31573b4fc93e60d171f4046c0220e1463075d9e GIT binary patch literal 7705 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 wKmFJ0_JHhk1&0B>;%YXATM literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..f7a64923ea1a0565d25fa139c176d6bf42184e48 GIT binary patch literal 4040 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 XyOn+%7f`N3b2T^T3uj5+eShz7v)7qy literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..807725501bdd92e94e51e7b2b0006f69e0083a0b GIT binary patch literal 9251 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..14c6c8fe39fcd51a0414866ad28cbe8ff3acb060 GIT binary patch literal 13984 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®q@N z14_H8ofW)l{|J8q+a)eH)I0r)>WXdzV%7J>PA~6_J)KLT90iYa^K=Wz7D!OybzqSru=f4?|KFl;Y)gP_H6V4x`~kZ6fE(xM1&;?72-TZNk+0 zr+Crr5yl%Iy@vfmt3eYFl!jIvPGFz^8Ek+2`48O1_pCX3xNWh-zBa{rIcc%+=|XVj zANYTg&s}TKb#OztQrCW(Xk?V^i{`q~%HtcveTxq(_HKeC9GzrtguMT4Nvs@KakPTA z9>*8bBZmLz`lK5=l)=b|=dT3a5ag^a1^znZyx5QKfUb1b9yacArRp%3@QWo(hrsCU z-K!-=jDmv!zb7XT>)r|-Z0Ry}lk2;dk-ECqMwr_nKN#x*X6~B5hVIN>6$1HwBz3Of z=Pk){AL5*=d90f17_qZEJLm;Q%WMdX=*N&!ki@E&cy7?>{1ssAH(tACtp*r@d^til z)x(1#6(kPD+joSF&J3sxJU@{-sWCS+pZq{Gsx=?z4wP;>?)1yHv0?X?VP{}cX4~aH zxeBPKw_rgW8rvewS1W2#^y+c>-183iMbJCqc38RN_o~__9-n|jcd&oA`m7*&Fqqpc z;Tev*0LS-ZK47Sq1unfvP1S43uA12P?PJmI8BeTYPr~R*tYUm^0;U%Hmu?bSZHEK6 zPjsW=E67Kq-&trmf;)UkmRABH2U)V)-eRT$j(%G12lLMsThSsU10iP#{)ZnvjzN$d z*K%P3`}oqyvpWP~venr>3viH8^`)Ma*=B31hw*Q+tqE>i2y7w!(o^lI^Yss^=tHW( z;cnCT(%B1gLz+TRGW9roFjI1EQTu-u`(f#RmZ8;FSN(bsC1J;+(i_R6mrW=yYx$cy z#%QKVrEx~kVMg~yo?^N28Wnk6x%L;J8i|*|ANEiNjq(Vhzuzl3ikpA*G!Z}kLAzAI z9qnySo%D|AuJj12%h;Otqjs(>LPj?rNdeU8so>P(C>XMzlho94ZD#w=cCOOU;=3&^ zsqAG!i{~lY271D|m>ztPV`)X@FO_;`wPjppYNQpM+ncvtz1lZjN>!Q^*I}T%uP78Z7tbV2$q3W_)14=kLFyJ z1GqL6T>ClgeZorL!}xP4f%OB_EsmJ`uw7dGWNV9OLlhb|UMpVhc{4@Bhh`tO!ZqzD zhusd<=K^ah!L@gQ?6dOpI-ge^e>S5W9eII57Zu16eU?GRbgKTeVk9yS{iK|O(zLR> zheb?;jwGCHS80NCn=jKxgJ>}qu4l%5NPihjzazGv#J?Jcyl;<#IW&x4mm>nrW8>}C z3U@aeD~)*F(0o^2{GnKVm$Jr#aZE ztl~TOkM^SdzJapQ((!-i8b!RkVQBKkL`2ZCBuy!qI1L{3Er526plVols~68U-^9Px zR(3{j;Z9RHX^muc0dUywJ|`yyZFf=k&-Gb#m4u73Lm5Ks%BfHj%2|gjn#i> zLC5pO$2Em9H;qoKQmMtl<@wgtPF1%2HariD5O~u>8=^*J&au~JH%Ih@&2Uging3U_ z0bzfKucW$ZHSx}!#buB?+-J)%RQbbXM-!BJTS&#dU_@lxU6>te2O+9 z@F{F{Nb!;{Cd`Gx+$G?11aB~S#wIH%D=*=7f7H@D@%B1)&bF$@t3JDq4l*%(wJTlh zo`?uMq{YilKUewPNaC)GuOr<8j9&ofqRU__BRUX^x8Cj3a;a$rXzgXqW>LR#CUn%~m)t zYC&ol(gAkbc^fd`xWU&bk5vT6KbFmsR=O78Bn%t7 znbw&=c+|T&#r+bls5rU6D#HMvqA<|;)BV%jOMonkm^p$7Vcel-Wwn$=uAJv&(8W>% z9))Fxpl*(%E#wFm_m!U~2HqgZs^2vaGeY(UfYKrSHV}w^D0N6!se5Ewy)Yy-!(2

aKj2hWG7>znxs|SE zN4rHtiSPqLskWp(?(_YYwgq+1@8v+~8As|(bC>$D(atG3ZE8-ZM3SVcg|vHQz$I=!(A`k`5= zOqR>&%G)$)k*QLz7MTB9wleWpv&N9Sta64wy}3Ytd?x!Ja8z>(z~(3UNFu^eFmn#6 zw!!gUxOuZi$PQIs*ixfZR3iLyADJ z5&s%tPfk>V!x|A-;oq%1!yk9H$UBP0ToA*EDtz(^!_AnF1bBQ7joj|? z5b)gSI8c8O$PYFE!vXJ<4gebg*9G9P2wcB{#kv0FItc5T@PDNo)}Rh4Us}L{e}xzW zhwt`)j`M)mP=G6H0;^&q=I0{jU%bIRkF#uLF;{vVC&H|_uc literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..244ca2506dbe0fd8f6a05520ac7d1a629ea81438 GIT binary patch literal 17683 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..bfabe6871a17a5e95b78fb30d49b7d2b4d2fe4c0 GIT binary patch literal 13346 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

VwO4Hu8X}%XHUAz_J@50rzbkIsat>4oWtQt< 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/packages/mobile/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..6929071268eb03ee0f088142b6523566b78550e2 GIT binary patch literal 17489 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 KR&><$`~L^lv%S~= literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..c7bd21dbd8 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..d5fccc538c --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml new file mode 100644 index 0000000000..e984b381b2 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/drawable/ic_stat_notify.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/packages/mobile/android/app/src/main/res/drawable/splash.png b/packages/mobile/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..f7a64923ea1a0565d25fa139c176d6bf42184e48 GIT binary patch literal 4040 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 XyOn+%7f`N3b2T^T3uj5+eShz7v)7qy literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/layout/activity_main.xml b/packages/mobile/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..b5ad138701 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..24335ca31f --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..24335ca31f --- /dev/null +++ b/packages/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0d8378276806a95297e95082e4566e5b2ff66f40 GIT binary patch literal 2398 zcmV-k38D6hP)lGufLtz@Udbr|1CZ0{6o5LbUC2_k721aoYzqKu)LAz0l8}Kg-gkOC?K|ERr{GUfHr`i$q05>BW7XJb5HjrcAPA z$r5dg7>)`$fSj=eY-Iob{ZhJgX-StZolKoNRUSTkC@WX4l!_HAN~KDb^!~wv2QqEi zG;Kp$`XmKE`4k|R%O&5xe|InD?%li6xN&1in>MYqZQEAv+_@u%48$0RZ`Qu_Ao zD}@Uema=8bO3j)zm846SEGeZ*m69@L%1E(d#iUQ4KHB!gi4&?}j4^)vczON$^)K@T z1V9cC^+$V_FJCUXbLW=q*|W>0O`B8$Fsl+JO6XOiMh&T6y}Ftae~%e6Mt?6~zPthl zSX_LEHZcZc!BHz#tk5~)Gh@F4$l-AOk?SWXKS?b?cTKJa|yYXKtQ>XYqVCp8@2I)tYVFwyAbx%$QMDuU@StFlWvjeQ2}< zQ1DLt4GH(}-(N0VxZv*p=FJ;*;Z}~(IzyDxtNo= zc?O<^XEM*|4X7jo$PxQ?qobphgqt*JBF~;ZlU1u$DZjugm=O!9TD7V!7*pB3d$*fd zzkdCyTr+RpJee?Ig3O*hTP9DQtQv&J%9k&nbnDhlon z*Q{BiUW-0}Gi%l?{hTsoihd6_y?y&u`#|1DjvP_00c?O_d@jrmK=E{6zI@Rzf(V`P zz~=x(MMX)@oH4n)w<;HLp*^(OwQHA~ zsF7>euF0@r!`v?40Ape%;vzzTJPGK>;X}lJ(5gKI1;Z2isp;4Hu;T%kB=FFLDqKu2V!atZ7V`6@oC_rGQ zcsIx#(k52&8-Q};$RP&~9MCrjFVv}1M;C%d@n+E~(ge5y?}f>+7(m5z5KIFXZ@7to zuwlc73QXn7m32%$iJ=-bYNYNTw@z%;t5+|LxQUyVEnB99@0S3DhllH1CN^Oa@C4+& zdGltuefzd7TC_+PY23WwrYl#j=va6g;v2NifC-0TPG}6=hNhrp@DXI)wQE-yG-!|v zA3j|BQ6cbafI>q<)r(k`rJvMy00nb^JPGa~V7LQQ!Spa8ObHD%p#Yi)*t9cdOJBkR zJYMI{on_#_feMoO3^;%+%Tm+gZ%hud9zA-rY7Cmvv13QoCO|QH7hqvdq$x#;6p{Ar z+iTE>sV-QsK!GM+0uYL7Xb)Gw0%XNrm>OWA6{H#q7cO)spKuLen1B+Vfz0tp1b}!t zIHpIB9tsL(X@ChDfGiK7;3v$E{0y>%pXSb;tMLuILu5>-K#60-h!JW|2>}yy09lr$ z01;KAB>;uVoH=twuhXYb%hjt_H69|OCO0DnN(h*s1jzE>Co~Gqfrl7_*a_ajq|g{| zS`+jDS(c^zLs`VsqKU}S9NaeEDKX##JwRb$VG7Xt_3JeX1|ZDJ2FUsuAfjS`wgIx@ z*B~1p%e#r$09pSyKoJoU8VTdNeE|?@h~H-Od-v|ugpwo^FUG=?3n1Fjo^l8I+MYdo z{HI_R85t?rvSriclTrkkI2pW2DUuo>lY$HwFhE+jZmoG6$t`7IbBxBP07b_Z?TLyX zJ$j^Xj~t5yo03OT0c6^ctBo8vQd5;DPoC(@;hB6|v`=W;hwWg1f-K3};Uqu;z`q3$ zT4OkeYU0kFJ9S3|{_&e_A0uY+4%r89BsFWf8CdDX!W52|&i2NN#Dv zO8}UC`}V16XULF2rca;lKO2V$Z9S80(v^k70~FSA!GOT-1{!66@&-^`jUlt|)Txv1 zm_Wenc3@7-)uBTNtsQwDjY*6o3qaluFL1zs9n!~-AFJ8}4kZo%G9_7W8e}Sm@C^5C z?VvSOCR3+Qt@~l-@B$j+`Lg^9px+&T!P9NrxKT9>Z%6fzf;=Emt$n_V3XeDvfw1c5bOQG6rLL9`FpP z161CQ`ci|&v@ko4)tRy?8iMCx}t>#AYIOYBYqcXn>(DeS+=~=kJaIFJ8P@GH1@Lr#Rpr_=MlN=V&m; zfN5h6SN$mg2OK~N9VEw<Y3@eUt%oErX}4pUXO@geCx3-a#q= literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..48f80b576ec596c437820ecd235c287d50531636 GIT binary patch literal 2915 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dd12e409048fcf270cbd6072c61b6b2f3ab8aa76 GIT binary patch literal 764 zcmVs|hc_SzV1zxEcNuZGX#0(iyC%QQ9IbG}n%PzdL@BEmVcjg@!P^3ta zqF*ljpiZX)Ns{=X@1G zi9`ZUrxR|sn{DZz-aY;rNP6F>*=%BCV}t8Ouh&DT)1gwS;Njr`kw^p{j|bP+*C?0E z464;?addQq&CN|bKR-91at!-~_%L`<;+ZkwdbQHtG z!(1=qm|&)+rr`JcvADR%Km;i(kSK~D5gHj8;od`Oq6qEo?qYU!mSL#Qxn>>j{R*o{ks?I{^6CwJdKn+gIfyR+0000|k1|%Oc%$NbBI14-?iy0WWg+Z8+Vb&Z8prF5} zi(^Q|oU`W)85tOOnhpNUPOInb(cn0iz(X?nAREE18>I900<+EspwSGTu6{1-oD!M< DwFE7& literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..79528e933358f80e63f352b930b10f18040625ba GIT binary patch literal 1235 zcmV;^1T6cBP)RHEB_-!97VA5_@nE&snE)Y}Yc7Yw+3D%&Nh~ZZKp+qR>~lDr_xKwF zM!Xc*n+N8}%gV|cRVWm(`T2Pm9v)7L#bT1r=aWXGF^OPJO-(U^AV%?79Keiy7XMmfXmvWB zAdyH&ySuneg8A z^)=kw+`#_+J`Ht#eolj2US5L5Vu7-Qw61Vmpm z7!1_N$OwrmNf3lRZJ@TcHWEV`eFUMf@AY~i7z~0=rvr>Qp->0|0|O9=L}+$F}(3B zWilCs`x(Og9S|xe#J;{hG7tzPF_Ct3bi{LVa@L-@xqhtt_{nOuQp?NBluoBhB8XC{ zr0VPI?}0E8V*WuekH?b?g+gd5s-U1C?egOuNb~37s=d6tJc`Aa&*vjC`XmwwU1}gu zS63G%Mlu)-(h(&T3V*{f&sm^-tc3lBAc!$6VKGCa8L>iKUtg!qv%S5IWk-y~VvXUm z&t1Yk6#TNRhedvVe*Sx{R+~6JK8DlNQ!Gysd3kw1W}?Xd literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..9f6912e5cf087c8f7c733be76024b5d9041d1738 GIT binary patch literal 1630 zcmV-k2BGZkQ347E#O1Qca%Fj7t1Xa- zNCXug41pkPh#F)wC_7?g(EuT+fW|~$v{vXsh%qJj;)8^QQi2eqtpDc!b>?Qg%*^E~ zCpo!uXU_7Q|Mz|8d}mx-oGbu50AvB^2Jk05MKlM3BXI} zl+DdesRrd204D+L+9{ZD0Fy=_>+9<_AobdyOaM6kB_Q7dXf+yrV`D=ASmx*FrK-Oj>%58w|oRn+V5?rvBt76b(a;qKkLwsve|0`?n#J%gf8Cs;a`_!-r8&P#~%#CMII<-o4njZyy2z z0^s4{Aqw5PbxYc?E&F`@_)*4KTU*mJW1|twajkjSc~g0#fq?;0AuTNp?d|P2ckUea z@82&l2M-=Zad9!Gr>CXe(W6JBdb(X>=0H#b+-tfx<(A|oRMy}iBmK$ObWMcF8~08yEX7cb((i4!sz2TMsw5w)~$ zI6FIQ^P`!W8I+Zki6V?VZ*OlTCnw|Hy?ZidPEHQ4U%xIY>Hx81xF|cbE>LlCu|y3` z#WyAt;bi~5UXw1lDg@uLU z1B?)=!UZocFHfG+Z)g%u&b5bX(BY39IU*`Odh`f4ZrqT`S*Z?{A*2b=?=DIXV+dri zSj23sj4lF3MnPnAyVUt}NT10I_p!ZY~ZTIwb4RmjcnZQd3hU z0M4F0tEUhdeo*(bm)eJegM&DB?3jcx<>g|>-QM1G+X3Q#W{-S!<;oR2d-hBgST$SF z06o|4_9yMr4>jYftgN(UhXhj>Nk>355r1iVu1$P7rngj_=?CY~&Iuqv3$#m{*sG1k z$Hx&LA1@|j5>Gg;J*uNK?Dc>M!=NBgS63H0J3HkiI2OmW17Ufy86vhr}RBsfm z8CiIIW;8N-sM0o%(yz9Cl(sWpqtS@kj|v|?e89rO!j}66rQV^L9Ds!=^6D1*&oL(D zjbSu>2GlM9Kk}%eZZq0q^1+HXp94q)@YRmM=)k#h6J$cJe3)Q9#buZEZ>A8+Gq(AE cZ*rpi2btXFR7b#F#sB~S07*qoM6N<$f`U5p<^TWy literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..5c1290e0e8d957a825706645ec82b8115beb11d7 GIT binary patch literal 1231 zcmV;=1Tg!FP)I>?6p~!XaK3)0Z>C|4&xd(uzOVPx)4M&-^Pb;1zw?~)JQ9g0qKG1j zD588IWMPqfSHAOetyatJp97%PYU%m;S^tdtuL6+>3E<`BMXxBXlarG)H8u6>9r{=u zfaf`c|w)ogKi!@l|@piloc;1C?FRX7gjz!J)O3F(~1&d$y#Cntx!B9qA& zh*T=&Id*qm zdwV<0&COA2YAU(AyOT<#qNAfDrVbPaO%4tYc--FJ9%W``ax6GHI`Y!Gu&}^+VtRU- zY+>*k%c;M=KiSyWa4g^(Vga7X%gdv} z!a`1T}(_JTNHpyCZmpyjz0oG68i{%LZM&)h!a6qA^fzVp@9PyS@o*`R4Nr4 zV`OB6S0+I`04t%AM9qWB6Cey%q0a!&-OJ^2uBDLFFy5@CgyF#@=xBhLDmJOpdf%iSpWgGQ0riYL7fAR!&}$@VXFymX*3%B zhQ`#c%q##*Z7HD?mQK_2=H`aST4YQ4(WbtrsEEsJSbKGK zm3L$Sgn-Ac?d0UdXxv9)b^z~L5lW*TLfr#EiHV8)PA7kJJ|qzWfT@EmBq$tiS^dG* te{;0>-yH7#q)0>&MHEp)5k+EJegTH{4@({1PeK3y002ovPDHLkV1gJWLxcbT literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..e91ef4c160a2639ab6bb95adf3ba64e9b6429c02 GIT binary patch literal 176 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3oVGw3ym^DWND468w z;uumf=ju5}K?Vk%W`jSo)0BSQ5M(`Q(Q}vzXdm-x=2O@1@jc%-V>8e;22WQ%mvv4F FO#ruDKY9QF literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..9549e8877e32f9c18b297358c9c21b8c902739b3 GIT binary patch literal 1816 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 LNkvXXu0mjf_ek~{ literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..10c3ebc671d941283e291339ef72c7774fdd40b7 GIT binary patch literal 308 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkQ1F?j zi(^Q|oVOPZ85tOOnm7D3zkBk8GfzPQcd6NYwglb-QVpgI*$meh)-XmeZy1sS4L2G2 Y)9+LyNi?PR0e#Hi>FVdQ&MBb@0Go_gZ2$lO literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..39fa8bfe87abe10f2b60ea50f817ffadaf840113 GIT binary patch literal 3929 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/packages/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..72cf0b1a6abd747476ce4cfaa53c713824feac3f GIT binary patch literal 4903 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 Z{{S=NA9YgLNiqNc002ovPDHLkV1goAY#aap literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..f6bb54d6876a62073eca5d05b285904509f69052 GIT binary patch literal 6619 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..c01ebf996f4fcd19a9fb17eeb7bda3423e37d709 GIT binary patch literal 6176 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..9238f654706dd2969debb1683859aa92f383bb27 GIT binary patch literal 9968 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 D9q+7o literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000000000000000000000000000000000000..8a72324c90ab6b78988df0c755f8f5064bb5fe55 GIT binary patch literal 608 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcaloCO|{#S9GG!XV7ZFl&wk0|S$g zr;B4q#hka74H+32I2sIoProK1m%~{xqjkeiz6O6bg|iGxBpXbaC!{f4;&GV8=wZx| hIVw1O1LVLoS((&_Q>Hpk*a}QC44$rjF6*2UngA6Tt8f4S literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..9d7e54868047cf42cefb94804355403372b9158b GIT binary patch literal 8340 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..c5d5899fdf --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/values/strings.xml b/packages/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..75f28bfd42 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + OpenChamber + OpenChamber + com.openchamber.app + com.openchamber.app + diff --git a/packages/mobile/android/app/src/main/res/values/styles.xml b/packages/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..be874e54a4 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/res/xml/file_paths.xml b/packages/mobile/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000000..bd0c4d80d0 --- /dev/null +++ b/packages/mobile/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/mobile/android/build.gradle b/packages/mobile/android/build.gradle new file mode 100644 index 0000000000..9183d42f9b --- /dev/null +++ b/packages/mobile/android/build.gradle @@ -0,0 +1,36 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.2' + classpath 'com.google.gms:google-services:4.4.2' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } + configurations.all { + resolutionStrategy { + force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22' + } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/mobile/android/capacitor.settings.gradle b/packages/mobile/android/capacitor.settings.gradle new file mode 100644 index 0000000000..4516c4b12c --- /dev/null +++ b/packages/mobile/android/capacitor.settings.gradle @@ -0,0 +1,21 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../../../node_modules/.bun/@capacitor+android@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/android/capacitor') + +include ':aparajita-capacitor-secure-storage' +project(':aparajita-capacitor-secure-storage').projectDir = new File('../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage/android') + +include ':capacitor-mlkit-barcode-scanning' +project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app/android') + +include ':capacitor-keyboard' +project(':capacitor-keyboard').projectDir = new File('../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar/android') diff --git a/packages/mobile/android/gradle.properties b/packages/mobile/android/gradle.properties new file mode 100644 index 0000000000..2e87c52f83 --- /dev/null +++ b/packages/mobile/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar b/packages/mobile/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..c1d5e01859 --- /dev/null +++ b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/mobile/android/gradlew b/packages/mobile/android/gradlew new file mode 100755 index 0000000000..f5feea6d6b --- /dev/null +++ b/packages/mobile/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/packages/mobile/android/gradlew.bat b/packages/mobile/android/gradlew.bat new file mode 100644 index 0000000000..9b42019c79 --- /dev/null +++ b/packages/mobile/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/packages/mobile/android/settings.gradle b/packages/mobile/android/settings.gradle new file mode 100644 index 0000000000..3b4431d772 --- /dev/null +++ b/packages/mobile/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/packages/mobile/android/variables.gradle b/packages/mobile/android/variables.gradle new file mode 100644 index 0000000000..fefc245150 --- /dev/null +++ b/packages/mobile/android/variables.gradle @@ -0,0 +1,13 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 35 + targetSdkVersion = 35 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.2.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.4' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' + cordovaAndroidVersion = '13.0.0' +} diff --git a/packages/mobile/assets/icon-background.png b/packages/mobile/assets/icon-background.png new file mode 100644 index 0000000000000000000000000000000000000000..00751d9f0b5d4e7286d433b1fc1a6bb570bcbe98 GIT binary patch literal 20607 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/assets/icon-only.png b/packages/mobile/assets/icon-only.png new file mode 100644 index 0000000000000000000000000000000000000000..2171b7dc5ce4006affb592242ea2fc01516a5f15 GIT binary patch literal 75485 zcmeFZ`9GBX`#wG<3Q3YBNvK343Zayx5C)NbCz5^5Qg$Vhie$)6*0E&CzE?^}b}`n3 z?6U8C&uetwuh;wYAABE=*AI8eJu}yJJ)h6>Jdg7@j&lN)Z^;~@JWh#1p$^H(N~xew zr0^ptihMu(<;}wV82%!;uOf2;_3rzL3HZllQ!TlBii#)<{7jA_39ul(fdu|_9R5S0 z&6Ow%QIb6O0J>qQh72ftL=8vRcFe5@OR8+)okIrsRZ#I>zk4&5>gt8|N6tXfZ=O+*LxF3ayB0n`oI4eA|*%J zpZb4)OG83}Mjg5QRpH40^Fi?In0^2Ke&pk%Bz#a3q}2SXQvY>Hy?t5P9wt5xHp&MHqVJJzN~?5nb&wX^gnw zdfms{a(t}XZF$mV6sM`v7|z4vzB0YL#qWJ{58e4*qP;vGEUtUJDFFX>0c*TT!+G^B z^~+rfm#2Qz-F5uNSJxeOPBqJ*(v7b&j*)#Y8B1uPM^NNb5BPNUz#KJKu4N}CQ~P~t zXO5xxShTliI-1Nacp$Sm;h%T7QP7}dbAHuZ6M@dD^SRDb8dL3Q!RqPCAL6`s)~V#j zD>tY-!^K|v|J~FH_8JDCrOPkBp^+zM`-raI*~n~)xzfI$`h@va*V%6>fm`c~z2mJ( z*^&X&E??P`sT)-E#Hfht<9p^HT#x^9|K9aOaA-H3!4!GMh<p~Q*>~crKTw(n?!MtO>C5-v*r+%7sdNt1gz`l5Oa(J0qzpD zWL~@5uF>M&Rr>dv1kb&GlVkt$qmb9u1FP1zPfQ!Xy#4qz{;dKl@!?Sb*GW#C@qdb} z4UG-S%;H>sxoboC7`NuTn0~L#nUMoeS1cN)iK!7y!=}etbKUqU@xEH=w5q)-3oY8e z%v2f{TB}{1dWtLuPTZS6mIKsfq;*aodR0R51 zRM1#*Y3pB9TvLrALG9N093dW4DXpQ*oT~hH`sD;|vslxdstrqmocXP&=&|(DwtwCc zkJ3eL^|T8Rm)eiaYh!{lTEsa+-+Oy`rQdZ>`o<}xxN9!IUI-J^HT6vcPP&<&8T)o- zue8W=z;ffAE*(2aeD@jBa30-z{bkPYmBhC%(JuDi53OTyjZuud)-&0aUH&^_?JDv* z*qSrS@L&|Rqe*+8vzAsT(xoP)byI1uN#(nBu4HHdG#aW?`S8K~Z3~g4XdxVT? zP`!C3GJYJ?*Ot*_l-)eVea16{!uOKYS4L{=hUovUJxwv}SxNnaeS5cjfZ@TfQp|qvxu3s{}1OzFKyq z-|5?`^H%w);KCoq@y~tc-}0eHX`9t^5+4<|tkk(^;OP;tddEKF%# z94#HhP^fEH*%>8lXWNsX_h5c^=nv!GxgJK^(*q0_q=_Fm6pw}r6c1Fo_rmL!o4v=H zDQJ~H`@n7WXK3ccC7fK-+IwG{uFpj`H&(-Y0Z6cgpnMN83u(Q-*Q5|Qd82&&f?9e= zZ$`ol<;Hzk#V-Dc%9kYnJo4~SBQgmOgNozCX!wX8^xmyhd~@-GZOPC5n?D0sAB8Gg zR2JFv5+F+`aIAKyMdf<$?g*KEKcQOxF@$g@faRW~KUE&2TJ3jwr8ka?nC#tpO*EW- zyCtXR#NZFDkvuFqVk=a=HXIbc_jn@3B{-i!S+fy%gIp zXACPH36;OWEb?pbqu3_U)+DkbS6GNe!|%+Wjj0^n&J6Wzxz}eI2u-_zoN?yz;pg)h z4IbQy7v2?NprjfXbjuz%s?yHan%BzLmiPAW(XFW>BS%g5`se%;l2bX=MYH|oy^&^d z78k0w9F=J?k!d!RnVLC8`wtzjz2;J>G1uxY$z7gil5CPIJPg>l)07q-r?ql^o zAN4!ly9lqEMu4MjSMD-HiM_$G@UCQ-l3CoV0`FvSFA96y08*yQT;*yMmc_qgVXVnY zc;G>jzH_H~_-UD!A^zXB{zh^-oOZv3g`dRJ&rWe|edKjlwyyBz+OJ^A<3-GwE9;q3 zaoAB)Q`Ip~oCp=4xCCA$#d=|+-sJ-s<6RDl_$SDs`$*)%x{?;QrS2^njWZ-#sckBw z9poeZ$cpR+ZQ*vEXNnWfsBOG6YmO7L?!MR{s8eRey0+mJw;njUNrLYTx=3fq}rTwFz}fF zoEmM9t66LfduaF}Ab{1YTV^$K^1acAM=p&##agh+3fD=F@8yKsv=BHcNlkjhHJtv; zXjs6jU)H@jSIru8GX;z56WhK2{R$_?mv`Sb8xGSLuUD!niFT$cM&E;zBu^o~-*<1# z_({K#gix7o5ZjkOyb0SwjQUQm&S@K%3-8LDaPU)%x>T=`rF94IxR|pqBhOsnc=VTJ zD(zPi?qB~c^;C{kGXGu{ao_N#Ybbp>t}r|&B}Ntr8Z#>FGOfidXc>mDHI!B4m@d%y znHp`&k1LWNJ^Op=UHNxW^~0?XTN18!w2)0h4d z80@7AYA!gr`)KFU#8c5iYT!1(k*QajUufQXsw8@s-?X8-T}j+V>Ds)MJw%fdU2aRa z;DIKNf3J!x_g>i0})_5(_8Es*gx>#*9$lqQMY;~ zG_!R~3$1=oTTZ((XN@J4n%_#q^|zNMjrfQ}INkbND`go;icraoukN1QJ7ZpJKMzD- zyz}S~t61OC?~o{LYoSeV*O%u^CYak1FBMZ5HUXpDAN$6KGor$?xOCqUavE2j>dcIo zX#R^2ZZN=cSZFnwCf+KPbYsoeVzr!w>X{ma6YXiy*f#0JbBkWTzsEk-GcbHBMG<}3 z;cez|nkOcoPY_Q~n<`YriHCZT^hiEkLEBZWr*i*tng7gqpjctk@8~Ui-ylXq4?cca zB6j-mmKgeiSJwfQoQ<`@u%zD)-UEaZPXTU`&!_X^YcZUY@Y{_2q>Cj)n?v+8Ewc z<}zc6UznaJ`OR(ZIz zD+-s|9I8T#us`I}AfBOJsB>Ga`)(5JtUby%EzYf>GkkYEDVX|CL&s+R%K9L{JB>VJ z=?jnBF%-BF)zDg&t1i>dRAiKN_Hwh|#dkg0+ih4CBUjy5XIbugp=`|FTzi<`iC3*V zz~V{F^K3Nl;ds5lKVT>3vV5|xENv4_L(f7{NQd>aBJJDfEc$Zqe9}8ts^G1)UtGd; ztzeR^t0vI)?((H7y;4V}X}(3fXhEy+`v>{(zsMN%jEehRqGKH!&P4_c9o<6&vOaJP z(u9Rq#A6b9RJrH!%;6tRP!@KgN)O8~l&fQIxlcJS&ShHn{V?>l5it8+kRl&(&wOQh zvhzJPi%9nonG>%PSIt%wF{51Kru~PrvwpD$oEUz@)vy@y&Xh&K-Q%G+@&G>Kut7W1 z6HG`58yzO;d|C}5Z(ZX<0>!mRv?^-D+orE1uiv4LB{gkVj1VloW4_w&8s8KtP{+~J z`0ZFhS|z;DWqmY4_j}A0fyLWQUO5^hOXIDdPCguYMdj_EBg}wN`)XS{%3#%=`r_OV z^j?YgRRc&7LMh|RN32vOm4ll4&bUh0;SUyS(8tUy=4fO`73YcXZY|n=u7yJL-ihkT zK}Hv)0;`VnS1Iw#%>j6)goS;6T3$KUJ^4b;lWMBG58DuqaXUK-^xrwPB6_c@0(pZGu} z?|FLUv_cA-q!mO}9{0lsTk~}RLQY?TkkcJ<6AoP4xDh`+5=Liy=c+cp_tRM9>;kr> zYim(#`~|JmG`0p$7ZaSyL;K#Wx#;eJTTZ8Y-|1g%r*&mj}th&DSHoQ`x ziZ3RfXwvi0{0=>?Yk>sg3wih9D1C8+`)C6$@lZd`MKw?u=eF2s;65W*Y-tSfUgiJC zdxOWIqQpJQvrF4-JKkGq%uwx=O3Ep9e*OZDQb*H>t8cM5OyZ%~`MuA_z~{H@tII_` zKZLYuYeAlw|L#-#7k6{Tls>>ScRe?j_;3%Po^t+N>9s2c`0+_*^}zId{x5xEOdg<7o>>8O*|V`cbr?x53s-)}n6#%V z3gGM)TbHEVC)!ek9xmMNEwqXVX1`m~T=8&Gr`Wb1Y5oT^6w<@)M)2xSk46}N|8vcw za{i6U%RGvsXSHB`JrDVM?WKAx5Ri_u>ZKw%dyVD+GY+$vWmT!GS@lV#|I7<0omgB zPb~;R+7Okcnv3U=XrUv)rKrk@blw{*y>G_#$>rCz@r1{HRnDfXnifK=ils-!n&a&j z$C?Cqww3BqM*@MnDPZ8yd9zZsX^y#qwy%BnSU%=MmfS36a?h8BVxXY9zrjSh(1e@`6U5y)Vt4m`SpWH5G$EdBDX zcco*;VF|TmKe?Ew;|qU&3eXLpW~q3w9qT&4mn}lhXCF#$UiPZ9eGRaTD7P4|WIW&f zF$HE3r*yoOsd=_-e;LABaO;;jnM;~g5RY0M?9c--loOK6{LbW>)KP0%>2KmCb>GDa znBAIcO$sPqUoE8agu^Ff@`Wnmr+D(Oxb(aX6^Xi+EZJIl#(|T@)$y4#jFjj;B#npS z*adAel?R?(f4pDY7ftqWbM%Kx0#$QbO#d`IP9ixMeo)`F-*n%8s@!bdVt@YW>bjov z+oy{=3^=+xE^)jq-`&}?f#SEa*et$z>_)>%;AU}Sg`%A0oy=ib;na161$h&-*N8WXCk}ums_H+=@#+T*o<{cOhEeR z4#1NYK|i()9-V^pUWTDft#jf&SZ)8Vg4i3s-I}5VCI9KkWZy#FJY(FBfFsBOlP%ou ztNA+uy43g;Q`*&TH2qL*b)M=fW!`li^c21%mh($=rl4JqZD{1R(h5Iyw#)5vjYm%3 ze1p~X?^3h4PQUM^=t_5s|Mv&Pcnn2ILW-|W2qK4la9_366xbe%wiDvfKa9sq>7?C? zRWQ+cXDMAZH zlx8lCcITPc0G>zYF~3o8r0J<2eU5Na0il^nRvj<09L;h z{1#76P4$M)`w)RM4G+|SR*`{k2-}v+As=26!WXMyG^FI_0q(+=FeTkNsE zP;iB_&EZsMre^U;QMWg=`!~!98cA%O*~J6LM~aU3AoOH!(Q($g=VD07=0cWukD8^S zAmKQ#dgy*9|6#s6k=TfH<<&WUrU*B~qm^4TvmC*}vrHC7rBX_Qa7a)_7x;}hy3`6$ zxDG15ys;+XLF-r%=Y?>s_QvZ;sz@2QflyOUxse#aX);5=cjQ4ApqhVvUx1#lO~yK$ z`QBo?<{HjF41;JSd@>o7%Ux_#SKL{7mfE6TpHb7!`bx);- z;QVf~QQLDSfxd^?IFdv`4y_JM%AFqCl~t7Wz%5y{Q|zFKfuk41^bvhjT(U)oANiW_op@Ht4t1*XgNoNPKulvrkd_ z8J&ar#V`|gC{|)?<41{5;2?7RNKUaL2@dcE!et&>C?53cCE^W^Ufb$z7tkf{rHXM1 z*m*(A*}pKR7%gNvX7a$!2>tmP{rz5ahI;0h%}BH8YRu+I% z*uwIc;~xwM8r;NpUOmcrtAN?AqE#(Jpa79%%JKv1PTW`cTQnt-p=L^L{1JFaN<%Iu zVLG8^qF49@=!rUHgP!3F-e8~8b1By}c2}>DF-HrSy%YsP=mXAd5zXLxOPxIdfPYt! zjV`xVZbAf8rc@f%IpeOG`RG|_?k66-0=zn};nMog88(8=Az{7d{}P36J)*4WAez0k z(0Ji-^wPuMv1YG1m5P8*bZ08J&0x4-U=2GVKz7@leEM7nw!$uR^jg- zujf~Rl0Z=6bdOoQ;!Rg=Lfvf{mQjsPJt(Q#8>Jq{h-sn=YQtAIRWZbd^p)Qi5@%WE z$DE15#Y58zrBoBdJtF!%;Q0MA{@{mTPo)(_% zyu}8RE~(iP2t$=_%lS%T>mz%3@XVXIl1!;LfCsmM%_tqJhm${LLcSE4ie6tBy)T>x z$U%^d%;OSnW4gb*7{6p@J_F2WF#_8mUjN~R+f)Fx>zvd$3uGrdMFsS;v%@=(*C;Hy zZL@SvW%J*2TbRqNTpx>e>9r{)W4b(Vdb4`|;P}mz$;_N0N_tG~?$~*^01b5d^VFge zzb?zpjL}Daa7Ty!LUvhhIBpismm{f&2?$gU*TpQIp+p`{%`PVn`SRJaDTS08j{-V(^z{CYKvZ3KnyeA7k}gY9$i=Dh3T>{diSQ zJK-z9s=H)TL(V)J!@BR5H&L?@eQ`lG^?5`+=CaH0Sl6;ib`1M*9fWu2ZL|MK!Rhyx z0U+fB=u{&4#pejL|0Jf#nml%$OFQeo<0X%marbuuIj-5P%Tei3063&=U3LTdiN0-Yv>IS#=4~8R8PKXV~5#1LITtJ{i zDk%`&(#4#bqZ2o*Fs&(`JB0RWKqRKyH}o2&8=rfrDoN;?g(c@e9Uj=97Q+O3s7g|z zAWpZV`Bc1DVk95J@3Jw#q0Qr(KaTvo-xmb=KHTT%tFE?GIk&PSxFRDbgQoC_Sm$+f zR?>08Ka5tP3FjKL%Ld0Gil|CeCaIbId-wVwgom6t}VQfc*AM4WP8otBBh-d{Hd z_AmF!{jm~`UF^M^l5G8?YU~oaXauYSCHRM56nCN$I8d9hZ;`1g$?S!nsIPt_)69V` zZ&CeUYG7K)2UWAy6u+JhzGe)m|2-(po5#?e zr$W?xf!EXp5+KXr*O$~G=tHr`v9tKl_f%H zE6{^e-nt?G3J3r*Oywx-2JXlWs+cwYq`{>lEibIAy|%M}I~h2e5}NF}xguy&Ns+Cd ztg`Q+=&^TUuR6y)lG zjQCTH#+LO|JiM>D!VBuMftFf^8aM51>F<-O6zw580~HVTX%c{?9plbr3S}g!d;geU z5720pIpN*0t3Xb$4B_yrrnnZBb3C|?g;pnlJnJ6*2rG&D9k@oWD+$Mej)AQ*=c%Yi zE|5$jGOQD@4vw-bh>PNy?LCOW*-sn(!odcDw;vp^&57E1(IF3^nsek?*E(A^Wc zj(jXc>a1;?`qirij@p=4&amL+L9V#(3$0Z<8&(lL;3>O>xGiF3&wE**Zx{Y_Is2`8d9sm7rsX;rq) zdc`r>dA#LAm=*FmayFxzC?8%qNoB1oFVXFL# zd_YEH<8MU^HEs9Zmg{KIfe^ImNWZNCxaTh%^*w(S*2Xl(e3OH&~-}bGNpf8QNg6jr8EziKD)<2QPtK&tvtY0mZ5)E*?O|G@Rk?x3>5m z5uqV(+lh;No)FkTtl$3$G+$Zraf)zD{VB9^{UjvQBdH^P4Ro_f6UK(_zGh8Pri4YL zcNRxCP_$Hex~t9}CL{B_q`sz{FN)is%05>*3}W|m19#3PL+lbaB@*1Pp!5AXeu>Ynvg*n(94!Lq#uwOu56an}(7JhR`ZXCV-d3R<{Ny z%lCDfp{tteTgq>1Xo}J58EW;T%JUNu6{n?@5b{Y5b>#qLY&6=?WBv)yLC!8fyB!eL zHO=el#(6YqGFXkuDTjU#9`q2nEWZD_gc>PEEq%!A)*4~(9R{;;#Q{VbGHvM;z)rT) zGOV6XYPXoi0)Y{r2YFxW@U`}vY?Nr7x9>Q+GE7-?SgiV|Go5f)t@R9(O6aT*V6MBc z3u39v&#*f$$w_%acyxt|ITAo|2p9MEx^VwCg*r|}{Ur?<*$srX%?E{KAUplfzq$Di zf&msw_$*0jF8kC#KR6zqY}=jta}V;V;n0jaD|1QPfOCuP*3(AJ=+;+3UZ4s034QD` z(f0YckhOWh3CE`qAFQtWU>MSCEzPs<)`!|QssmpA2k}t@Z8qd1RRMc_gZ9Bxc5xqn zH;@Sdm#l>~c=|h!?l$t4f9vfo{N~+P;!p(rj-5$@2_X05z9uiPd_meh!pWh`eqgF? zrG1KMpLBtRVzx*y?1G`#->%ar9(H*}?h_+y#Tj3ur-YW#W@BlBf#B#Yn#~OI^k%cB z&>T`$)ftuFiXTVMldENEwc=9r@{FGV=^*8u*N%*o)MsMag$ zOpJr9JP#Tpvxcz||gAx^@R)1whtYgF3q(lQ05&tJ?OG zXSUWl5CVE9gIot5a<*uv&gCAY<1&J~EY3ct$7U zE#UZm5$NU8KGZ>m^B1=x!*$S%diK7(zh-B&nuS)XM#!Sb7hrb_2r(;Vd&8o0v zP}Hx^Tv`9OMew{EgK`a4*eFns_8rw9q!u*2;roPC?T25Ss?#I@MEAF^A?Bg=l;kaaIc%C@ z^qAL6%TZZ##SiaWlVo~`aT~;X;yq`%8BJBQ7SFLi2S=yhh zv@jZFbo)z|t2z*a3fFJ3D$>g%XapOvF)l9Pi73KC_r4v|6j@AoOx^HJW}es^$RiM! z;xsEj_Q)d-0QU5U{-WpBxI1Q2QK6~bs!oX|K^ZCvD<1#!T_LNp_H-05QFg@v2e6aR+5U?~!=_rB<4!G6^83&E9O# zG<~-HLS0?_iQ|1pSneNNR$RD zo$#pP_L*Ewp_Zrd0)%Z$;sq?<1m$}YHo zrfxe}5NZn7SC98WJBnHre#WSupSsjy#y}E zqpq`kyldI{0icSde($N3h2qfz@fFH*%NxeOt#Zh+fmP!jK@RR!;lpiK zMN^K3W6i-uc}p7Jj8yy2I&fu;9Fw93&1C{`Z!;4?|)+$npw z(5ZHGvrE^`)UF_BAB}mnn~H9=muLB8My5jj&JA0HPi)+ulPy`$LEF;&Rm^ik3pkm| zL({<03uKd%^La~w`&iG*2N66Mlc2gjeF^(Je!}YBGap7@;F3yozN@C)Dr^bQoWkQX zXsJ$IZc%HihlcrOoLALSoBUpUISF(jdf&rpCyNZ1Ja9-ti~8>N%&Fy@gD-cj7iG2^!Umi$m;w?-k9&`iwU0nZ~ zvpnBEk%LC`&-6#HCq-`x%v(R|6j@sk`E@2-=nObM4>CM)@fvB4A4Ck2Q-s3I;`|^3 z>f^Ded?eZs>flAxOIVW{FhWekp@EIyEs^8C62-5OdG~d2YLGgvEb5Yliapai&j+o) zy~)B9rgNBcPF!)^T0Lm7l?JS2*_`IgzO95o&~ zQ?f7a-abftMa$DY?UanXG4}+5Y(yxd&RrQBYl=2;-3-Y&@r z8Q7+b5$mVxrzblzf76mRyh7TMyH8--vy?{!Xr7k%gs)AFX+uA<1X561YT0F$YY%eq zmtF@KS+t);)HfSQ*njgXkVmdN;pbG(4r$+QYAM{+6?Q|sM%zG{X)h*vV8baovUPtT z=>`bT#{A~zzaOhPy3{^BGP@dvZT0WSH*21)YNoGkVIUg+LYUwXM7I8_gg}#!+rHNv zXY<3rJ^NgbAy^(FkP?si46){Kig-CvrM*+xX)+}eZ#?O1QUfZnB3moaIji0)7HtVM z_a*g139#;=cuSl*)Z_{d4Rs2gY&BImLQ7QWzLzJiv_bwc!ycDKI9vWxP}ELaR)FJS?L@em&&rB04vJfICJl6 zTWrB#yuZXXG$zjlGP!%E!jWbz)zLzolqIlX5kOgy&4|Pt7-jvXY=9yQ^_;Q$v7Trt z2*~rWBYm`n%Jbub4ZWBm?-SS^D9j{~HOR+}_|!-}D9v|Az=l1%bI@Tod6L^|H!WxT z^`OV%i*gF6Dl+E_^3}weuWkr&t7n9j%Vj7hqE9fIcT`lQ`kltdenEQ@TzloynfEs(K44~+ZQ7D4Ha*|_cOZs| z@|mLEN9PYlNRyu1dVb}T*_`~>+(aK2SjxRXN}khHT4|G9$x3?sEkUU}!uxq7JDQ1sywX3Pb_?$pnv+UsFY9ShJaEWYi;P-q&8U3*ZIE7QHKdU*8$t$* z0X|+uh)ud6B@j>_G+YI}la5gK+?n5CdoZ_+o^oi9rrO*yi+h+VEG}{r<=cS}{)D=K zR`wxj;v@i4`+)5_+6SG5U}txsCgF$bnSQl#v^6+_3|_Lv`g1GeRXALKIlwrJ-_<~g zoA0twhp+wd*}PH=j%kUTEU|YG3D?GRUOh!RvzV@?aw0WaL6`J~B|)PVG!@|$;KKO# zbO6~$5Uyv~+R4CgkWLqCt5>MG1MZxINEO6Rrj52?UM1i479${3JQk7vzk+{Hm{l^N zS%M53#&riNhfs7$MIPG+WJi~SgR=atFP2h9o`oda|90m=9QC(|3-J-Y_}Xl2f5wQ7 z`EQxHYY+8o_i^Z`QQSlbDmGZZ&L!=kqK2x!^bo>u302Imge4Z`%^^$!R7 zueBg0U)~89{}$GDfIq%Yq$|A=2(Y;KP!W0o3MC0GhsDnUAfNLxREKV_R|`j{$&J48 zjnx65Z|R_BTC7VHahpm0bw)xpgyy*iU}C@O)PGk)1fGAMYrIhHH=MR<5MyPtKkyxIf03m@%C%S-oT^fSP$$}}>c;%Z zsq}RD%wirT^cXd+y{-Ow3RYU1w7w3pfzle{yYehg&UNRV4!cwCFp0N#uR9avfI{tk z7r?zHYiN}bufhcr&2!c6v!!+524dB#G{0cvv{5XPXZ*SENikNn?IVqHf?6gC++pCw z{U;J{W9qE{5ECg99L-W|0XErgu&M|~freKDd?litmKz=h=bE@^iVVYZ_2m%ju_>_-qc>P0cK7yNCR{(1Sk8L19n{tjA_lGdeHYxpXoy3UC^ z&@L^0$oB!M$Kdl?0SyD@c%K#TLFXU(wmrp;WGyQ467_vl(Q%1=J<1BJN!~xiGx~}9%cd(BoeKUARO)GWy|UwQjF1 zp}@ifad8ye2xIV>cjJSvEyzwjp5gllR7M%)RXFa2;18XdSKk3;D-m!B`8GMf7wHO( zR+UGoyWZ5N9|FK zM&f|JR~~y8cv9uXy%dm9o<_)hC&KPEDWG{eBwEw&^jF`E; z{+W43yT-{vz$v?|?ezc%aip6195&P+z0MAoFQsB$qPoxGTK9tb=U@p=IZ(A0OJs3c%RgR@i zmkPzyGpk$$`%#~y|IYTNJ9qgpgqvr>0h5%x(0a$3{Dg#r(&lpG8gy@$Yq(YC8X>1t z>00K{O2O_|opc*#U#+zT-4>B~eJMv8Bbs@Z?b?z9i|M(2+obPx>%2;tQOK!W(YZTz zqCpD8hM5H?J;!>ERJMZP)cjCF)SsnxrA=c00aD*=;$sam=s(yLU@7jL6u!yg;0u1~ ze$j_W-}?CV<%#6idiX?0)(@D=c^eA*b^&>>3hfu6R+8vd4NJ%eXmIx{_1vF_OnH-^ zxYM4Z(a?c#-G1vQ-{uPD*N4pACc&a(*d;H(O#@#-T3zOjx96?|p8s&D}8!GPurZ?E747D8WN`{ji;P-Bjy1kE7BNzfj7 zQu&JQqgOsx4VR8wDc#V?qvc}xBO61zg(4!Vk!RwsjW z5NtO5yP)46)erzqo{LDCp5lOus{};X`=9v(sT2%^A*E zw=8ufp?&b2Zw2etv5*jtABJ+DB3FD=Bglh11^n;wn)&-QyE4=p zk?{(dlMj<4OTaa96ykiG|M7St^`O`AYQ9*k&cApS=&wv zw4ZMtp%M!L%a;bx%|ybgB|1Sv-3UuxUttwH=<9@7{;6eNT_L@Y5F}<=XISaxY!4hh z+l-mI92=`*C(TG&&~HfC>qP3-9xU-?HWovd^8GhBkn1 zbf#Iqa>x339T@*TfxvLtXB2cWY=F3r0jAG045>^Qg1~fcs6{7|-?Xm?wGQa3CIFV&CDXp1s8WNW z$ZD%eEJWxk1mbhX*xw*>yU8z*jk*%1aOMnRg>EwBsP9>v(9URF9z*Q@-s* z9b(}Ch^OSt`z68{?Ix1VX%3D_w`VWTF9#&0wYiC~G-EloE0iq+)m4pf(jIkMndiX* z!mC+zsbKKveAA_Gnm=+32N8N7rYBbTN%UpREw*a|kIulyEb#k3cge7!*I9nh@BHIo z`OB~4-49vO6NrY+^77Pu@|%cvD%haV3z|i`Zza3O)3KXe=807GkgXrhL9Iq4nr#T| zy?jk~UaJLZl0^c5Zl?(QE;>;SeYU<_)AR4wxp~}{#_#AnydI<^@sPpk_3Foi7iw2; z8pPWB#$36t(_3US1Fk9I{*Ddv45y@M3mg@7_QdZ;p(il1MApLyb1Dag~P@1YdC~yfF&9iquw0fD6`MG&mm^M;nTRKd6;In-uQCR zZHkArzul|`(bgQU3mf9jp;O<(EJ>^9AjhepRQh8&fr<1?I+53d^oO9$c@H68d+*zu zI{>VnZ@;MEKZgb_$yNNm2eDK3D?vKt{HTx&9&>5L(iQ4hF;426j#xA&UzrV1Ft!{O z;3#7fb#t;`c3Ca4x;a0nxgmt zZtUOSv|>v42~{bw`tcpOsE>zCW9pQZUgNa&gv08oOC~Jh!mXJ_ zsVVG^UZa_$l}>&-{XsX!pP3V55;<7iBV= z)kN$8Jo#XF+FiqDVO znC^R3TpdPlJjr0z>>Ng%c8}XyE~w!KZhvGktX3|p-OUmCF>P{aa%op~ z2tQOuOF%7!X^9sbfB`m)#SQeoUEbbtZW4=z;S)wi2)+c9XcNw63s5EXAIqv31(MiC z3{y9#X6+1cbBE8XJ=5X{lyo;rq&I*8LI+`-sBX-gU<9Ax z#Y*-CjT7a-{xaumXyZW+WS1JIQLeE;=7UTd!i@IW!r;ICsBGcFV}^L2$xq;Q=~)_y z0QRP#G70qqvCrBV!S@FQ>$}esUO&rIHT3A9HB3wngfh5aHFtV0aTZx3+K^%y8Hq*= zoZ>OwN`&H;oIX;mS zhYl!Fv1s0xPS2{d-kNVJZzo>2azd5Mdlw8Hh@P7oUCHXb#TRx*=3WeN!7d+~z4ekg)k+wVsGukB2E0smI`zl7Xc(z5@!`Ho3mhy-S1^iYIHD| zfFpTDEXZpy2?u|2zbpC3fnRcFKQX1HI^#@Pf`GxX@3GM*du`jP|OwPhn%<*v6RW9ZbLB6PjoFME7ZCuW`J^o1F9DXLLs;N<;*w_OZjf zs8c4ZBoIaQ91(AdE-9bts_T)A|2Ip@XCU))H{V%~S~kNxmy)8oi57>W8m3OBT{@Zf zEUWu8-(FE`n9S-F0%Gvs0|qbucWEVV!}P=0lq}kI>=B$GOb11>xQ!=jFXz2lJ2A7n zlX+qa<`iiIuLNAGhn}m%bfzjaKBG1d%aZA2Zr;1L^gcl;UTh`Dd%ID$=X1z+!7s;I z;1IQ5>H6j*j&`Gc8&!f0O{;vLD5%dizF&griRUGKy7Rya0>RFrhh`@>uOzrnFuwH~ z`8ti>XSm&$XS2l54IwVhC~Rokj-~{+Ou-{5ZaMRL3Zw^|J*(7MZGq=Vkg5XY@E!*5 zqaJO;#LOy}EVHrCF8KDG=tJnvoUwZv{60WTRS`Jh43;BbUOV{$=KVb5tP5IL+va!c z0AO-6xP6mUE>}hIG9cgs&i9fa>Bb>t3Arf5e_5=~0g4>S_nN_w7ST9b(=8=3Hr;R^7S`G<}}dFKNsvY zkgwrcIsKN?^Dh&wa`L>3&?;8F`XNwK{U_~tNI>Y30HLG5Ss4+$D77iP0uHmWdlm_Fc(f@ac_UQ$|&Kd1lKL8Z)vZV1h;y$D~!aB0Sq3O|9zhszIwQ*+Epd9iyb&T zQVGu^+5>Kh0y_HhROv@OV@rMy&=S31R(L)5@T|L<9P`ZLs`>)<$nNJL#$FI7tbe@I zkBm8F7aS@9Ep9gCt3Dsop@gaZ){w7@?tI$cj!i*~F3>bv!BpD!nl$N69vz3bHY7T} zHvrg@aAmJiI%Mj32ppR0+*r?0=S9AzVV%g>E{}_Vx50&Csfg zvdmGfnL$WlC}lIt^;1YvWRt+j!};byC3to~*?bPEh)_T#opKhw=j7le6@?*SEu4QZ z1NfV;xeF7K3GQEMjdz~jZx;PQRvISm=Pyx)$akx!Pt31RoYHP=U+NGkFntfUdWRG% zwU1vkwYl|2CX9o#T8`}AY^Yh{2G7?cTRsHpX`Sfjn6cD9b7CdDrhiB|Y2aLO9zOmS+| zJQDbXLJd{(Xj7;QL|&mD=>Xt+Fl5Voc$L-OYZO`)nu#W-k_rOSa-^_SYip2c=Qr6K z7nqPcgRr(?DP;gwG+y&t=Y?)>i>SA06Wm{P*t%wXOHsB>AB zv7wXLgy-x_8NsSAda%vcmaf%jtIEme&R-o#w&2ebN`cBcvp# z0s3#jxZ-(&IGNMqo$2?@Wm!9cs8-99*<5RXJ{^9%S^9=N_<@)Je0_y2M-Vz+r{_kH zJ`kL9U@cya-%*g~@dXlakjyHGo_oyHh!Tqtt|@W&rN9v>i6~gWjv>>!7E?5bd_!Q} z{5+OUiMe#fRQ`WVy>~p-fB!%393!+v(vXUbLPcreD3zI&%5K;zDHP!-l}f0LkiC*n zX4z??vdNan9+f@6_ow&u`Q3j1T(_?G^}bH$yvF1Cc-$ZNK^Druz~scPYg1pqoGC=9 zV$yuND;F$+Y*{rIuf;w>a1M>vZfP!!@9eq*RFSp>ZKcM|q2BSKub{?zk}1vj<6wC} zdGyJ!gU`AzPYb^~jCYQ&toY-B+IGVfhM>R8EOG;Rk3dBDu^D_hKrfGcha_qf+{C=s z?Jg#hx6*c{qkQr>G`H~X{WK8%Eb+Ism&*$63&)iQwe3Hh(MoPbSEHfaTu))mFf!Ob zA8_`*V9cLQ@-1Gne?F|SeelrtK7VO%lx|DsxYzKT-rAQ}@3jr2I`ueZxU~tZF^Fa| z-CrweNmz5(I%BJtMYFKFDH7pXcM?6ClkJm{eHq(6UjzI`|Hmgp3CLT-S4N5$2`%9`5!8FGbW_keqjPX@gO*K0#rVTS&#y)^pHAt1I?+_%nu|uY#lTY| zrR4K!)@-%37_-?QfRRs#Wq-nK9{OjcV$q>YSEUk}fNe#eEj^i3ge^8F28fF!#|P}v zU43-%If9hNW}?kq_PX!|Gv(49nE^?V{H5^!2Hz&!UuI%JULcelrlsAnn%@E6#q~g) zS=QYNh(p29yS9+wKXbN?w088Q4)^~0Hyq4}H+s&lE5w-U8*dby^P}om{Yu%{_Wupc z8!a-G;2My>h`0DZJ=LY4CEb*1`vtHDjNRA+8k^o^avb+CNcN8UC>S~X;NBX+MrHAQ z&bAHL9g3Mb+b!!;!iZu!{w}vn$jv7?!F4YlRp>$@WN`C6dfu(TQ(L{ThWGkGS!t1z zv)=|JMnA<18U90AO|}vNC;ru5y}TFOk{N(E|Knz9ZWFbzh7i097-$jC&$0bIs z@0?xERdpXhb~sFSPrs9Lw9E~?rKSj#>C#DGaQ8q-jx3gIH~mhEbX~1f z;xS>K$o#g0Kh@nwJI;knoS|Uhp*`%6FzP55=2J({$IUtdsayBmEGBa+`svd!IGQ;{ zIZy4~vMIC2JAi?9)yH9%L@n>wo#h*H1TvzQ>8hzZ{7I4NxnAmELZ--0uxNu9ik98Dd0IHJGhIpc?p0SU zP^z$PWHXVMu={#l`2wmJe_fDTIh}5~BuP?LXpo?tWpmmbsH)=c(Gleuc^{VhbD61l zP)lt)(ZuMcd|9r1H^ezm7kBKGC`zES+Dse%3l-Gx3HV63$T7v1Ss~ zxn}k(tXrfDr2h7KE)^<@Te4}JcJuo54w1mLDXw#ZL&u z>)zw8D-!&X558j$0v%>kVAO=*KUH0hgevhy@j>7o(-~E$(%tq|cdXgp{p`w1yr1!0`!Xh(0>zDnIGFD| zos}vGzB6HIFf%&T(nKhHHX|a;IFh&M&k!&<`T2z%^z7=}J`>>9oD&zCeIiRarWBxa zARyGu%Lqex%AR+)mwhJBl?DWph&_k$!qxRDG!WEUs!PB}-rcNtZT;JD8EH7#JW6^V z#kInDPtrhhR6zINt1o6-lEeK8a$*~|yzAY(>Al{S2Z=(x=W(LO$L-tGVhehjuar9^ z?G4yfaY9ScTw7;71Q>D7bj|C_j_5Y@{km3VW>0QpeP}C0jYdNEXA>^qH9#9k+yPbM z3~xwkAAbW~Wy`VE+#NDLv^TE2SQ6RtTtLTE9Ojwus9!jCeCkp|+Q({E%@{p_CHm>6 z4{gx@h-ORkYwECF$i3RzVVS(cf=|?ulFa&SpN+2ib&m@X20v0yB^Chz(t)*VzDZtv zc{d6j6g&Sk$GbIQsB`_?n(@SN#~48dKgYY0&1}7QPP=^7-2EVH+};xVb?Go?YBpeV z;v{L)Rv_Jc>+6>%Vt%l+;C^EX_-0D?_2W@RGmX3KZ+bs1K)$Ecu(WJvh^7AKnAs(8 z+Ob`wm)Q|=mc#I6fK=mqeUPd7M*|HFgbu`&_KQd{oEbl>=;HU^_F^|HU8 zm>A`GML6BfX-5I!9*NT`Qr+?BRHJXez8RVO&VR?@s2EBWz*CjMapK;)j6HYPMiikD zF;QB(ag6K0pG1M~S`bCjmfD3mHd>VQ+W8uert9sXWNhWD{d>ZEDVN^x`2KUVwf}P` z%u6T&zL_U|zU|6RXG^U1jI!(7>I+m&+QLET?Y(f9tdPJGzwg4K3EldC3#1CIPvG@z z@0#$BFMX$3vMc>d^Pj2ho&t^uXJFdcV! zAflEPOl6OXV-Lk_H7_6XT)Af2Tb0&uCv#akWIwH^Uw+tERpS!Sry@K4<6`{FiHu3+ ztB;av86YV+HhaluKp0}}hZT4zZ4^2C@f6b$dm_5H5j|n_D!lO`)u$)yFF0Xks_PHJ1rV|&V zrpc5|UHPtG5zjYlaV?+L)^4#pDC1f%Y0j#!&XPJDhxBocoE}sRDXvxzaxNUW%ZW zEMs_n>Ki;JEWuQ1?Y_n_b(_#$fhHJ(*0}YS05%_{P8x=nQV9(3 zKkI`*zoRSu>^a380LjcRrJaHo5kvAgN@eqFvNqAaUoW7~$3tNdm9Bp14*DgBOoXdL znKl_QWw_M;W!{>ozLDz5gNgO|s{F`K+0H41ZcxZAXa(;B6CXZHlpz?K(xTuz-f z?D=$DYJW~;iDhP=cL0eu+-(@K%R_z%gFgc{LA&GQ7Gy%wDlLbjOE*=4hE9vS1%H&Tdu`6 z-{8Yut6g%#eS~z@#og+V9nn3X4k6{X4X(3}_uTF(z3lsR4G?$yIoiRlCHsfo{L+7w z0xtGFQD&cjU zf=4@zQmo>!JZ=*(7bsWlR9nKpK@8E}72UWY$vnz*RBg|w{4KIsDD`_e%Zc&rPvDjV zb+6+a`>+0au3L_xVec}pWpl&ZY?w-n2k^!=;RklwOOnRa#r^#Kc`MXt{Uaw$_Lc(} zx$m=Q@7=iOjW6gDFzwN5_qju78Z9yg;Z1CbXDQ3}q^jkVi{A@{NY7nWX_~^uJ&D%Z z2D*)LEE)y##1ATnQ`+`EK3YdUzh&ZC+PCUrPO`%164k9?Rt@mp5#055kH~4TG_#ut z>7u;ng#5TGPy^3A-Nd6up&xaoM>k86y@NpL18(tx>>!hdLSY8AqkJL+7q^cMHY;ZK zf74^MFnwkfcM99*$XidDul}lro;?X}a$kpk-i8Py&Azd_(4=gwLKTh1-46684Tp52 z02|y7k1E;zs(9#CU}gmfn5C~ItQa->JRvg+%%S6bK<9m)2agO#c&dgzwt~+z-#`4t zdoFzu1QB!efd%GhcqZ{@KjT`K0e9 zxfEE!Uq*9>&v@sxE)?J0`oM=~NFd5uiO<+>m2Q~CrPjkR3765ky2pH7ZMLui zER3CO=VP3yyM{f9(H`rL&Fi&2u(W&YJqYpCtG8FW8;O~pHv8}V%^n!A&jKK9dE7mh z=Pf?CB~m(sn8WK7A3eLZ;Pc3o)iWwiRVnRDm31#L0=8L6PXhbO8E!aooBiW~2j+$P zigV-y;$D}q^+ZPb?Q3SKw00wk)XmO6eSe&p#-T_$w;*$a%i|66=z|vkAH{-0|56Tv zZE@M1<&uJ$2R2O&wJfqQt*W|mlkHi6(x*9aG_oole-QBF-?KUSu?O*j)%)`Y!{+wP zXD<7e+@+Kload|q%qpA#G&(v)53;B)uS_d&gEnOcb}{k<;|E_ikTkmjn`oPtZQ z2X5Q|u*)k}y1(*Sx=$SCgO{6F`T{v5_}AZ0RRdecnsDtb@?`}anA40)F5m>q!1gh> zfk9U}{L!((LhuHxu<;BnmHJRGYF}9aS_>S@eeRz+hM zcJEK)#kD%toXxxT8WJDRjqCrsj;m?FIFU}&tr=SMoAS7;s!lUwGjD-|*}j#_5f8k; z{S@Z+ps2UO^4RZ!Xu{jx)#`rw>HJtjsQ4Y0_RCxAu^%`11KX!w*4PuY2&$E?1ttLE zG~;mV*{Hs6{T(%0go@?2SL3V+9|T8GQFA=csbsd;ZBA(pW$ z5FwY$y%<&|)}G;*`00As$Fe?N!f=_D;)^nO_<|M}RgOA+6j=hULEFy3S$XJ*I?GWx z&7X(j-+dwm9@Fx-I3kL0kXb_@!lskE$>`g&O%5Jdt?Cj!z*mv3C6iYQ0ti>IcOHwH z2M3Q9HzhQ01Y(w#tdqX|XZ`P+m0T4PKK6WIn7cBS#58@&*jYj@hR)r~xs_b%SM#u= z6x-tSm+=PtN-AxvA7`sr`2@79-J2Ks!)6|`0M{y2EiYkK<5)WN3egKFb3Bg|V6P*- z;5<4e{He(TmP#WNASRqhbCneN#pHkHX#XtHeR?N(?!cvQ#!v0o{)?H__$7T_ooKAa zAtU?&l|^$-k9YE;q|ZAd$BG-pyg3T#EEhgMc`iC1a*dh#JL|7iMWhE=cu9^$aOBbh zV~K62q5GUeV9$r|pMn?Gs<*4k{RPLEWhLC5q8QwlAN?WRtht|A()>@bkdpiU0D%cK z30W}aS=TT6{$Ih_eCJHi!Ac~<&PIrctT`aCkD{87(InG?t*8)Ad|A^E9zIO(;H;=i zTJLGzcNv?XUpE{qBT@v+Nz?r9){>^mze2HDtCD3y=zr}nk4x9V32~Sr)mCnPLZW>? zwS*9+c%1LKEW1swlXb(fHncJ{Rn2|u9+B+mKR%%P?o_d4TI}79!FJmt!)&S#0C%J) z+s1}52)$+_?oPz3@2$BnMl=)V}E;~Dt@|*hb)Gh>U_g2=8wGh zxJb6*I{VnYy32xw*O_gf*DL*lozk+2f8S4s-<#;VU&(o{E!2E`M{+5j2T_l`YXt+d z(}6EV_IbX`gkx~fUh`%oI3t9Mn`nZU5yGYt6uI9Y#qJcw?SQ6d)aaFVi3V3>PnfItmj%fxLg zltGgPaCdXZ(#Vu&;M^OxJMX?0f25jq=1CV+c;cw|M1Yyl@FkPLuLVzZulHloH2a;=hx>2_gsMw*LhB=$x);kg|8*?rIbf|EC7U4$=&`)r7q#H!!bonB>v7% zvaaFXCxisswqC-yx-le}Ec*sV2I)`|mJ++$It`b>sf%Z_?7NhLW)q>ck+o&5#=K^)zifbf3`14fPK={kP$y1o_jDYU-GmifD$-!#^)RQ!;B3OoZ+cP!;?QG>}>sgt~ z@)WF#ECMaHx-Ymo^UG6#f21he*Q3)4;>0rNBztAc?U~alNbexUZ_$<$B)9aJTbo+~ zgq;#@Y+z5bIL(P_y*wNNG1mbH4R@eICgjlre_S2-QHe^@S$jlz(4SG!YEBq>dpGpq zy;7a*totb@{q+fdU?}?(Mq}S^h%T+z+(BeZ1U{ou4%gi7UY@A-3J8ORR=mp8#nbfE ziDTC|eTGkRw!gqAZqt}iB0c)LxodJ`%=ils@!z&r;D>b&I$57uw%5iP;>q#vhlb_j z6f@pG!M8DQPc_>RaZEy+xY;9%#^m@@wQGI%;jTdc;q_=j!m>s1*|zgepQ?*s#=L3{xBS=W zE9(4vEHGZU&+juz@S@jES>00rZuPK1@{hwbPOAa1Of&Ifz0%()ljAR|r#P3frV>3= z@1V}{p7oRcJjeAkh#P`%+e!vLKXZaxuI+MWoz+{64+13G1VX7f8o@$~OZuCSMTl7` z77+>C*56=b1uELA3H5#epoYMC9c$}H z`;|RTCI359wOa`!lk1gh)4oRDD;KRTuUjFCM%Ugw^@eoe#@+Qxgdx4&W22}DOBrwC zF0(sDpNk^6%YFVvwR_`+GyU4>#w8ucj=R6wYoK<|8OD1Dvd`oP-B0A-PD#F4lh812 z+=ZuO`<3e|mcL~@G}Q-RB&DPg6&+bRI2`)E?$VQL4*tlti+#g{rXzPoC9KT6F~j~n zIY<(#&2>Xod4LvI<^SWyMBU5ABTjhRZS`6_B|RVT(TgQ`D+m1WY9A0)2GTqD&~zPTDw&2k%L(FH)BE zuamI4$1HG=3M0Ob2jW?ve+S3UYhB>3$gr?kos_pN@|KEcVR}Zd6NA&>dohLz*bbIO$QDZR%*kz*cFxdS^1}EwK#h^p zQe2z#&SAVIX&1RT$PD<1mzwPO(#fICRAQw#qwWqn#Q4PThY!!oJ@GdZ+T{$yD%W8i z^TWx~ea3aFn-5>BU|tij#FP*W95&R1dQuxTVl-lo+e&RSO6T<5g-Y~IAigF|5#MeS zmNf0TlzghMgCayQL*tBOCUXCPy7UhEC{@b5rA%69?`SAUe}f1g1v|_)0~GC>|;t6%6_dz12p8 zE<;u?lIAOtzLODWmSO!ajp8kld;Q2}1(v*J0&Ikoq*5IvYysq7J5Gs<=wbBMMymNg zrG{ncnMOo0^z5zI|CKdfHh{isdasSHaxN!BB;P7u`ptOV?$ofoH`dNQ)G}lrJSC5Z z`RkhYl0E+h5_u?sMB|a$#Q>+V-qeD>j6Ew0>mnEWjTX{~!@*e1RmJ^G8T#^XH?-dP zr|N>?aR6x(te+nWyfeTzNi~$tUUQd&84bW*wxNS#wi=WT-x{CmzuhC3vOm{I`AtS# zL>8Axmh!V}!frW%s>JMwh?!D3*guJwJ-`!`;ZB~Q6g)v+#}XWwo)sa!XJ}q%he#Wy z$?A~9M#olC0j#~kjcb_v^GoN%;8K}8&ZBG*j#r+aeI4!M&M9tq_|BQj_nC}qF+~J1 zTmkkv;LaPJeLH^2YR6YV=y!TjirsgtAF!%hckB7cg!Yf25mb45Wd``gnnQQ+&)KC^ zF^d07WAII!9zQ{fSmoo^E`|pt#K#@v{x``#*GrnW53bDQWz87U*ZRIU?keDuQ3Wq1 z8Q5yhfM)U1ZEjl3T^_jkk*iKW3%CS|@(^#+>@tmdv~~E{cE=!=S>5=bE{&JB?>lf_ z8%F70K^W-HDp2`x?5(;It9jtVtgYk0*Ur9LvSOoMQ|9+z2|VJn${nuPtP+Rh2Van_49Cv^TJ2nZkIqe~*nb8nr+9e(73(po;v5qqVb?N=uGceF?aem4>hpFY;zfi~$_ zqgePFgRbObr);4c%`90R4}yX1fr!+>+rAm+hgeCp;2j{M&8qe3S-?4?@=Z#`7OEJ*`rSe*{daZfzBE~m8AUr1xCR^UyG`1^sH%P|OUI;}aqN#WNMZ31YIZ=49L zqK4Qe3{uMv_V46otq74d@?c0lP=D?U=15x$hMAXlyOW=f1+g!)czXv9t0(6fh#}xHwg-#jIqJcRX1_{Qwn*Ef$*jC^=Waa>uiTJi&PTn#n;bW`*dw0lEYZiLvD@SyU)vw^OL*jY z8$}7>+iLA-Bc1e8NSm|xs_F8<#PT%E+en_mRO^bwGa+;N|cy@4lJtFXJf-|k(JS)*2%%jGw!Gb%HO3TKVsKHf$c9rdb3CTp*< z?oIj{AU=1A=*V`?$(L7}tm<2hN9lXuj1v^Au1D=P6#BWUf)7fF6ym&+d$$A=r_@HN zKqe=RScTOj(omV6nApVrB384~+Y4}56G6;~Ym~@d=@y$>4i4tFJK&f=L!vS<_J-ES z+GD1gn@cP#no?v>T+N1MQER#^uHU1hvq&;o4KK)5QQu>QS!Z=fww%MtmAXq(gxgCU zhhloPiqUV>4^mRp!pBO4o06vde3N|uDH1QemeY?*H$M_o3;$vG1=P-`i|vL@Mw^33 zXTCavM*pm<+999fCe`Zry*rd6qBV)4{eUBsHMXQH?m~ZF7bMVmcu+{fq?B`}zHmOk zumAFDR3P$P+l?=Ex(x&;V}*YIQ!Jq`kd&Ql?|062l_!~}M~{?I{na0$C^8%Sf3*Nl zNG!Mp56#frns_^f5ANLTcEs{>(T*+!hK-v_W*NW@zgj4c^t1buk2Ghw){j_)E#T^D z>EHYfFd|uUZ_5HG5DzBedJh`8ugUnBYC5v|l^VBz_;EgAf}I?by8MJSR*mLf5rNeY zF^3-Ot^RDYxyP!BCjF)7{52f0wx1bPk0p2>r725J!`h9sL(8`!? zjfB3HF5h*zrJ8Z57AMys!)CZEgzmBp#yIQ3v=Jz~vzQD<%0sdPLd8HfS z#FINJ=}`~E84j$~6pBy&3S6y_`X8$o@`cdwRQGf|AFaxtMxCYGg@PMF%Q}yA2|K-- zkE2IU{@ohMl59JoPHZ%Os3t&TNTDunB!nFqMFD>>J;Xr(Hs@i;Ss9f24 zg=IW?MhjCrZMqW#PC^$m`|o_gaXwWKfN%}i)wP@cj=OG8Ra3uEit#0tPgk5Onx4cTce!K$e?K}QrK{C%S}(Kr{ItV@ zW@ybf{K|3xx|XJhQX`c;wgn!}Bu*-5)*d~tMWN7|a&_9tLry|ctO7C~2psrk<2#ON z>eJ#uM-ku{{P8*_Kv?Tj((U+}9s3hE36Hvjqk&5#Yd?pX@W5?YfAB@-F@(%F!HDP( zqFTP5;iIjjK^C)HK&0mAy+9%L;w`Um1i17iCg{5{2*g#*5BvXM`is5k^7WFh;G6ZP znfrXx3N1?5V*zEHDz$loD>y>+C(EnW`u+Osn@xD@{=36&Vxz@=BO4BU84|t#Nebt) zEW;Lv!PaS2MBJ>>L^0noxXyMd440=fG!?x@Eh0IJyS|4tHHr3kC{%C3#NW&E?C@Ea z7`61R$t$%D83b-yHDx6etjD6@&YF3(Ncnpl@e3qwp>(w!@E(-}VR(NwT^PuyK3O*E zq8PuSf=?-89Hit2Od?Mbe;u()4Y9aVZ{QT1Wl$U99$^A90PVlJI0lnMxOxWm{MWdv0z~h1q5^)sb9VR_maWuXt zv^?5CXjJM{Q@$qgvRYU>^3QmoE%WaJu9GuGmHX74bD}FNLXH`gQAZ<3ecfl@E2MB0 zWP6#KnkRm$Nz`hws_LW5x#otM+0!;{lI(T5%D1Gf0x1jMTOs@h@?XyNcA zug4_zJMGp4v;!tP#`k(v+_!Wa&&MQ6@YyoFPy`pF?!|2%Xtt|&7|(`_u%SrCW#=!o06vz_+WvORbdSWaMBFk z*sfMvWaPdLE&!J2)nwKu33S7y_!AbFU-s*zc+-rTfYF)Y#YhK2t_QYe9smZ$8g%)C zfm?gtnUd_P#D_R`k*LV+&W}g+MS$?sCcv}bUTAQrh$dB72g#duole^=+9s?St6(kl zUdNqP$ml5GHj>-rzVX*mrYepvj|CnywVu9y^~Ue=O4r^eZmMHO@YK-FE}2*jyGUr; z=u%V|L+{+>G^8I?t8cX)9zmUhYgS0B6I7U)k<@7zB0QW5Kd}2E^JqzRhDEjhO-X^b znrS<1?w&x0MiHCaXtAS!w2*EnlUKWzsPjHVV4laO4OQ_AWjrDKS_mB%$dggQX9|;# zK%~Gvz$LG^#0_W9CJ4;Bfbr>Aax8NjGl?Vef?S+-SoaxD-jg)N>Eqa85D}Y2a!jfT z#kr{J=rq*aJu-3F8ts+l;Gn3QoVhbfaNNw)0nyDapF2wf%9yhy9nRdNVnMcX=D~t% zyo_tjzK5kHk*UJ3ms$f#5ZzlfbDB|v9Zv=)+M>ayS};vHYxLOfALrHDHkq#82E;BM zNhvvo-D@V!w%F6|)w)a^+h4iPI0m(xt$qrpbPXbaEKUilyFFUufEKF}s$tAYmk%?= zG5`KEv;G6INFDRddU6=Ny2Ewh&DvxGjb+NuKqhk$XWjmCu4`KAD*dznI!m53Kb*KR zp=%!{a7@YaEi#PCGqnu%EHyu_3$c7MM$uNsk^>!ag}J2EHo0V7OuYXDHNg7Ejs0%k=N}=Zc^8GRaK$Y?FG@gAwXDlgCqLPCKnvgJ5z= z&CO2jBvWl}s*o8gxn&B_jMGQi|2o5%lG?c57E`QkJsqg&wX&6wOLl*wJtwg4+DK1J zJkCPrf5j?3Y0hvI_?!bFWpFg_#d-h_ptBMPBp?519Gg5~CYT*}iKbR8TL(xlWZzts zvUsrR8FfPOkUQ|rTtp*U{;;_6#9MaD9SwaT^PP=;Y^y2;r_K|&VH+Q{V^mYfrq@-- z0Pz8))E)P-I)BL(Zr zv6dTsz{k^?t)!Nq8>zJXb_nl%mnL7t5$n?@zH8Dnb9XFTFZpHgkz42fg{^*pG&mu) ztalf^RQ(e0Fq`&gXz6tgPskC|vmSEGq zPGd-pi^tZZ|17tt?&xR6aqM|FhKK$-xpm73jHjCbW}e$|`lXk=i@B#PWiURfcOG6x zqi1>xt*Qn%zn!Z-$wwYbzHQ7WzkYde#Rh+-QHe_|=qqofvs}0LRWmk&?)fub!!nzy zbdAZsMk!sAldmcvL#B=Ur$+lp4$;UsSzWhS9))G|Ymzq%_V-@$jBoD0bf^4Xq1!}n zwcB8Lal35e@NV6^w6jc4DXRj*+ht&6R|L%xNszfxT|n0P#5!WCbR`07bb`2PitL$# zXYyuVp2)M>lX)GA%v=&gDJDz8zXkz|e65*@`rFA_p}CoPJ=dwK;q{9p0bWSo)>ZrL zDVX2h+bTKIhQ;PgQwCncvzLPYku&Zp5dcYT;l>}YF=eRbJr*JQQ?V_CXW4Om>T*QV zl0zm@NKXkhINpUZx@A;Y60;@ASR&J>jZHn|z0d`z@3-~NWzApA?iMeYj250?B`X}P zHcc_=XA@)C8{$VB)6IO#G~TD|eLcRGoFQBB>ho(pvBDp}QXB6pFA!viL={|X;yt<2 zIfVBHNoCkutoz_3n{N@=)I_CP2mjOT!$nTilo16xK?^n^^H_&@tfowK6aKm~&C613 z;u3jI0Ir={@G%=*Ft<82_pQ>0bMdC8lXzX7zcq^`nI|WJe+0eX-<%ctE8WTu|MOPn zxCXL7<%TUBKe8`H5bgkB2XZfCtxUeWW6P=vj&PVMG^#e4j%>cULNQveVt5+&EVpe= zX30Xx&;rO`XVJ^>%nDY{+}1nB_k!%lEX%9Jm>w(8ns|wxhMe?7IaJT25@g^Q{1CQ& z1ij2YGYMwfV+FCs>=zENon`fLR1Q_=a0n>L=f2ArdP>zzi%~my;^BIekRywTH?Tu8 zm?eYo)M^^K>1{UgzNG&~aHwK(nNoy)`Bdp1HVxm@>@FT0V#odFr>1Gft zb`qcZY)(0yNa8RR@Uo~ z@yNEp(z)oot%R9c&tkFt3AK#GIn+BRE+aKmnJ&O!!S=_MiL`GH?MmNAA#rE`WiY=s zb;$>^gd@h7Av`oTUwV86u{G*qUbKS^Z}|E4&=7wN=_k5O-4SN?@SXedEhk2ErZ=Uu zbxns2T^UI&)B#-Z8jQr?ckc3SdXh^$j3KP`8<74ow{6vV55hh$wreccW26~%;Q*Mv z7-o(m@rJgt^lny?)y1-G-8q~l9X}7Uq#8?`V3ZPj7ZGpc{DT>V{*R$ zpvL?UBmKAR81BhNdd4#uqOI{F#+6UYm(1C*%KsC~7(Ff_?axF^uPUwilS@}oY=oQ0 zj_=eytJ8(P+0LmVLEap58sr6iwRCcBIgjJ=p_Q;5-w|ou=KLl}J1H=f%MX+k@rf5p z6kQwju_f5B-!78)JM~*Li9b~7fCNIPqje>*oNg*_>OR%RZ@RQA8JRA1Q-mrOl_zT} z@{9_TlAC31_6TAL9iHV=QaONNh-`h)X9t@}vfjuUN+82*t>-qhzZX16=s{mya(}%0 z%vYR+Wl$#nfPe8o^aC;W*Xn&OAu@Zz;TeN-Qq5Pf{i*)22T?N*R!UaiedlgJW?T>j%D)I1yqURZ$~s!P^hqnRKcsF5Z~HixSCePwh>Dd!k;<>o7^4;6`zEbo={!-CIjmKHgzdWw_ z^IYl1HU(PYOh7{+x=y*a@2@Umq31N;{_fTwpo#KV&Tn>+zy1-SrD+CPfzNPIf|D{{$^h*>IHpFcg zAWUbF(N%z6#b^$u;egMfA-mw-VD`S;ZeN2oIQR^bj5Eb|{<0mATgYG|WqW6-dE-?i zfDAyRS~imNoKKeuteO~4o`nrX-aZwH?~tuq5n9#%Ud%5jYR zFo%8t7x#(t!-S8$975VJYBnEw7u$d2a=}>%ZNjGtx_AyP{N;j>YBcB`BY+E7FSXg3 zAw6Xy6(?LK8Bw~JD+-BW&A#^Bk>aH`lOsO?ol8!LTgm&zFiP|Y0PA#=3n8tH|K>=n zm3dCB37Si%2Y&M2EZfTY>|*fO2$RW!q8_+LdVt{v0bovyAJU(Q^(b^R^7|!_%-b=8 z8~hSC_;F_Un@b1?hzAT`KYAm%`b;>(;OfDcp6V`a9(2({wg{deLEF7r=1N#Cxt%F_ zznEo-X_sr~*wlyX6Mg5O7lL!Bv!P6o1c-V^-8Hd~!H?5*LPQ_#%#Lx6QrQtuS z$(oGMk60~eMg>`HBc(CykdrRSyg`V9m_}rIZ=3V0O<1)mYd&{(8u%fX>M6fyf)g(7 zlJntFqs%KH2wUE!cfMIJdRgP|H>^v8vy@^mg%$}XJi7qIYwuudhm~X9BbTNehorJv zw3me&4}2&jFOw4CbKbL!M~3!@nFm&MtMtZTBx^pT&C9)@t9k=jTTj&TRJCXh19cC%IIqkkNI;{8;)N zC|6CeP%Um>=xKTHgpQP%%x^ToYI!`vSu`vUl7VUw+DfWbQ09;Wvb@Lfi7sl+4%Vv+ zlDf7|8|x`jxy22aeQ{sRoUzyG={|N#m^6k$oA0~T_d12UO-zXZZZtUWzt9C3dt6&| zjQp4=u5)ZO^V~ZdFq1`4mCnTvRFeS23I@%8;8305 z9V|q5buAGx%)1tndar{`_r)H(&Ox1Oat!3}H9z4Gl1?L10xvdpl$&X!aDCf{bgJ9s zmWfNr`I?FGOWkRu)LjN@0W+8T)1B=e>GUAk-1 z_wOO^EWDD====S6r47-DK3c0zeN(tFH|z-Uv>s-=0a&X`y1wX&@yG6wmThgx_w@9w5Ge#SniKQ*OK$@at+4;OSb!UN;`f;{kM@+hxZKG+O*i0iM}r2-_mW- zj$^_(JGT@+ca6QO7=TDtd9vR+%?1!>C!ck`AM!^(h6sg5Cc8K zFBap*;`=yg1s)C{khMPo{ie=;E6LB-x!21Jw@V^@;NmKWXI{e7dqe}OzVj%pAOXRt z?BMLD5e$;#fd99nAys%MMWHVISHa>51l&@8>kiky{a0M}ORM*5?FEbee%{b!j2v2C^Oi4Y`>+m!UQ9iZ1J;NgeamzmF=)e5Nc30*!yu95C^i%z;dqxs zTur`(>7BHPa3*Q-AAL!Z1zNd#JzC#fj?uH+<1#)ls(+_GK9UBSOAE)kxfP>2l3HCj zd#_NWOy#Hi6T|@c+{fele3X9>1vr(Qa1IoD`uBrnM8@%#X%ak!CAI=TG`HrE@Zqdo zv7SfIp@$zwn90+}3YKpbP@Ib|UtDnDMpJr4St+B=XxN0jHN+m1a90T|vPNxG>d`4i zcTrWIeb!IWf{N8Obsff#3cM14_lObSED4!~78X1hYfJ@K`-=huX)cB{Le3*8=p1Ka zb>tY@$~bOh><;dqL>OVS|1_-C32sL`KBFvr0(a&Csui=)HMhOLfi0hg2b;_pwj&Wp zvP_*w;;RMhF@QGYkFNM)9n?V3=cY2$q&m8z7W_7lFanpham(mhe1i!(p`oi zz%IT%y{^-QBrU3*DY`fW(D*Ln@8&Qo3BX0C2$G;~;y6Tz0=xjzcBn_vrFf*D@8Aim zi|jXQS&DHP8$O5OyCfe-Kn*S8zdfU$GPG{DE>Y4=Os*0l#zV;nud=Zu?Yc!@VR;(T z9)p@Z`Qp_a`mo73#W6CYCmmpHv4hGQn@8WHZDlk-`29rmVDn(7^eg0b5yF|w_0y)# zs&R36uD~6^ZIj3aH%EWo3)ABE*-wNKBDKaC)Hk-mecawDbo&R$$a>$E{Kadc>vgAp zB_ZUep&8@tmn=X9#-VBGo14P~># z_EK<%5&y6rB^dan9=x@1Kp0B6>PC1)$NIyl*DKTmkjLui#4F3^d@5dUnfpG1A)ve% zjgmqBvU#oUx#P;nR{Te*;`Q+wghmSUN8WA5ov2f_8AJ?c7_`2va8^kHcJY?~Hso7- z{%`$OBQ@Rm54(VjUj|mm{lr*abxIcUfc@(~8}Q#IPE#s}o+4GQq<;>Z4|ICXI|Wo{ z6398nItk`n`{!WY#XC6ybx^KHD+$4oi5r_z4CNgm34o!y&fkQC&UgQ3Wa4nOC)|yC zStZW1FVDVyYOCTAN1Q|W-IqmQ*!nAWVR30zhhZ)kDVKS}Cts7~V3-qF1ap!J@_(+B ztu-XYU_Zs?V_l>iV-iThE)3iimIf!Ay|U^|#FK6US!rB)4K?k&fNmi2d?_UPqBw_ z*x_0N3UEBdh_+$CqN4lCgMEW4k;&}DqX6r=IEf?tk#dhsErcG$WFbg;)?Ey8-*gOl zZw4FI%`WKNNv#IyHOKAuSo-i4CyqH=J-90Cb(goxCL59t8w&yUU5tDeUEdxg4^BLt z<%auAQ`>;I#COsUAkjU0mVtOzTiJ~zHj`cx$e~56xZvb!NGy`(er;Z`E1?E#jzr72 zNK?W2s>)OS|50r4ZIV$(|L;J9MbE?`Q&dm3x7)c1IJimyKHv1wsXvGSqw$@mP8%cA zD2puZCVErGJI1vocd7d@B$v5Dk;!vz`lfGGIO!Et>+5FZO6C{eK>^ti#b^^*Y>&Oi zOxO-wB5NrS3}(PQ1}6Q($h{(Y!L5PJoMQSv$S{m{-9{rDagSbJRm+&7#nq99DeJiYPlK-N zM7=fFTr1?m5#=uy`zRXU3QQfB&s`S5y#PoHE{)jFCP+XAsT9=IziU}5PT$^k+}p0{ zYj2dHRTbeaob(`urX+9V|Bjh=T8!>CmkwJ0*rkUo?){hF>{vWzWYLDs%Dv+z_xu_& zwt#|!sX&PQs=c+mtupsnI56JWkBO`sL5C6&KZ#`;-$trVn(=EONV#n*L6#hM)?!yj z++qJXvKy}_2bGc+kjclW$jEDQa`@31@~pDXo;XJq?M+3}Eww7av(ZhdrMfp#n%|IhN(r8MqEx+nS+uJJ%^*M|17L9K= zDgVE|Xp`?eOeN7R1k=Kn>mu<>!653AyJY!c&=1%FrPk~7-F!!vau!iKPjM|x6`jL~ zbx|jM+SL=9n^3|*2iO%*EKOUSkvq9ZX6m<1<%495TXN6?)95FHApqOY-HRL#u=Rg- z@$O})bi7Ss-AUvOMspRdTZGGfmeSSn20;}1^D6~r{^_jPxWAplLkxRSvrGcp&S9xX7-Qq{n<05uP z+;V6B7bs?6iMbBSfJbf*D)-}!+f6ZLh&_9^S|3+Ci_Y5^as4-s)VUs%Ym1qpH7EDK zQ;tCsazo4ZCRxoOun4ii4u*Yk98A$db_lon;X2V`Mu<%Hzb(QFv;I}TQWv?mi#HL-!IfKVHeB(Uc?I`b z#KqeTEYG21P zQha)u4WVWtU5!P#t?HjKZ9+xYy-y@-MD6t7CkDW(VpJkJf@|*Uq{y_v2&OMGF_TR9 zf){bTi*K3=C&sYt9afaz*Z@fuRfcqsedS#4^?#-js%S%x z2&+fZC_xQIhb$(ibvQj)=CKa8le>#E>`v94&?Ae7!a4#4{VLTaz)S@@ko8X_R;sQS zl%dv)>^HvAl<$OVL=YunV}^!LMUIV~nz%i(ckX`zx~FxsGdm%HB2Zf+PU_$3x@cB# zyIAi5B|TELx|!v_>VtXVG$c?YqYRO}U-ZV-j|eeD@q2&)5SHzXJGP6pM>=()z>Vbw z244voP}u*gkGt|#zga@A=o8WxD2|*W&#mXNbI=ml(yBn?1{vg8cH)=D-vjRR-R>&) z*h#V({^7EvY!e=U*)mjr<5g~V{H-wg(f6{P>I89ETMhxi_)v?Bx5U(1d_g)*gdhmF z4dWk@|FWJMvi9Y&b{yU*!m9|m^ZHUI^Q2-jKOkAb1V!@|&Xu^!**7+LbgupqDwUdv zm?nUWCW7HTmpbBD=CK~$%{-YgaF@(kc#u&pM`TEOTQzy~9B2V`2)oU8Fv3sFSJkL1 zdX95D#gLhTQ0M^%o#M?5H#}%JnTQ4ww>4qIY=nKOFk6nLbx;H9JINCt2Rq=?IQ9GD zwT2^tdsL7AmbfVL<^L{)oa-~GFw+wwKi{{mkM+<5$ZP`>@kP7Rq@eq&6k_SExhQvO zF5)vAXZy_@-~m*JB{9pwy#e(UQYYjUP&rrksf3Upz^@w#+ML+M!mQ} zfrNiyyDDau0Z%!sgx~N|*|>=`S+5&c_@k%M%vtW5s|ykC^9?rr*-+VC3@e>jEb*Eo zZRdU=#ass0#xIbV%n#!CWC2Fg1!Z0o!cdER{_}11G^L1m%QBkHrZ*@{o(7>y;TYqA zM6&-@m31%OHYbxrK6`WXsTGTj6@y0x)9ieJQorpa`D{APoEa+39fZ-^0*SxvtC;++ zuAIxClZ{T2VSdhX%mKuJdHTbXn>5p<&ye>`lG=69hZClr;ic{(4@QDTW@kp!-~V6f zPh`gasHWxfHw%Yph_6pU?hp>-=y7PBSHkiE)R@> z-R-vbeQY_e?2XLNcwotN5V9wdLxrDUiiYTe%Ex<)KjFlp9nb*TGBaLIRz=sfib%sr zRFTM;lz}AOKE>UX{OnQQYL{;~wk2#@-DYh1Tk{XyTHtgkuf#z@~&uGq-pEcOWQ*{NI_D;BmL!|XA@~dqqkyD zY?&?HNGN&Om`Mx<48?402uTJDhX{sANAR^fjcKz@tdIBVFKGE|p&Es4xg~64>_45d z_fHxEIh7nBgl3}gy`AhQ@No2U<1Cn7{uccrd~6@4%aOFE`XV-pxNtaSN=3oW~bsN7-yvS-q_G4%d8`NS=p=U8a0hi>bULJ}=R zJPm{RUm?phK5=?Fw)KQCdiJI?J6vY)m}K=3drI_sii?^!7YrnWKw&4GKZgtz0)pIt z5TM@wkYIeY3~u41di)Apd@>d1gfbur{UrEIq+glY~Bn8rU($0&icZ4e&ym!(V6EAGc@mGf7S5U972CF{;TBLNxv#>1d^b$Inh^ zNLJ(`--=*3Vyuzp=;bDCGK~tGKCi8Cq1|#@KF`H8q67N?vnPUTS@0T|dKs9rSe#Ix z=^Gi`)km1gMW$c!K;zkfm9z~iSSAeFU6KqL3kkt_F~iHiApobB&=uyI`o1e;`B?6^ zHj#D`JP3)&^5$%YOj0C-#fz5F{oR)`;Loha1x27Aw6mB3)=$H77pxLH2#A_w7pBzo z-q=ME2_fN(n98s?`Pq3OQ@fhveMs}fLbMiAm^F5>M6ozG0E;7h*hWaq>Az^d1Qwr< zd)x%9CqyTZu*_gz@fur4fr7^|`Wig#AoyH@T+H6X3mxj_E|nF6Pv~h=QWjMou+g(3 zNNgqDD1AAkct8K|5T~_21z5L#`^vV(QZ*v_>c*74Rn7B9dNGCW3vQQct>`~6x@L3F z*krf-wpn{Kn zJUG{=r%AfnC-qK+C=Di=|CYnZWx&vZmEvC@(fkTD*LVZon!7S)k9wq079(;T=J8@G zt6RNU_TO7^j{wydq~HFRK~H5~0Z})695M(wF@BbT997wBzp-htfbq~(Hg@Eg828tw zs+~iieKhkZ?^_5gUlIe8g3;Zn2_6ez)>-#dM8yxz2`ye#R!<$g+)rKZx-3>ZJL>uy zJMJ|>gxV{8E0@KebT9-OAf7pmr4KoHJ-|Nv0meT`Vv=+nOeYIxQd1XQ^ZwiZkvo@- z2FWMXhkNlpPOhEAMz-agTFJEJp`tUDMVd38)KD;O3#LqsYBkl?Q~Q>V-J8a1ynxEI z59`nDuk?5B>%%@|lX}Kd9wLa{kD5N*#l3 z+I?b}(5yQtQ;&o4Kzt(W$t%1r z@L-zhH%mO^ui-~_y}xan&6!9=WssWMM7t>BNNzGl!Z$?mkQ;{PfiYZfrhEFx zA|yiNyH8NkA4wT(n9Er&w3Bj-J&1ZFRC6fq zFMo0;MvV;ciix;Ml#vU3fYM9|jtY3e;cPRKC__Eg0UEjO_XrwKyM8bSCBy z>d8%?6ly+zsl0TEhLC>jRs?3A{hoE!TQ>Wi76*fwV#%p z?};xVdN{ZXoP1y50$Mln<-ld#MW*$#7rq@p4(h&PdpKvKPFTGQ&+m0J&+ZT!4mwCD zh9Wr!Iqg=`au*?z4PDvNNV|oV*U~2KNP-XlyImT~CiF1?*1q@&adgKuHu+Slw&w_M z{n2G5^)6-yJ*Tj2Mo6n|L8|+ws>FpMiXW-%!X`}v1=@An!G;*G)75Ii#N0^`nUZ?9LmAGsMmnhXGi(| z$!nd#aEP^Qt978A$hnnO-Y-BiJQ}UYL^1~rkDBQ%)Lq(p8Nfrg09jqyRwMYMFnTO5 z==tO)%@$>w?fZtVy0}FyqS(Jj8%(BcVEN_{a0KsfPWbu7U#DulfVnw))LMM%>8y-f z`~fYGOmIj)BYW3qUcKpb9&9Q5NQMjpbC1G57&UJqEn29Fj4iY;u-mo*82KkEk%oeZ z(ItS`pgf)-iGBMF&cbIhTnE;W=DI1P*NRGwLh2pdS%ti-DB9qF4mtdP=#>B1ohn0t2^JDBG=cBuCiFU+7 zGZZ;<2-A#T_kDvK9*4=rL;=QZ7;FaN=Guf|L&t$4U z8`x$OQl5^*Xu*$3d{Q4W$mCR%-Dr$PjQlg_y=w?73<_SYh0WG23-=F2569y`+~#vj z$VYm+`ru|H(oHF3u%6xoZHmGc-^J~x-`#$J#zZs97>$X0cMlqEkb*zhz{?TMku0&l z?~a34@-}nkD_8+C4%fYC?+lN;2cNxe$=dDncWHYijrG{POS^gEoZKZCpv}(oFP%1A z^oz(vA^U9E+(Dhy)cwB7Ajmfg@kxAcKbqyM+%Vypg`1PWA7R6hKg%A|ULOAA9Bber z0gVlsv^kRMQrr_N}M4HK*gsFSC zFd*Ij3uCJ2jwIt`y1ZT{y{@tM&cs1#!yss$&KW#^=l}TnE8D-3mH`NGECyh7vWsbe zK&uo12L^1=ba)L>Y2A|j3ZNzzW*u2H0gXpOA2*4g#YOT)Z$f2rxH&-1bc_@mVW{`C zyGD$r4!bXOv#HpD!HH@PeDgOa$M5Y(IcB(-=LpDy&9Z?B8Z#2sP#@&|!oSB};BK(| z&#c|v=TXm-auUO3mp?j=X@oy<99uh&%7s4E#4M%nE3eSPH!SnZ5KAVfre-b@1P#lw zlSMsrcNs)=AQ0a2&}C<#2>s@B77WW$;iqyIU64QJT+ZrkwR`1VcUH|a)KW$w@gQac zw-(KRpg`b7k{PuIiP3U50S=KUtpcdFsEDCj;P5!j z^pkYnHp?aIESrH**Wh35P;6T2^i5#?mob~)MmB%@!jGr(-?L8t1VG66R};hmjpPxS z$eB*;+%(KfID5^zhVpw;9fSXwp0Nvg1#bJmzqvbl!AP^)Q^-!hQf^`)<{6`A(5NBI zvggSLe`XBE<%ZBD3;YzR&;}Ueo=Oc3T_dZsQeX+);G+50vdiEEdW7O5vjAvb{#;Vp zb@uU)YmoZ;WDI54ehK34l-?IcxbA~|*F~M}U zy}aS8zI@inngq-P7e+C+wMQO z@PdMInFZYP=bk?w?OG>r+!}D>iNSz{rkuZeYggLv>AFmq>G6X5#74KSV|NiP(3uu>GSSmFN&lu?dlimx-GjP4sxT{>fG)F)vj5-@VKe7L+apbM}U592H^K(Xl z)5dwE!Gn+E*%bB46}bt`#V2c`=w|#hA+*lpdPP<9I0&5&BbnF}*o5ggT0R(D0vv^A zDnYzHu)q?#6}%S{sfcWO&#BKCUoBkBcgam>`TUn82_SFK>pDY)V>h}J20kc#3<>=l zm3a=>H-sCsirRoH_%mP#_44HlHIzUg(TKWNFqi8wzo{OVpzoUk6=4@h7#v6JM9jA7 zzE{1}7sX;TAk124K2!97X^-}G1=9J8Q-8@Wq_i!BjBk(Ghq6Dv9+J_!ewmW?xr2J~ESri_!k8`Ty{t zU<>+eT{n->{qKMH?+@k|?!Q&~Z)?!9fd3BA|1Jl*|89-{>vs6>s{Fqe##I^8Z+||5j9b&37J19`rlMJ0dF;v+abq6%JLZM|dz9(qmc` z7%U_}cwq(@_4=|t0X^LN>%DVm1P0r79sg3qu*{r2qVu+iV?++S>Vs3x3H)MM~`q5mh4~{KqvjVs0aV zOjlNOrutBH2o>X|-M3Av1cxw1O@(oa6$mVvZ+?6$dPn-#*i5lNZNtr_F7*ClSol%b z?@_>EO97*obX);@TgLC!sJvpBon!&2!X%id3Fy0ry2$vzB)|w`swRh;h^e@)UMglO zc=ug=u4Z>1&cW(=OSX{qIxS@FQW8M(j~D^=LPC`HlEpi@E*|XywyuyNOnQF2Z4{-+ zxQlo)$5-oKILoRI?{q@9_L`e+vqi(a_54Av8PKHr92Oaa-{0Dsp!m-vUy|efP?5MS z6Cp$*sA^K^@v}A~GJ<|=tMYyUF0zMCrPZF@{QP{I{KR2p>qzSI$qp{=!3wb7i(WUrQee$VRm9+y3jOgWe z#&4cR=UMl6-bPotiWk1J%Pp| z##bmm_KrAd2Y$uGxdMmv-8}DIt1}))bZ1tWRbGTF#&6;$^>US>P#}->tH0K!(dWir zKj;5>&U#QjDp{na_>RKO?*bxQq8RVO8f5>9jcoV!GacYq!TE6Mh6~3I?zqP}x3MwV z+OYBQD(15XgKje}+Vt^}cDtunRxkfq6$$D3l;$=Wi^>llom}e^rd*EIr#$T>N+OMs z8^_2(WPlP?Wy^UyK{Gto^w$0P-X`w78=O^B?zTe-8snF2T#~cHsPFt`6UH@k%E6%m>&4fpqw`s-a|)E)qZqj$ z-FGstB3gMGaxBSUq1kOQ!q`B6mC|Ha{L7WO0`rhJ^UN`d?FzqL#59gDpG>KntnMe- z#!H1#t-2r8WuIZ8$TX-sjU3p%A>TRjp>U(-i|$a+j?WNMAnDj)hM*5EDlNC8Y#`^w zH+~A2ZA61D0;8Gx|5>vr5QzxSUKS!y>JoH1;dP63jnr*-WqqnUq}CWzVcYB^DP&g0 zwdecX`?p`hzo^-}Fs5%OI#bdbJ>axe%5K%;(7&8WA!MzWKLiW7 z8%&we9+&d;Q6g&zrntD|e)-4V{DIK!aN}6({>$wGv-L#(8g9{f|GcM-CKTJ7L`U0X ztbVc~;y{w7l~Iy^T-+TiN56Y@5B0+yUZLc(?4S#@lT^H{zMWS?&KAO=JJv$8@fzBJ z<(Zo_%NZ^uAC!fts4XnR2pR1BlaW_?Yj_s!QlCYvLEFt*iNFdbEF*$N4p^xrX~bne zpdPof#&=xRH+s1JjAGIY%OCgYy)Elqcf{2@B@xSkeAhvK^@s=6Aa~Ft#WBM(ETx*l zJ&q1!|Dp1%qjxZ#G`~{j$0$2{(Yi6_-FfN?aouf_+ZyeaaDJ-(p-z~p?X$pp)Ksh2 zyT%k^4R0Ma<)OQN)N_fxVgD8n(2trStbZ=r+58x|h7C#G!cm{hF2$n#lzZ3l?Hm22 zhAS7oL*Tgrz28%!Rs#|LvP*tvj)tk&Zr&!XZL}peHv1_x*{Wdb`fZMTTyA4xC3N0q zUa1kweBd=iOy64q(xTrU@dVQ(1r$)id?)5kLH;$H#}o_5!gEDNv1$R$;!f0bDa}tDBc(=;clj-$V~I@luEyZW2ak>I=;=$ze&P zMuT4-gXrnmipS?Ljgk!xntRzI?tj8*F#~2ev&rDGEq~v$k+keU>VdW1+PtE%u~LcD z7_0j=lgpESmOjI^(=N}f6NNWUv=no@Pf`EK$Bv?{TEHD4l|6}^KqtbvU-?Z5 zOmzE0|7O0oP-6;=Fmb+1gUz=TBwb}m`@c^oE&C3Yf^(z6siPC7tHxEgSl=k<3gtS( zQo1Y0t-m+1BzDL^=dsploU|9CuU&PNC2S52o@bCtpFT886wKUG$bdc(=LOR<1FYb@$w78G2b7@_z(N>=gG3Id&h#LH9M9XWMxz{srBnq`Hy>BO6Vv$ZhP~V)Ay$rpZ&B} z`rtE6Ys;r|j7VmMsG~t{gFZbpq_Q7XuIlwMZVV)X>$;dm`mel@b)r;*}R{Im@; z^w)QTtMZ)a8`w3x0_USsi0y)jF5hK8o1L$g~39}O)ohgFLc5=0x1ZVE%M5sMGlP@9W^>=yE@B6 zv3KT~e`0nF^|sUOqWoWFR_fS-Z{EDyAI-8GLt%2CvpNdXj{G|QX*UyWl+L=K6rkc- zJ|%}8Q8pMKW|xkeBn)cS3!A;9*xO~Kamro8;8b4ggX?d2`gTr!CP^3!Kj6ff&6F<9D1wN7&u>N50D{^v=)vsgNw z*!GqX_;V_Xsq3sf5|^zs>@>5LJGfE1`A&t`@PKZ`<5|3(STDZ2uQ#$;exgED!I~(x z`!(D(L0L+ibQ@reTO064UI*22i#nYr?_JCZtT$*6T?k0Y-VfWmO`dg+7d0e3^N-HH zO1|kzP+hhzua>P`)}%72d7;hFMjzD1B62A8TfNmb!VoM>j=Pnx#jf~|%QNG=bm9ts z8SW+*HMweE7^;D$qae*u1)MC`upm6`K zD5-F&i&aNb%3(ac#5i5MIOW882I||RVSjIh9?JS+jluPN9(^ywuiAp5R(QqUEw&Wae+}ea%Ex32*^tWs=V2WYaqED>9IKHe=S}!EkTyUt~~% z3^t%tv@y*1_f7e-kx65c+d@}J(k$F7i?8mgw$X-6yf!4qNy`p*&b3eQdacl)UrZNc zFc>5g#y~U_h$8`+brpkhtJEFUI|?4yKt$;?brqH}GGU)CYm$(H-!kZjDA|{9(P4i# z4=!vF3xu3RPLb)!KSY*RcxyP7b#3mk=KZ$Ukv~{kJxlr&pS=XcsEbg8d`5HuNP!o1I=)2W02G^&wRG!e$}rlg6!OI~e5gquO;Nt(S;Kar-wsY;o^RIf@CNs1H%xGsLRmZ-1E8nDThdLEhBiDTh z6~WJ1DO~2ZVRO{Q+?%U+AYX?b1x5RZ7v4pSnj>vbn|62h?#aSZb4$q~@R=gc>y;_`VHL@`$$x&J=#IT>xm#$lF zxQ}`Fl`l!Ef<)~{+#8bU65Sev3M2!)!pnadXQC7!^u$MseD{Go$zx*fP3QY+FdPd5 zIk&r_raziv*Ak^y$FT+ycmy0GN(tNDwqrJwko={0t{lzNsEuf*1jBx;&;~DHeC1X11P*^)= zx7_w>I8{58CsFDFbunJA@?&hf8yVl#@jxlRbl0_v^)?n*(iiB@#ANg!rz##ftJ6k{)(uzNq)y%RB%S^%F{C z8?)s$VcN6;v`JSI*$7^d^^#UW2p+A~hTGFzUC5jAuREk;bfdycrlsk-urAXYy3)GP zIOB7hqXHF(QEPaUP{5uI{ll(NOA9d?;Q|Tud}Vr|P``X;cb_ZP&82)|>}p5}3l@E# zBDQ8TZS;a!r~UbBcE_(XVBdZJdwNpjN^9B8Z%r1Kok(0naWd#boKWbKSvE3=l_5>?!Xw8{lkHIRbR zL!EC9l(~>Qg_lR!8w!pOniqyCsaj1ZE{i4Ux)m>ZUC!in$yldeA+PJf1R0B3effOa z(jV0o0<~U#2{hY z`MIgaVjGN4|Fk}2x{v96P3#TKfmCYl`}O9c=Ia+A^<4w2@mooUHt}v{vqx%0oVG98 zs=v`;m)w`{)cwtw8r`t00wV#-VmL*0Ha`MRx!8#y!0%lb69b%^h`AaDC3M|3Ki4ZnZWqIfEoI@(6{D$+U%E$mM~~B zmExDdZPE`dET@x;#^%(8)$jBA+R(_30#UBFi?SdBkS@AAWj4GgR^!iTK>a4KBB}5G zSs$%x6K$VJ1yE)1vW5^l=;oX$>>Q;VqPPoPRu+MYsxIiMsOny=6)scnDKvOmBQ}}L z=GN9(*lszX2w|bE^w%Bwb;l1dTY7rJx1$e|v z)gHRZ=>B=rJOuD4nSk3pP;rDOy>cv6f~n`Tlo+X7RPlwB`8&$P>&6ds+p8GseDIr6 zNa(5C!y+m--Iw7~qp~?fN@EH`TDm9^msA1#CTT1{gAe7@PeI(F^U>tHGNra2MQc~X zB(WeUH{IoaxW^>PHNG_*rwS?U5t6XuZjL1*aWw^(OZgZK7&Cj*6z4=3wJp1^3k5uf z1L!+R_Di;P3HCl~mtF*hpwuB$%>4a01l#s*>hNY})k{8Jtub-zqQZJ&^XJl=l0#_e zr`j%!_}|(3##zIU;iz(Z)!8lY^ex#B3D_foFq-<9D;P4kzmmruvorIqif>FhDRH8N zj!aDy_FLQIz9p@QmYl6Ns+=j6>~W;H9_Orzasu<%79)Z`QCQ1e9@VTX*h!+Wz=vY$ zMyJ_{ml?m$GuD_P*Bng~a=4CzOGI)D#~O_6{5>rBnnC>71FJcppRG3&g%aA% z;!MBxV`#kV8Q-&}A&0R(-W%#NA+Wqj^RXpW?A_s-01Wl*MYWORVdRy2~jCUjf0)HN!=faWypGD|45DNXna@&d#RXzR;#RZy5o;x!L13;Y*>E+z$*fc3JC8iJ zuy)k0R#u$6rIk}U`)%1|=1zL8E|p{Oz*Y1-VeyGM#K)&vs=nT(a6UDc58P3B(I)H^Od{r1qvZl|YE z+Tv17W3HvuhL9jg{fa4-eG{KF)Hi<>pqyb3J6AOhgX?!@WtMlVpy&W@Wr|6lK{_BC#B8nQ{m^4!b z^F!8#oG{ZQQvu%(P%6J8#^*ZE5?#?hve@k#3h$FSX!paw`W9;&QEV9^CHe>o!fbvM z*V3-IO`=Q4GTObN@)$(l;EWN8jXnQ04NlRu(!=tn_LldMhD@z6acm6iDz&}>y#bEs z4*|!Iz=6UX6MfH52kd>PRz^nIxusmby}22$-l8^UsDEsMjG7Lc0y+5va-qo00qpT?}eQ&syBAt&VULi;SRKh6fRs6b%`q!GO ztm2;%SOeozTS==rnu{VC2^vlo`{{(l(iW;34KWAVw(m^PHMJBcGY1|XBRvw8B zig<$hc)lR%CL7sDj_DCS?HVCAAIlcMl|Zfk0!6kM?y5Rk?;Y)DGTDz6gmx9l4sB3} zxzrsw?6!c~DsmF>6d>bncIVfcmDh|8RLLF~yjO+t#GWyUI+a{R^LPy@6h)0^1VZe{KOmA`0KQ!nfV{AfB{auDrH9}Y%b z@*NGIwScHM%QzQ`jaF0@C=CV%-fOLPKh_(b;wnv2@6V+3HGThUCsc2Pnj20@6}HzG@2QuEc7S>SoF_hJT^UfH0p@Wr%PZDM^8+}qyD zll@zW*dHF-Ss6LFSg)q#){Nga{25%UIUUkOdkDON_kdmHSJI5TXQKK&z!QDhJ4!VPGq44m)%yFH6!)ok}i`dS4CV}g@;Kd|ER*N zPF^mE+vhjRlIv}O5&|45KIlPyhPdFM%a`rw8fz~m5I**NDbb=g%CXaMYvrPa?@k%%)$Vy$_r1ArPj*^@9)sI z3b?c(FoM)55o3y}9LzeDHr1sNS2Frtc(ID8P&oel_yT(5ZyZB&13W=*_>%06*YU=g zpZyR8e^%ld$j(6ZIM_Q=|7-jK(az*PY7f-E2%xsJ-x?eqVvgRtfRPn_2VXx<#=|gL<2+ONlYspmX_1!yRe=ZWjpwC5IPKIey2+rXv5Ph zHxOB@#$W5D?H;3=jQG(*>22Z%6v->pWAfB>OZeQCFZUipZfsVlgpMu;Ix8EEDPdbu zObe;AsG1BRs=o5_ta-!`XcFn@OB9^Hg&X1FN45TuFQGk2?|SfECdykCW%}>mSPyr! zP}Z)HxWELMWxv2|bm$$6M-eS}Esm8Aa>!vE z51N9oAsZZ{G@E<}UqA>jMzSf^Q~gGG3{KnqoMJw3m;}!{N#TPf1>W&{OzmGWAq$lu>YGWw*66r@yRrGpS@mF3 z_asjg5u%m(4+Sl6CXO$uvSDv^HaJY^-Z! ztB*T(Pk3p@zE~@mXapP91qEMez)CM)ItCRk?ZiZ)D(5Mt z^YY@aSW~g;&Bq>hW2l+fvf2WKzaj6LU6HV-ku>Ruui!9p;FQz9g~)D}WiWJ)JvWO! zT_#D@I|HxG`A4LUV8Q!CtZ0 z`--xD4+G@Jj}wF9Ti5!n9TU*6DR@OEDX&sZN*K!qj}59RqO*uM5-Ah&B@7wsr9~Uy z2kQ;4_l8nq^^X{FDn2A9EH(^pttRDADaI#pEkA}R)?N?PmA=*ol3qRS{*4MS8`=T2 zYW~R8XuVvo$#Sz~%j%f(3Yfg5tQBN7*<h`_Jjze*E|C4g(uO=d&WJ2gsLG_0#8xBA!Tz}7oD)u%-O&qH&zONk@1SutzYlaw{v1^8_K-=U+Z=&prWZ9u2THWpLt>i`VugXm!R8K zbW>D-A!PHA#l9a9p%=@&_`#~n1wP@KDnaqLrj)5o)1WlkXD|>+Dk2)T5j`o1BvfSmES1Kyt*ElAX61>i zIwF`Ns4qyv>P_9^n(2B`%$Dn<>is#56K=PIQocwzL=d;!$Heysf)smuxf;@-&U<5z zNkApwXs_JPa*X=-;|+fzU|3a&3eYlR%VT~Qg~?EKG${;6*KUM+Vmo8qjsm1+L{I}# zkbi*I3-E&qNPLExb54QtngtPMBG9g3?TZ?oqlk2MNe@JPU1C~AaZz_;hDK#B$%lix z!ed<7!smt*mIon&oZW_C?TTP+9z;QOHDOMInb*bXY_X%-8r%Ljnk2hC7{9yw;PJL~ zFy49(_sqfh_wJ>~|S1w<#Qu$nALbf^sMf# z4#bP#xt^YMu?D)08P!h?hpl~$p5JW~SSoRc_Cnf6*{y-=Lk~`v%d!(GT$@lC?_q${ zwxe}DcD$|pL(cAyIG-hQuSd7CiFTVryIyVObHCPQqDb;!a2w_5cBLE=#Z}QKOvFxk zNjGKocZbIx@fI(8@DQ6N8;v`P?oPZo7kmlak`v_O#jtp+Ecaxy68?I;_5MD16p1p_iUW#8@|=I@>_{l>RxXIdVSo%rHti;RU9 zUG7I5aX;$UFxe=(e{gZ3P}j`hG=E0|0+9h($fKS*YhhmDO~J44M{bgZKBR#qpTUo5 zScO6w9Q}?ix;XXLwD074m+ei?*b3#LvH}hZBz?ZDmPrBeleFUHU4r}Sb3^+pH_1H# zluJ@@xN03DOBu5PfEekC!(3+-xzTPZr7U|&cz)Zh%{!8A5;FDB;m-}7zGl>&TwB}1TlG^`FWqLzbQ<`fD zz+H_&Mq2(Y46x<8j;-!d6i;fD0^Q}%mV{#C+rrDi0}6WvayyJ|tllP$Kal_3z9`3_ z9sNPiGd{fohsk?ev})+iSjZsS5#f-aY*oF0+@#W`3M_+HWhgm9#HBB_@vG(45+rpgQ@&%G+9|NO6K-j-tgy;vkheOZy-7 z6~P|OrI8?anGn{u_^$VlcAkETI{kKa#-Yl`$P< zETt>EzXIwIn(>zP-ve;#X87onq<$)^LUM7ljH^{S=Rd9Kl!!e zaUXrVlH9B{zvIW9skMWSPp5!SqD#Ll0}cHhvkY;+mx8(W0zt_LEmoaNXK11nR|Ik9 zcJ$mZ#`au7FDU?f78?-guI2ouzF2LvYy#)gh%nhl8;@76s=Pq@dc1baU~iClm4LO= z+*LUZY015-fZpR=6MS3R$63BjJYPVt^;bLXy_1YbSNcp6APp2}q8{hfKlR4HDgax~ixZ&o@4sL{oj9tMoeK3lu?r85frd8SoRE0%1Qq!ahj+yy_xZlnT= z!tOi*w{xlpL4|lB{Vg<)8K!T>^7ZZgbqvo1992p8!gGiwTOnItrh2e#*aT<^ZOaiP~YCt*}RHlZ4r}(oy3$mv?#hEs0H#=Z%x)e z{uf)JwS4hnJb#WgP2?#kCp*XOSCVra>#LV|M3O>*Ku5Ib9qit%pglnqwZ&)A62d)# zZ>8jbk}CQGQQ;TA-H7ilR+#;L`a?!)l`#mn0Y)pMj(2Wk++{#%GsKnHMA|%{Ovrak zCHG3CRbEzBsXV%^uqNYZrLmmH{KiImehJ0KZXJ;v=@X7R-@qxOCYY(H#wzCm5z>5+ zxFl2Sa~pF!u)&x4Z{Idj&QI5UWSil5jPooa^4Ps-tUisd3BW6h*tMzgf6Gf$4A~N! zq!>2;KE827Lv-fqW1L`d9zo=NJABqNPz2;DCn1EiQQq=faz22L%1%opIk#%dkmghpdb%aVEN8R&sd{@y3Xm@ieXeH zuh=rtM}6fq>0X|v7NWGRj28J)x{VF@Gf05cq?{ZluDyU9 zbAVjC-RS4j(*FX2{n0p8E^etI^muP{>h0uv=1)(wmKHNWcZ{=k?;d6!J+?ZeHg97G zp;`U|E!O&AY0slvJ-U%5o>!5Wo$WD5vWSleFH(GP)%=ht!RC$=V%BQO0HSi@AQBZm zQJ<*al~SdHSZ)ImIy=6Mw3m>pn+9PBSMcNqMf=7x%f0LLn_w7fjkG@tXUwo|%$PRp}a0aF6)4>jY8x9%lfi}BSb7MHYcnjzBa%DE+xy$0(qw$ndD zO!&Jp^5d$fO3}|xlB){^;O3BE>R=vZKTpEhOzQ2{?K-igm*)b^1OiokP-})fd}`?{ zhnMHMX{@QvVmEp$5q`M->;tFcV~6EC46(@}%?+UePsOWIUur-*O92?tJo3-DE?t zZulrqoyC!`neGf8otg}-N{SrYm;tgbQg~?!jGa%ed*Cld{`H(_)T4%*?^Ci;L09{g zu5vti2c)kYdWXks6XN>@@C6OJZ!Z}m@iy5y1Zx6AAQ-0!h{j0u2dvxfH%d8v`?~HjbTbdO9Y&j&%G7?k@*O|jjg*oncjT!lBMQIXtXk#q zZQbL3(1)BVS)HSl)JPxE<0z)AHfJW^hb1r(N(Q;&NWIEQ{PO{N$?KAS6DYR`p>K&k zqw@hWX9B|N3zV@QWgaM7`*D+HVn`_LEQpPe0WbP72;Z9NhBja-k=s1qjT*24Q?Uq* zuyub|J*o9QyeyAYd!I?6^2z@R`R6NamR5ae<=DXZ>*r~!3;USd!JSkWtjc7Y1Yw7l zw62c6Xv|`p2JT^eSVh-gh53z8l!vmQ2&~g=a1ticJFm#AO{sTh#8n>0t+hk0d{n(Q z!+-vAU8D1Vr6#Jv4B^V1~%&88S*$Ri5blhT1M6WmQ5E0OYKxz*1!;l8M`y8bKB`XfE zJBewH41|2b+m(1y86dBOwnvs|LWF_4OZ8-B1>gHN(vD2xM!7up(|84TMQL^kSYlQ* zm%-*QRWj*QB}x4+97V+#$Xh62~~<)}{3vj0TKn8{n%&}ePpZgy9Al&2fF z+VsaOQjP~n>N}cTBXMhf;fe_H;CxG<1U5zqi^#j&Z=Tj8Y4Dk1oEGOs6Bl#g60q`>G|bS9oR0%LXCh?@~g1`IvZE6;pF7 zI4xnFM%pRPXK!(dtq$^TU?mFtgn&8?FkY7ipERY%g|S4P_u)De7}Uh@^mKh*eDkxB z{?m(Z4mh`rBWC z+1t9DhRn+ujUtu5`YN4<%;RlMBenJREEv)yw}mo&KYyx5-X*`bTT4e#qeL5Dl`? zG~$EUF~8IKqvXvxSSOyoXFdf9bH_qW6Ig*+am*8h-)v@?a~@Id;`yXpwD1e1$%)LI=VC)4NQdAxMaB8h@iAW284u zx2GrZ37!z`9ATB=_5}UoFNuOE`{=7VnZnr^kQxUv^Z`7Lepr)7(^gZyiTm*0-yc*+ zJ~FyWdyyBk-)U?bdG=8Pz!0&T6jk=xo@hX4WPiyOn`&*8h=e**iHU9$UA;& z3wxeSU8s19rk-*)34Xj|{yY-zihu(LS(qTsJbSr;DbuQc3-B+|_o_8a&dbHjI|yQr zThvt1tf#eBE>T0sS?g-hXh@@lStXqu+jkX`dT?A1KrcT=w=sw`tx1`F{|-d<_}#mr zX=OSrD|1P4izDbieJPf(vX?DRHqNVg|6}>ZPwK`DsLnU%(mz6YUH`nVYzjn7+5SPV zxq>if;s|1HvgurBKkU#*kA>6WK5gf4Xy8~J$Y>R%Ik+&^tjG0vfy!Oxg zJ?GT-&T7D7sUId?&rx&bSGWdf1$r-X(&}!lk&E{L8djM;o>FCDG% zAA6rl#DvmYm{GXrJjs(W6O6+x@xFgj*ipjdN` zP5|W#%bwE}n$aH06ZZy0TZ|BAT(}-kBF}ink2TGwaOmKJKR@r9@;SlVMe^8YMZpS1 zH9|@uJxs!YwY{G~L+HJsuYB<sj;)d z%-6l6ITY7wFu=OQ&eH~Osb4&nC#6cYu6ExJS%iH;PYiy4M?gE?ky%Du_4$h9Hc{L>-d13=sx3ZV&Iu$K2Fof-EHc(;-(az3TS_Tp#1_a<^ zijgW2Am6A#;kyHg?P_A`g0#Z8CiVX9RnJ~Gf&oK(>~o&m-7HV_rxFuY!)g8pu^}AP zb|}bRK;wSxOP!Wper9C6m7(&EQ+?HwcRnOs1#F1PV=g3YFCre3H2!)}iGfA<8EXG* zdbxN8vf9P^F3vHWR(9a1l5r~%Uvg!gy4vUC3lHB@jgwtupyBTxQ5r`D*+Ev1vGaPJ z8Vj|^ot_P&Awsl-0Pe=v6E8kopiO3>k3zM5t9I;j3E0tYO>ed}JpByMF+!Rr9(D!r zMeV~J)%7vGo$G|!z+t-A`_mD952v-%fD80VT!}hTbOGS%lO-=f#K z_9gL0BzhWZ$7Sjt^r+)I(fpE#sk|vKG1XT(f2(^_V#t7r<&g@S%q*BIE=I>nCH+j< zw(&XF9g*^!a*_oiT4-asISqHyqs5ER$a!E}q$f@g!)Wt60|f>r<_y}53@jWqF!Gi1dvyM^HbjXBj`jaygt{Hu^$QDfYmTj ziGUczBDxTh;vkaID~tJ{$J+#p(hirGS_uI*E`h{^8DTLC+|KtHSGgCiv~pHDA8%+R z#iBCeA*#A7kqQNy>sI>VG~{>B_FS4)e!8gMPhC%1P`G9K=2TSWU-GLKo<&C(N$}Iy zJa+Qd7U22!MTj7z-4UGi4TEyTJmco-A>u>-zfml)R%BWj#exmlqly)k`;k#O@XaA~ zy762I%QQBI2`#Cm6~-qr@WRF804 zF5&I1h3I>!$I+H{i04={Vbw+&3KRZQU}w&@0c>6G_0DFHkXFI{5v?4KzQ%8b5auF5 z4}*`;z%yK-5hyPmrCW-T{F!bLDAkFF^vMpMYe7!Stve32)aK{RLx;?(wOM+t_^R}lfnm=CP)1{#n- z=AXEZ8J|S@J#p9XQCiZgHGa4Cx|Gc>@3e?mM8XH-E>k4)x6@9s5`Nzmv8a}o`n%&K zfb30qaLi@`N#t9{&{Do>G_#vYY`_Y>dhmyIixzPc$0U2V&97;%pqk?Z3uSjthz_~H z_p>GnjgdM)$_vsDHri*jmt+IuZortY^s;@4A3aKw<3E(Eo+XQXzK6fQP_?t|^{3au zw4aoYmLerP>9-Q?z?m`JoJh5#XPt{r>K2TH%66+W3^a!zXuvMoD_ZtU-TX;FMzW%6 zKDV=0Pivi=D($wTU``xY1ep`3ZM<~mOGw0|&VF1o{a#`AE{$b_evJ#EH5}wN2@3R| zQWP1P9sZ5B>Swt1+Cbi99ITLm!OEKP&YmGNI0L)DKOo+gziiF?o}0tid&%6*q4Tuu zAi$U@_Hum55Lvp7egnk*syR;~V&sZ@vf?*?xnFT5;iK)6t}4pRBi0ZR<4{p8g<~mV3UU4eI$@6_$Tpk7Uy|gA4q<6^brp zaET`eziD;p?X)%NCdRY0kSD;((386jBjt9vUl+@QrsKOMUFIXfQgQl;huJ#4)&~YZ z4BU?KU_`msv|8?o$F&MC`y75AT0&ZS6%|;z&znf{+2lBII2snqp%tpzg+;`rYm3}P z3jRL2A19qO73O9#OrTcdg2+J(Oz%|~w1}O*#^>^g2paVZMQi&n+`GH!9T% zYZkaw0^r_;m@06*9t;OMV&~K zzj~SED(4k`NBf#{(#Wtcr^DU(yOqL8*qUUsbJLi|mwoM<6Z(OxsWb z&zIBtW%A0pB#XW=p$*sA@9gJlmPg{e_romeSzBx|J1?IDOE-S+`o{A6w4drr9u?>s9f!-M8BU^PY_#da)JUbmc?{*- z%YL`$_1--t-EY#yr5Il@#HEe14w$^1M*ST##>+zmyPDO2%A9=WOTDJN?IDT8~x7@00)F%|{KXY5Pw3!4lX#0!# zBF723l0`DXq@dpZfPLPgpB=Z3vo&<-u>JikO! zMe5RTd&GQ$hmnWJ)`Mf`QZU_^VkG^PF^G5m%Ys<3)T()Tv^Yu$Vyde~Lp`Yl*SyP@ z3-1p;R6G-m*7VHbajB?w+K%AIbG6fltI;kTfg!wgS`AIi--s#UT{^UfiU~?3UO1{l z@}P|$)CFm&1eu&&Ojna6RZ3e$%6FpJCe$ceghrl3ejg@7?0xyrxi9N-&>_?g?6?{O zJd;x@-!RQ!i-X4p9f{mO^sRtWZ*Oq~lVBdRG}p@$Ss1(&B+qj_>rljeLWLJ)Zo4uU zWsNM<;e5A8euzp>RCk*7P19s0QAHq75OrB;;LX@Iw5JOZLT;t-8J#yf!q;x|nv+ZX zi_}I};fX?^oDBzWGCpwKwP4E;iQBOkdp%3)x z`pi9*`DcpRgpBInE1Pli5P)>z#bXP8)VYueVm%h%g>6!^%gdaV_L6ACE`GW%Eb~V) zHqTq80i1#7gf9d-a_y!ZU|WuEI;~wZ2^v{}E9SpvoL~cgYW~kYa6^OAKLI$e0cxm% z)}*|>v-7eyTk+FVsld~Y7Wx7=e}X2GkdJ>tnwbZNQ-wHat6}U7&=p>WODipb7p=xd z><7;9F8Q)6h6n0O;QTiyZ2lXVj;C<1VE%WATmJ@dH*N;7zNxvietq_fs9kDv|1T*n zhU%FFI^<``hN2*dD}&TO09Vl61nwct0Z!_Jj!$ff+W~4|15e5k?^OV9-{7=0R|k)> z18ukh+CCeVzYd%_8XPXMfHpdSZtQ*wy4VFcVSl`It}~zIkzZe4CWixWsGO=3X|%4m z5Zp}$dTL2Kup)mM$o~X#@?2vJ=N{l;1HkGVc>kc`i< zWI01M!;#{c+KufRw<^zvZTvq>nCg>a{P{RmxWE1FCH{i_cz0cSG0GB6%tE!X< zK!-|(%oKs%Vav?9;xp*j8(@Ua{=FG^K^f>kNuOp0Xyq$#q6XBe+ux_D2sHpw;Qk7>A1kFqd(J7`Z}K-jjMhg>Y89Q2b mk5-lN(s8t^B)+O_{Lg;o&>y}zXG1PA0D-5gpUXO@geCxDi{}^s literal 0 HcmV?d00001 diff --git a/packages/mobile/capacitor.config.ts b/packages/mobile/capacitor.config.ts new file mode 100644 index 0000000000..d397caffeb --- /dev/null +++ b/packages/mobile/capacitor.config.ts @@ -0,0 +1,33 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.openchamber.app', + appName: 'OpenChamber', + webDir: 'dist', + server: { + androidScheme: 'https', + }, + plugins: { + Keyboard: { + // 'none' leaves the WebView at full height; the UI follows the keyboard + // itself via the --oc-keyboard-inset CSS variable driven by keyboardWillShow + // (see useNativeMobileChrome). The built-in 'native' resize lands only after + // the keyboard animation finishes, which looked like a ~1.5s lag. + resize: 'none', + resizeOnFullScreen: true, + autoBackdropColor: 'dom', + }, + StatusBar: { + overlaysWebView: true, + style: 'DEFAULT', + }, + PushNotifications: { + // Never display an APNs alert while the app is foreground. The server always sends + // (no racy visibility gate); iOS suppresses the foreground banner, so there is no + // notification when the app is active. Background pushes are shown by iOS as usual. + presentationOptions: [], + }, + }, +}; + +export default config; diff --git a/packages/mobile/ios/.gitignore b/packages/mobile/ios/.gitignore new file mode 100644 index 0000000000..f47029973b --- /dev/null +++ b/packages/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/packages/mobile/ios/App/App.xcodeproj/project.pbxproj b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..94498243eb --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,738 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */; }; + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A1 /* WidgetShared.swift */; }; + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A2 /* OpenChamberWidgets.swift */; }; + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A3 /* OpenChamberControl.swift */; }; + D0A2000000000000000000B4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A4 /* Assets.xcassets */; }; + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D0B2000000000000000000B1 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A1 /* NotificationService.swift */; }; + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = AppIcon.icon; path = ../../../electron/resources/icons/AppIcon.icon; sourceTree = SOURCE_ROOT; }; + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; + D0A1000000000000000000A1 /* WidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetShared.swift; sourceTree = ""; }; + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberWidgets.swift; sourceTree = ""; }; + D0A1000000000000000000A3 /* OpenChamberControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenChamberControl.swift; sourceTree = ""; }; + D0A1000000000000000000A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + D0A1000000000000000000A5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberWidget.entitlements; sourceTree = ""; }; + D0A1000000000000000000A7 /* OpenChamberWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + D0B1000000000000000000A1 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + D0B1000000000000000000A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenChamberNotificationService.entitlements; sourceTree = ""; }; + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenChamberNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C2 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXContainerItemProxy section */ + D0A4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0A4000000000000000000D1; + remoteInfo = OpenChamberWidget; + }; + D0B4000000000000000000D3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0B4000000000000000000D1; + remoteInfo = OpenChamberNotificationService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + D0A3000000000000000000C4 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0A2000000000000000000B5 /* OpenChamberWidget.appex in Embed App Extensions */, + D0B2000000000000000000B5 /* OpenChamberNotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXGroup section */ + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { + isa = PBXGroup; + children = ( + AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 504EC3061FED79650016851F /* App */, + D0A6000000000000000000F1 /* OpenChamberWidget */, + D0B6000000000000000000F1 /* OpenChamberNotificationService */, + 504EC3051FED79650016851F /* Products */, + 7F8756D8B27F46E3366F6CEA /* Pods */, + 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + D0A1000000000000000000A7 /* OpenChamberWidget.appex */, + D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */, + ); + name = Products; + sourceTree = ""; + }; + D0A6000000000000000000F1 /* OpenChamberWidget */ = { + isa = PBXGroup; + children = ( + D0A1000000000000000000A1 /* WidgetShared.swift */, + D0A1000000000000000000A2 /* OpenChamberWidgets.swift */, + D0A1000000000000000000A3 /* OpenChamberControl.swift */, + D0A1000000000000000000A4 /* Assets.xcassets */, + D0A1000000000000000000A5 /* Info.plist */, + D0A1000000000000000000A6 /* OpenChamberWidget.entitlements */, + ); + path = OpenChamberWidget; + sourceTree = ""; + }; + D0B6000000000000000000F1 /* OpenChamberNotificationService */ = { + isa = PBXGroup; + children = ( + D0B1000000000000000000A1 /* NotificationService.swift */, + D0B1000000000000000000A2 /* Info.plist */, + D0B1000000000000000000A3 /* OpenChamberNotificationService.entitlements */, + ); + path = OpenChamberNotificationService; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + D0C1000000000000000000A1 /* PrivacyInfo.xcprivacy */, + 8E7A4F192C4B4C749E0A1001 /* AppIcon.icon */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; + 7F8756D8B27F46E3366F6CEA /* Pods */ = { + isa = PBXGroup; + children = ( + FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, + AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + D0A3000000000000000000C4 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D0A4000000000000000000D2 /* PBXTargetDependency */, + D0B4000000000000000000D2 /* PBXTargetDependency */, + ); + name = App; + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; + D0A4000000000000000000D1 /* OpenChamberWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */; + buildPhases = ( + D0A3000000000000000000C1 /* Sources */, + D0A3000000000000000000C2 /* Frameworks */, + D0A3000000000000000000C3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberWidget; + productName = OpenChamberWidget; + productReference = D0A1000000000000000000A7 /* OpenChamberWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; + D0B4000000000000000000D1 /* OpenChamberNotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */; + buildPhases = ( + D0B3000000000000000000C1 /* Sources */, + D0B3000000000000000000C2 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OpenChamberNotificationService; + productName = OpenChamberNotificationService; + productReference = D0B1000000000000000000A7 /* OpenChamberNotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 2700; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + D0A4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + D0B4000000000000000000D1 = { + CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + D0A4000000000000000000D1 /* OpenChamberWidget */, + D0B4000000000000000000D1 /* OpenChamberNotificationService */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + D0C2000000000000000000B1 /* PrivacyInfo.xcprivacy in Resources */, + 8E7A4F1A2C4B4C749E0A1001 /* AppIcon.icon in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B4 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + D0A2000000000000000000B6 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0A3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0A2000000000000000000B1 /* WidgetShared.swift in Sources */, + D0A2000000000000000000B2 /* OpenChamberWidgets.swift in Sources */, + D0A2000000000000000000B3 /* OpenChamberControl.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D0B3000000000000000000C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B2000000000000000000B1 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D0A4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0A4000000000000000000D1 /* OpenChamberWidget */; + targetProxy = D0A4000000000000000000D3 /* PBXContainerItemProxy */; + }; + D0B4000000000000000000D2 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0B4000000000000000000D1 /* OpenChamberNotificationService */; + targetProxy = D0B4000000000000000000D3 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = OpenChamber; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0A5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0A5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberWidget/OpenChamberWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + D0B5000000000000000000E2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + D0B5000000000000000000E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = OpenChamberNotificationService/OpenChamberNotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5J7WJGPA2Q; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = OpenChamberNotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.openchamber.app.OpenChamberNotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0A5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0A5000000000000000000E2 /* Debug */, + D0A5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + D0B5000000000000000000E1 /* Build configuration list for PBXNativeTarget "OpenChamberNotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0B5000000000000000000E2 /* Debug */, + D0B5000000000000000000E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme new file mode 100644 index 0000000000..f57acce6a6 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme new file mode 100644 index 0000000000..427e1b9067 --- /dev/null +++ b/packages/mobile/ios/App/App.xcodeproj/xcshareddata/xcschemes/OpenChamberWidget.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..b301e824b3 --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/packages/mobile/ios/App/App.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/mobile/ios/App/App/App.entitlements b/packages/mobile/ios/App/App/App.entitlements new file mode 100644 index 0000000000..14e33ec302 --- /dev/null +++ b/packages/mobile/ios/App/App/App.entitlements @@ -0,0 +1,18 @@ + + + + + + aps-environment + development + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/App/AppDelegate.swift b/packages/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000000..d93645550a --- /dev/null +++ b/packages/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,162 @@ +import UIKit +import Capacitor +import UserNotifications +import WidgetKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + // Forward APNs registration to Capacitor so @capacitor/push-notifications can + // deliver the device token / error to the JS `registration` / `registrationError` + // listeners. Required because this app uses a custom AppDelegate (not the stock + // Capacitor template, which already posts these notifications). + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + +} + +// iOS 26 (TN3187) requires apps built with the latest SDK to adopt the UIScene +// lifecycle. Capacitor 7's template still uses the legacy window setup, so we host a +// minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController) +// and forwards deep links / universal links into Capacitor's delegate proxy. +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + let window = UIWindow(windowScene: windowScene) + let storyboard = UIStoryboard(name: "Main", bundle: nil) + window.rootViewController = storyboard.instantiateInitialViewController() + self.window = window + window.makeKeyAndVisible() + + configureWebViewChrome() + + if let urlContext = connectionOptions.urlContexts.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + if let userActivity = connectionOptions.userActivities.first { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Re-assert in case the WebView wasn't ready at scene-connect time, or the + // effect was re-enabled while backgrounded. + configureWebViewChrome() + + // Clear the app-icon badge whenever the app becomes active. The server sends + // an absolute badge count (sessions needing attention) on each push; once the + // user is looking at the app, the in-app indicators take over, so reset to 0. + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) + } else { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + + // Refresh the widgets' session overview now that the WebView is loaded and state is fresh. + writeWidgetSnapshot() + } + + func sceneWillResignActive(_ scene: UIScene) { + // Capture the latest session overview before the app leaves the foreground, so the + // home/lock-screen/Control Center widgets reflect what the user just saw. + writeWidgetSnapshot() + } + + private static let widgetAppGroup = "group.com.openchamber.app" + private static let widgetSnapshotKey = "widgetSnapshot" + + /// Pulls the session overview JSON from the web layer (window.__OPENCHAMBER_WIDGET_SNAPSHOT__), + /// stores it in the shared App Group, and reloads the widget timelines. localStorage/stores + /// aren't reachable from the widget process, so this is how the bundled UI feeds the widgets — + /// no server involved. Failures are ignored so a transient read never clobbers a good snapshot. + private func writeWidgetSnapshot() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + let js = "(typeof window.__OPENCHAMBER_WIDGET_SNAPSHOT__ === 'function') ? window.__OPENCHAMBER_WIDGET_SNAPSHOT__() : null" + webView.evaluateJavaScript(js) { result, _ in + guard let json = result as? String, !json.isEmpty, + let defaults = UserDefaults(suiteName: SceneDelegate.widgetAppGroup) else { return } + // Only write + reload when the overview actually changed. We write this on every + // scene activate/resign; reloading WidgetCenter every time burns the WidgetKit + // reload budget and leaves some widgets stale (the snapshot no longer contains a + // per-call timestamp, so identical overviews compare equal). + if defaults.string(forKey: SceneDelegate.widgetSnapshotKey) == json { return } + defaults.set(json, forKey: SceneDelegate.widgetSnapshotKey) + WidgetCenter.shared.reloadAllTimelines() + } + } + + /// iOS 26 (Liquid Glass) automatically applies a "scroll edge effect" — a blur + + /// appearance-coloured dim — to the top/bottom of a scroll view beneath the system + /// bars. On the full-screen WKWebView that renders as a dark band behind the status + /// bar in Dark Mode (independent of the in-app theme). Hide it so the web content + /// (which paints its own themed background) is what shows under the status bar. + private func configureWebViewChrome() { + guard let bridge = window?.rootViewController as? CAPBridgeViewController, + let webView = bridge.webView else { return } + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + if #available(iOS 26.0, *) { + webView.scrollView.topEdgeEffect.isHidden = true + webView.scrollView.bottomEdgeEffect.isHidden = true + } + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, open: urlContext.url, options: [:]) + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + _ = ApplicationDelegateProxy.shared.application(UIApplication.shared, continue: userActivity) { _ in } + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..adf6ba01dbe256605c5152ac1fd78ae99aaa2a8d GIT binary patch literal 110522 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 C`SD)> literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..9b7d382dce --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..da4a164c91 --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000000..d7d96a67c0 --- /dev/null +++ b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/packages/mobile/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..e7ae5d7802 --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Base.lproj/Main.storyboard b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..b44df7be8f --- /dev/null +++ b/packages/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist new file mode 100644 index 0000000000..3ffde0d620 --- /dev/null +++ b/packages/mobile/ios/App/App/Info.plist @@ -0,0 +1,92 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + ITSAppUsesNonExemptEncryption + + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + OpenChamber connects to OpenChamber servers on your local network. + NSCameraUsageDescription + OpenChamber uses the camera to scan a server's pairing QR code. + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + com.openchamber.app.deeplink + CFBundleURLSchemes + + openchamber + + + + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..71a204d67e --- /dev/null +++ b/packages/mobile/ios/App/App/PrivacyInfo.xcprivacy @@ -0,0 +1,30 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + NSPrivacyCollectedDataTypes + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + C56D.1 + + + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist new file mode 100644 index 0000000000..51328fd309 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamberNotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift new file mode 100644 index 0000000000..ed29076a21 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/NotificationService.swift @@ -0,0 +1,71 @@ +import UserNotifications +import WidgetKit + +/// Runs on every incoming push that carries `mutable-content: 1` — even when the app is closed +/// — and refreshes the widgets' shared snapshot so the home/lock-screen attention count and +/// unread dot stay current without the app having to foreground. It makes NO network calls: +/// it reads the count the server already put in `aps.badge` and the `sessionId` from the push, +/// updates the App Group snapshot the app wrote, and reloads the widget timelines. The app +/// still overwrites the snapshot with the authoritative full list on its next foreground. +class NotificationService: UNNotificationServiceExtension { + private static let appGroup = "group.com.openchamber.app" + private static let snapshotKey = "widgetSnapshot" + + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttempt: UNMutableNotificationContent? + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + self.bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent + + refreshWidgetSnapshot(from: request) + + // Deliver the notification unchanged (we only used the push to refresh widgets). + contentHandler(bestAttempt ?? request.content) + } + + override func serviceExtensionTimeWillExpire() { + if let handler = contentHandler { + handler(bestAttempt ?? UNNotificationContent()) + } + } + + private func refreshWidgetSnapshot(from request: UNNotificationRequest) { + guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return } + + var snapshot: [String: Any] = [ + "attentionCount": 0, + "recentSessions": [], + ] + if let json = defaults.string(forKey: Self.snapshotKey), + let data = json.data(using: .utf8), + let stored = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + snapshot = stored + } + + // Attention count: authoritative server value carried in aps.badge. + if let badge = request.content.badge as? Int { + snapshot["attentionCount"] = badge + } + + // Mark the pushed session unread in the existing recent list (best-effort; the full + // list/titles only refresh when the app next foregrounds). + if let sessionId = request.content.userInfo["sessionId"] as? String, + var sessions = snapshot["recentSessions"] as? [[String: Any]] { + for index in sessions.indices where sessions[index]["id"] as? String == sessionId { + sessions[index]["unread"] = true + } + snapshot["recentSessions"] = sessions + } + + if let data = try? JSONSerialization.data(withJSONObject: snapshot), + let json = String(data: data, encoding: .utf8) { + defaults.set(json, forKey: Self.snapshotKey) + } + + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements new file mode 100644 index 0000000000..149617ae03 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberNotificationService/OpenChamberNotificationService.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json new file mode 100644 index 0000000000..03c3f191f8 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/Contents.json @@ -0,0 +1,13 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "oclogo-symbol.svg", + "idiom" : "universal", + "rendering-intent" : "template" + } + ] +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg new file mode 100644 index 0000000000..e7ac3b374f --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Assets.xcassets/OCLogoSymbol.symbolset/oclogo-symbol.svg @@ -0,0 +1,55 @@ + + + + + + Small + Medium + Large + + + Ultralight + Regular + Black + Template v.3.0 + + https://github.com/swhitty/SwiftDraw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/mobile/ios/App/OpenChamberWidget/Info.plist b/packages/mobile/ios/App/OpenChamberWidget/Info.plist new file mode 100644 index 0000000000..3eb8c038e4 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + OpenChamber + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift new file mode 100644 index 0000000000..93f345f8b9 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberControl.swift @@ -0,0 +1,38 @@ +import AppIntents +import SwiftUI +import WidgetKit + +// Control Center control (iOS 18+): tap the OpenChamber logo to start a new session. +// +// IMPORTANT: this file is a member of BOTH the app target and the widget extension target. +// iOS requires the control's AppIntent to exist in the app target too, otherwise tapping the +// control can't open the app (the tap does nothing). It's kept self-contained (inline URL, no +// dependency on the widget's shared code) so it compiles cleanly in the app target. +@available(iOS 18.0, *) +struct OpenChamberNewSessionControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration(kind: "OpenChamberNewSessionControl") { + ControlWidgetButton(action: OpenNewSessionIntent()) { + // Custom symbol is referenced via `image:` (the asset-catalog symbol path; + // `systemImage:` only finds Apple's system SF Symbols → shows a "?"). The glyph + // uses bold strokes so it stays visible at the control's small, tinted size — + // thin strokes rendered blank. + Label("New Session", image: "OCLogoSymbol") + } + } + .displayName("New Session") + .description("Start a new OpenChamber session.") + } +} + +@available(iOS 18.0, *) +struct OpenNewSessionIntent: AppIntent { + static let title: LocalizedStringResource = "New OpenChamber Session" + static let openAppWhenRun: Bool = true + static let isDiscoverable: Bool = true + + @MainActor + func perform() async throws -> some IntentResult & OpensIntent { + return .result(opensIntent: OpenURLIntent(URL(string: "openchamber://new")!)) + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements new file mode 100644 index 0000000000..3fc5495fd1 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidget.entitlements @@ -0,0 +1,11 @@ + + + + + + com.apple.security.application-groups + + group.com.openchamber.app + + + diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift new file mode 100644 index 0000000000..51122f3ce8 --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift @@ -0,0 +1,321 @@ +import SwiftUI +import WidgetKit + +// MARK: - Medium home-screen widget: recent sessions (left) + quick actions (right) + +struct OverviewWidgetView: View { + let entry: OverviewEntry + + var body: some View { + HStack(alignment: .center, spacing: 16) { + sessionsColumn + actionsGrid + } + } + + private var sessionsColumn: some View { + VStack(alignment: .leading, spacing: 0) { + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } else { + ForEach(entry.snapshot.recentSessions.prefix(4)) { session in + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 8) { + // Every row shows a same-size dot so titles align: a filled orange + // dot for unread, a hollow grey ring for read. + unreadIndicator(session.unread) + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + // Each row claims an equal share of the height → even distribution, no gap. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .foregroundStyle(.primary) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + @ViewBuilder + private func unreadIndicator(_ unread: Bool) -> some View { + if unread { + Circle() + .fill(Color.orange) + .frame(width: 7, height: 7) + } else { + Circle() + .strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + .frame(width: 7, height: 7) + } + } + + private var actionsGrid: some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + actionButton(systemImage: "plus", url: WidgetDeepLink.newSession()) + actionButton(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + } + HStack(spacing: 16) { + actionButton(systemImage: "server.rack", url: WidgetDeepLink.instances()) + actionButton(systemImage: "gearshape", url: WidgetDeepLink.settings()) + } + } + .frame(maxHeight: .infinity) + } + + private func actionButton(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .medium)) + .frame(width: 56, height: 56) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct OverviewWidget: Widget { + let kind = "OpenChamberOverview" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + OverviewWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("OpenChamber") + .description("Recent sessions and quick actions.") + .supportedFamilies([.systemMedium]) + } +} + +// MARK: - Small home-screen widget: New Session + quick actions + +struct QuickActionsWidgetView: View { + var body: some View { + VStack(spacing: 10) { + // Wide primary button: New Session. + Link(destination: WidgetDeepLink.newSession()) { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 26, height: 26) + Text("Chat") + .font(.title3) + .fontWeight(.semibold) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Capsule()) + } + .foregroundStyle(.primary) + + // Two round secondary actions. + HStack(spacing: 10) { + quickCircle(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + quickCircle(systemImage: "server.rack", url: WidgetDeepLink.instances()) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func quickCircle(systemImage: String, url: URL) -> some View { + Link(destination: url) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .medium)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } +} + +struct QuickActionsWidget: Widget { + let kind = "OpenChamberQuickActions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + QuickActionsWidgetView() + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Quick Actions") + .description("New session, status and instances.") + .supportedFamilies([.systemSmall]) + } +} + +// MARK: - Large home-screen widget: full session list with project labels + +struct SessionsWidgetView: View { + let entry: OverviewEntry + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + if entry.snapshot.recentSessions.isEmpty { + Text("No sessions yet") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(spacing: 0) { + ForEach(entry.snapshot.recentSessions.prefix(6)) { session in + row(session) + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + private var header: some View { + HStack(spacing: 8) { + CubeLogoView() + .frame(width: 20, height: 20) + Text("Sessions") + .font(.headline) + Spacer(minLength: 0) + if entry.snapshot.attentionCount > 0 { + Text("\(entry.snapshot.attentionCount)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + } + Link(destination: WidgetDeepLink.newSession()) { + Image(systemName: "plus") + .font(.system(size: 15, weight: .semibold)) + .frame(width: 30, height: 30) + .background(.quaternary, in: Circle()) + } + .foregroundStyle(.primary) + } + } + + private func row(_ session: WidgetSession) -> some View { + Link(destination: WidgetDeepLink.session(session.id)) { + HStack(spacing: 10) { + Group { + if session.unread { + Circle().fill(Color.orange) + } else { + Circle().strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1.5) + } + } + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(session.title.isEmpty ? "Untitled" : session.title) + .font(.subheadline) + .fontWeight(session.unread ? .semibold : .regular) + .lineLimit(1) + if let project = session.project, !project.isEmpty { + Text(project) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 7) + } + .foregroundStyle(.primary) + } +} + +struct SessionsWidget: Widget { + let kind = "OpenChamberSessions" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + SessionsWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Sessions") + .description("Recent sessions with their project.") + .supportedFamilies([.systemLarge]) + } +} + +// MARK: - Lock Screen: logo → new session + +struct LockNewSessionView: View { + var body: some View { + ZStack { + AccessoryWidgetBackground() + CubeLogoView() + .padding(7) + } + .widgetURL(WidgetDeepLink.newSession()) + } +} + +struct LockNewSessionWidget: Widget { + let kind = "OpenChamberLockNew" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { _ in + LockNewSessionView() + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("New Session") + .description("Start a new OpenChamber session.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Lock Screen: attention counter + +struct LockAttentionView: View { + let entry: OverviewEntry + + var body: some View { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(entry.snapshot.attentionCount)") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + Image(systemName: "bell.badge") + .font(.system(size: 10)) + } + } + .widgetURL(WidgetDeepLink.attention()) + } +} + +struct LockAttentionWidget: Widget { + let kind = "OpenChamberLockAttention" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: OverviewProvider()) { entry in + LockAttentionView(entry: entry) + .containerBackground(.clear, for: .widget) + } + .configurationDisplayName("Needs Attention") + .description("How many sessions need attention.") + .supportedFamilies([.accessoryCircular]) + } +} + +// MARK: - Bundle + +@main +struct OpenChamberWidgetBundle: WidgetBundle { + var body: some Widget { + OverviewWidget() + SessionsWidget() + QuickActionsWidget() + LockNewSessionWidget() + LockAttentionWidget() + if #available(iOS 18.0, *) { + OpenChamberNewSessionControl() + } + } +} diff --git a/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift new file mode 100644 index 0000000000..e98e08e5ab --- /dev/null +++ b/packages/mobile/ios/App/OpenChamberWidget/WidgetShared.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WidgetKit + +// MARK: - Shared model + App Group reader + +/// One row of the session overview the app writes to the shared App Group. +/// Mirrors MobileWidgetSession in packages/ui/src/apps/mobileWidgetSnapshot.ts. +struct WidgetSession: Codable, Identifiable, Hashable { + let id: String + let title: String + let unread: Bool + /// Project label for the session's directory. Optional so snapshots written before this + /// field existed still decode. + var project: String? +} + +/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the +/// JSON the app stores decodes directly. +struct WidgetSnapshot: Codable { + let attentionCount: Int + let recentSessions: [WidgetSession] + + static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: []) +} + +enum WidgetStore { + static let appGroup = "group.com.openchamber.app" + static let snapshotKey = "widgetSnapshot" + + /// Reads the latest snapshot the app persisted. Returns `.empty` when nothing has been + /// written yet (fresh install / app never foregrounded) so widgets render a clean state. + static func load() -> WidgetSnapshot { + guard let defaults = UserDefaults(suiteName: appGroup), + let json = defaults.string(forKey: snapshotKey), + let data = json.data(using: .utf8), + let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .empty + } + return snapshot + } +} + +// MARK: - Deep links (mirror packages/ui/src/apps/deepLinks.ts) + +enum WidgetDeepLink { + static func newSession() -> URL { URL(string: "openchamber://new")! } + static func attention() -> URL { URL(string: "openchamber://sessions?filter=attention")! } + static func status() -> URL { URL(string: "openchamber://status")! } + static func settings() -> URL { URL(string: "openchamber://settings")! } + static func changes() -> URL { URL(string: "openchamber://changes")! } + static func files() -> URL { URL(string: "openchamber://view/files")! } + static func instances() -> URL { URL(string: "openchamber://view/instances")! } + static func session(_ id: String) -> URL { + let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id + return URL(string: "openchamber://session/\(encoded)") ?? newSession() + } +} + +// MARK: - Timeline provider + +struct OverviewEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct OverviewProvider: TimelineProvider { + func placeholder(in context: Context) -> OverviewEntry { + OverviewEntry(date: Date(), snapshot: .empty) + } + + func getSnapshot(in context: Context, completion: @escaping (OverviewEntry) -> Void) { + completion(OverviewEntry(date: Date(), snapshot: WidgetStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // The app/NSE reload timelines (WidgetCenter) when the snapshot changes, but with several + // widgets sharing the app's WidgetKit reload budget iOS can refresh them unevenly and + // leave one stale. Ask for a periodic refresh too so every widget independently re-reads + // the shared snapshot and converges to the latest state (budget permitting). + let entry = OverviewEntry(date: Date(), snapshot: WidgetStore.load()) + let nextRefresh = Date().addingTimeInterval(10 * 60) + completion(Timeline(entries: [entry], policy: .after(nextRefresh))) + } +} + +// MARK: - Logo (full OpenChamber mark drawn from the SVG) + +/// The OpenChamber logo, drawn to match packages/web/public/logo-dark-512x512.svg: an +/// isometric cube with translucent face fills, stroked edges, and the OpenCode mark on the +/// top face. Faces use low-opacity `.primary` so the system tint on the Lock Screen / Control +/// Center reads as a translucent fill (no colour) rather than a flat wireframe. Coordinates are +/// the SVG inner group (range x:-41.568…41.568, y:-48…48). +struct CubeLogoView: View { + var body: some View { + Canvas { context, size in + let halfW: CGFloat = 41.568 + let halfH: CGFloat = 48 + let scale = min(size.width / (halfW * 2), size.height / (halfH * 2)) + let cx = size.width / 2 + let cy = size.height / 2 + let lineWidth = max(1.5, 3 * scale) + + // Cube coordinate → canvas point. + func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: cx + x * scale, y: cy + y * scale) } + // OpenCode-mark local coordinate → canvas point (SVG: matrix(0.866,0.5,-0.866,0.5,0,-24) · scale(0.75)). + func m(_ x: CGFloat, _ y: CGFloat) -> CGPoint { + let s: CGFloat = 0.75 + let mx = 0.866 * s * x - 0.866 * s * y + let my = 0.5 * s * x + 0.5 * s * y - 24 + return p(mx, my) + } + + var left = Path() + left.move(to: p(0, 0)); left.addLine(to: p(-halfW, -24)); left.addLine(to: p(-halfW, 24)); left.addLine(to: p(0, 48)); left.closeSubpath() + var right = Path() + right.move(to: p(0, 0)); right.addLine(to: p(halfW, -24)); right.addLine(to: p(halfW, 24)); right.addLine(to: p(0, 48)); right.closeSubpath() + var top = Path() + top.move(to: p(0, -48)); top.addLine(to: p(-halfW, -24)); top.addLine(to: p(0, 0)); top.addLine(to: p(halfW, -24)); top.closeSubpath() + + context.fill(left, with: .color(.primary.opacity(0.2))) + context.fill(right, with: .color(.primary.opacity(0.35))) + context.stroke(left, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(right, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + context.stroke(top, with: .color(.primary), style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) + + // OpenCode mark: square ring (even-odd) + a partial inner fill. + var ring = Path() + ring.move(to: m(-16, -20)); ring.addLine(to: m(16, -20)); ring.addLine(to: m(16, 20)); ring.addLine(to: m(-16, 20)); ring.closeSubpath() + ring.move(to: m(-8, -12)); ring.addLine(to: m(-8, 12)); ring.addLine(to: m(8, 12)); ring.addLine(to: m(8, -12)); ring.closeSubpath() + context.fill(ring, with: .color(.primary), style: FillStyle(eoFill: true)) + + var inner = Path() + inner.move(to: m(-8, -4)); inner.addLine(to: m(8, -4)); inner.addLine(to: m(8, 12)); inner.addLine(to: m(-8, 12)); inner.closeSubpath() + context.fill(inner, with: .color(.primary.opacity(0.4))) + } + } +} diff --git a/packages/mobile/ios/App/Podfile b/packages/mobile/ios/App/Podfile new file mode 100644 index 0000000000..cb62cfa534 --- /dev/null +++ b/packages/mobile/ios/App/Podfile @@ -0,0 +1,40 @@ +require_relative '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios/scripts/pods_helpers' + +platform :ios, '15.5' +use_frameworks! + +# workaround to avoid Xcode caching of Pods that requires +# Product -> Clean Build Folder after new Cordova plugins installed +# Requires CocoaPods 1.6 or newer +install! 'cocoapods', :disable_input_output_paths => true + +def capacitor_pods + pod 'Capacitor', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'CapacitorCordova', :path => '../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios' + pod 'AparajitaCapacitorSecureStorage', :path => '../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage' + pod 'CapacitorMlkitBarcodeScanning', :path => '../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning' + pod 'CapacitorApp', :path => '../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app' + pod 'CapacitorKeyboard', :path => '../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard' + pod 'CapacitorPushNotifications', :path => '../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications' + pod 'CapacitorStatusBar', :path => '../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar' +end + +target 'App' do + capacitor_pods + # Add your Pods here +end + +post_install do |installer| + assertDeploymentTarget(installer) + # Xcode 16+/iOS 26 SDK rejects deployment targets below 15.0, and GoogleMLKit + # (pulled in by the barcode scanner) requires iOS 15.5+. Force every Pods target + # up so the Capacitor/Cordova/MLKit pods build for a real device. + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' + # Capacitor's Cordova compatibility headers use quoted includes; newer Xcode + # treats those as errors in framework headers. Keep it a (non-fatal) warning. + config.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' + end + end +end diff --git a/packages/mobile/ios/App/Podfile.lock b/packages/mobile/ios/App/Podfile.lock new file mode 100644 index 0000000000..cd65e1a551 --- /dev/null +++ b/packages/mobile/ios/App/Podfile.lock @@ -0,0 +1,134 @@ +PODS: + - AparajitaCapacitorSecureStorage (8.0.0): + - Capacitor + - KeychainSwift (~> 21.0) + - Capacitor (8.4.1): + - CapacitorCordova + - CapacitorApp (8.1.0): + - Capacitor + - CapacitorCordova (8.4.1) + - CapacitorKeyboard (8.0.5): + - Capacitor + - CapacitorMlkitBarcodeScanning (8.1.0): + - Capacitor + - GoogleMLKit/BarcodeScanning (~> 8.0.0) + - CapacitorPushNotifications (8.1.1): + - Capacitor + - CapacitorStatusBar (8.0.2): + - Capacitor + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (8.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 7.0.0) + - GoogleMLKit/MLKitCore (8.0.0): + - MLKitCommon (~> 13.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.1): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.1): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.1) + - GoogleUtilities/UserDefaults (8.1.1): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - KeychainSwift (21.0.0) + - MLImage (1.0.0-beta7) + - MLKitBarcodeScanning (7.0.0): + - MLKitCommon (~> 13.0) + - MLKitVision (~> 9.0) + - MLKitCommon (13.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (9.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta7) + - MLKitCommon (~> 13.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.1) + +DEPENDENCIES: + - "AparajitaCapacitorSecureStorage (from `../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage`)" + - "Capacitor (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorApp (from `../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app`)" + - "CapacitorCordova (from `../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios`)" + - "CapacitorKeyboard (from `../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard`)" + - "CapacitorMlkitBarcodeScanning (from `../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning`)" + - "CapacitorPushNotifications (from `../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications`)" + - "CapacitorStatusBar (from `../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar`)" + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - KeychainSwift + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + AparajitaCapacitorSecureStorage: + :path: "../../../../node_modules/.bun/@aparajita+capacitor-secure-storage@8.0.0/node_modules/@aparajita/capacitor-secure-storage" + Capacitor: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorApp: + :path: "../../../../node_modules/.bun/@capacitor+app@8.1.0+767ac80cbab8ae50/node_modules/@capacitor/app" + CapacitorCordova: + :path: "../../../../node_modules/.bun/@capacitor+ios@8.4.1+767ac80cbab8ae50/node_modules/@capacitor/ios" + CapacitorKeyboard: + :path: "../../../../node_modules/.bun/@capacitor+keyboard@8.0.5+767ac80cbab8ae50/node_modules/@capacitor/keyboard" + CapacitorMlkitBarcodeScanning: + :path: "../../../../node_modules/.bun/@capacitor-mlkit+barcode-scanning@8.1.0+767ac80cbab8ae50/node_modules/@capacitor-mlkit/barcode-scanning" + CapacitorPushNotifications: + :path: "../../../../node_modules/.bun/@capacitor+push-notifications@8.1.1+767ac80cbab8ae50/node_modules/@capacitor/push-notifications" + CapacitorStatusBar: + :path: "../../../../node_modules/.bun/@capacitor+status-bar@8.0.2+767ac80cbab8ae50/node_modules/@capacitor/status-bar" + +SPEC CHECKSUMS: + AparajitaCapacitorSecureStorage: 8128d05cafcb13b00448e20fb388a0edccd44b12 + Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b + CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650 + CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09 + CapacitorKeyboard: b6b0744890cdb1d9a96e2cafcc9253fdcc55de3b + CapacitorMlkitBarcodeScanning: 31c6af9f39873ff69e16ed5b39ebe2e1915f16fe + CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e + CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: ddd51d7dff36ff28defa69afedd9cdce684fd857 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + KeychainSwift: 4a71a45c802fd9e73906457c2dcbdbdc06c9419d + MLImage: 2ab9c968e75f57911c16f4c9d9e8a8e9604a86a1 + MLKitBarcodeScanning: 72c6437f13a900833b400136be53a8a5d86f42fa + MLKitCommon: 26b779f072a182c1603d4c88a101c350cac837b1 + MLKitVision: fa8dea9012ac59497c79ddbe9ebf32051047ac4c + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 + +PODFILE CHECKSUM: 99c62a30e73aa8ec805869506d2b1969ee4fb92d + +COCOAPODS: 1.16.2 diff --git a/packages/mobile/package.json b/packages/mobile/package.json new file mode 100644 index 0000000000..7559624083 --- /dev/null +++ b/packages/mobile/package.json @@ -0,0 +1,47 @@ +{ + "name": "@openchamber/mobile", + "version": "1.13.2", + "private": true, + "type": "module", + "scripts": { + "build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs", + "sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"", + "add:ios": "cap add ios", + "add:android": "cap add android", + "build:android:debug": "node scripts/with-mobile-env.mjs \"bun run sync && ./android/gradlew -p android assembleDebug\"", + "android:devices": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs devices\"", + "android:install": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs install\"", + "android:launch": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs launch\"", + "android:run": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs run\"", + "android:logcat": "node scripts/with-mobile-env.mjs \"node scripts/android-device.mjs logcat\"", + "build:ios:simulator": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim-build.mjs\"", + "sim:boot": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs boot\"", + "sim:install": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs install\"", + "sim:launch": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs launch\"", + "sim:run": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs run\"", + "sim:serve": "node scripts/with-mobile-env.mjs \"serve-sim --detach -q\"", + "sim:list": "node scripts/with-mobile-env.mjs \"serve-sim --list -q\"", + "sim:kill": "node scripts/with-mobile-env.mjs \"serve-sim --kill\"", + "open:ios": "cap open ios", + "open:android": "cap open android", + "type-check": "tsc --noEmit", + "lint": "eslint \"./**/*.{ts,tsx,js,mjs}\" --config ../../eslint.config.js --ignore-pattern dist --ignore-pattern ios --ignore-pattern android" + }, + "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", + "@capacitor-mlkit/barcode-scanning": "^8.1.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0" + }, + "devDependencies": { + "@capacitor/android": "^8.4.1", + "@capacitor/cli": "^8.4.1", + "@capacitor/ios": "^8.4.1", + "@types/node": "^24.3.1", + "serve-sim": "^0.1.34", + "typescript": "~5.9.0" + } +} diff --git a/packages/mobile/scripts/android-device.mjs b/packages/mobile/scripts/android-device.mjs new file mode 100644 index 0000000000..82aab84c41 --- /dev/null +++ b/packages/mobile/scripts/android-device.mjs @@ -0,0 +1,93 @@ +// Install / launch the debug APK on a connected Android device via adb. +// +// Mirrors scripts/ios-sim.mjs for the iOS simulator. Run through with-mobile-env.mjs so adb +// (ANDROID_HOME/platform-tools) and the JDK are on PATH. Build the APK first with +// `bun run build:android:debug`; `run` installs + launches it. + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APK_PATH = join(mobileRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); +const APP_ID = 'com.openchamber.app'; +const LAUNCH_ACTIVITY = `${APP_ID}/.MainActivity`; + +const adb = (args, { capture = false, allowFail = false } = {}) => { + const result = spawnSync('adb', args, { stdio: capture ? 'pipe' : 'inherit', encoding: 'utf8' }); + if (!allowFail && result.status !== 0) { + throw new Error(`adb ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } + return result; +}; + +const connectedDevices = () => { + const output = adb(['devices'], { capture: true, allowFail: true }).stdout || ''; + return output + .split('\n') + .slice(1) + .map((line) => line.trim()) + .filter((line) => line.endsWith('\tdevice')) + .map((line) => line.split('\t')[0]); +}; + +const requireDevice = () => { + const devices = connectedDevices(); + if (devices.length === 0) { + console.error( + 'No authorized Android device found. Enable Developer options + USB debugging on the device, ' + + 'connect it, and accept the "Allow USB debugging" prompt. Check with: bun run android:devices', + ); + process.exit(1); + } + return devices; +}; + +const requireApk = () => { + if (!existsSync(APK_PATH)) { + throw new Error(`Debug APK not found at ${APK_PATH}. Build it first: bun run build:android:debug`); + } +}; + +const install = () => { + requireDevice(); + requireApk(); + adb(['install', '-r', APK_PATH]); +}; + +const launch = () => { + requireDevice(); + adb(['shell', 'am', 'start', '-n', LAUNCH_ACTIVITY]); +}; + +const command = process.argv[2]; +switch (command) { + case 'devices': + adb(['devices', '-l']); + break; + case 'install': + install(); + break; + case 'launch': + launch(); + break; + case 'run': + install(); + launch(); + break; + case 'logcat': { + requireDevice(); + const pid = (adb(['shell', 'pidof', APP_ID], { capture: true, allowFail: true }).stdout || '').trim().split(/\s+/)[0]; + if (pid) { + adb(['logcat', `--pid=${pid}`]); + } else { + console.warn(`[android] ${APP_ID} is not running; streaming Capacitor/Chromium logs. Launch the app to see its logs.`); + adb(['logcat', '-s', 'Capacitor:V', 'Capacitor/Console:V', 'chromium:V']); + } + break; + } + default: + console.error('Usage: node scripts/android-device.mjs '); + process.exit(1); +} diff --git a/packages/mobile/scripts/ios-sim-build.mjs b/packages/mobile/scripts/ios-sim-build.mjs new file mode 100644 index 0000000000..78b8a1fd97 --- /dev/null +++ b/packages/mobile/scripts/ios-sim-build.mjs @@ -0,0 +1,69 @@ +// Builds the iOS app for the Apple-Silicon simulator. +// +// Why this is special: the barcode scanner (`@capacitor-mlkit/barcode-scanning` → +// GoogleMLKit) ships only device-arm64 + simulator-x86_64 slices — there is NO +// arm64-simulator slice. CocoaPods therefore adds `EXCLUDED_ARCHS[sdk=iphonesimulator*] = +// arm64`, so a normal build produces an x86_64-only binary that can't install on an +// arm64-only iOS 26+ simulator ("does not contain code for ... arm64"). +// +// QR scanning needs a camera, which the simulator doesn't have, so dropping the scanner for +// simulator builds loses nothing: this script temporarily removes the MLKit pod, builds an +// arm64 simulator binary, then restores the Podfile + Pods so device/TestFlight builds keep +// the scanner. The JS side already degrades cleanly when the native plugin is absent +// (mobileQrScan: getScannerPlugin() → null → isQrScanSupported() false). + +import { spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const iosAppDir = join(mobileRoot, 'ios', 'App'); +const podfilePath = join(iosAppDir, 'Podfile'); + +const run = (command, args, cwd = mobileRoot) => { + const result = spawnSync(command, args, { stdio: 'inherit', cwd, env: process.env }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.status ?? result.signal}`); + } +}; + +// 1. Build the web bundle and copy it into the iOS project (no pod regen — `copy`, not `sync`). +run('bun', ['run', 'build']); +run('cap', ['copy', 'ios']); + +// 2. Strip the MLKit barcode-scanning pod, then reinstall pods without it. +const originalPodfile = readFileSync(podfilePath, 'utf8'); +const strippedPodfile = originalPodfile + .split('\n') + .filter((line) => !line.includes('CapacitorMlkitBarcodeScanning')) + .join('\n'); + +if (strippedPodfile === originalPodfile) { + console.warn('[ios-sim-build] CapacitorMlkitBarcodeScanning not found in Podfile — building as-is.'); +} + +try { + writeFileSync(podfilePath, strippedPodfile); + run('pod', ['install'], iosAppDir); + + // 3. Build for the simulator. With MLKit gone the arm64 simulator slice builds cleanly. + run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-destination', 'generic/platform=iOS Simulator', + 'CODE_SIGNING_ALLOWED=NO', + 'build', + ]); +} finally { + // 4. Always restore the Podfile + Pods so device/TestFlight builds keep the scanner. Pods/ + // and Podfile.lock return to their original state (a no-op for git once this completes). + if (strippedPodfile !== originalPodfile) { + writeFileSync(podfilePath, originalPodfile); + run('pod', ['install'], iosAppDir); + } +} + +console.log('[ios-sim-build] Simulator build complete. Run `bun run sim:run` to install + launch.'); diff --git a/packages/mobile/scripts/ios-sim.mjs b/packages/mobile/scripts/ios-sim.mjs new file mode 100644 index 0000000000..c5fe3a0e55 --- /dev/null +++ b/packages/mobile/scripts/ios-sim.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const BUNDLE_ID = 'com.openchamber.app'; +const DEFAULT_DEVICE = 'iPhone 17 Pro'; + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + env: process.env, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + encoding: 'utf8', + }); + + if (result.status !== 0) { + if (options.capture && result.stderr) process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + + return result.stdout?.trim() ?? ''; +}; + +const getBootedDevice = () => { + const json = run('xcrun', ['simctl', 'list', 'devices', 'booted', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const device = devices.find((item) => item.state === 'Booted'); + if (device) return device; + } + return null; +}; + +const bootDevice = (name = DEFAULT_DEVICE) => { + const booted = getBootedDevice(); + if (booted) return booted.udid; + + const json = run('xcrun', ['simctl', 'list', 'devices', 'available', '--json'], { capture: true }); + const data = JSON.parse(json); + for (const devices of Object.values(data.devices ?? {})) { + const match = devices.find((device) => device.name === name && device.isAvailable !== false); + if (!match) continue; + run('xcrun', ['simctl', 'boot', match.udid]); + return match.udid; + } + + throw new Error(`No available simulator named "${name}" found.`); +}; + +const getBuiltAppPath = () => { + const appPath = run('xcodebuild', [ + '-workspace', 'ios/App/App.xcworkspace', + '-scheme', 'App', + '-configuration', 'Debug', + '-sdk', 'iphonesimulator', + '-showBuildSettings', + ], { capture: true }) + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('TARGET_BUILD_DIR = ')) + ?.replace('TARGET_BUILD_DIR = ', ''); + + if (!appPath) throw new Error('Unable to resolve iOS simulator build output directory.'); + const fullPath = path.join(appPath, 'App.app'); + if (!existsSync(fullPath)) throw new Error(`Built app not found at ${fullPath}. Run bun run build:ios:simulator first.`); + return fullPath; +}; + +const command = process.argv[2]; + +switch (command) { + case 'boot': { + const udid = bootDevice(process.argv.slice(3).join(' ') || DEFAULT_DEVICE); + console.log(udid); + break; + } + case 'install': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + break; + } + case 'launch': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + case 'run': { + const udid = bootDevice(); + run('xcrun', ['simctl', 'install', udid, getBuiltAppPath()]); + run('xcrun', ['simctl', 'launch', udid, BUNDLE_ID]); + break; + } + default: + console.error('Usage: node scripts/ios-sim.mjs [device name]'); + process.exit(1); +} diff --git a/packages/mobile/scripts/prepare-web-assets.mjs b/packages/mobile/scripts/prepare-web-assets.mjs new file mode 100644 index 0000000000..13103ff1f7 --- /dev/null +++ b/packages/mobile/scripts/prepare-web-assets.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const mobileRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const webDist = path.resolve(mobileRoot, '../web/dist'); +const mobileDist = path.resolve(mobileRoot, 'dist'); +const mobileHtml = path.join(mobileDist, 'mobile.html'); +const indexHtml = path.join(mobileDist, 'index.html'); + +await rm(mobileDist, { recursive: true, force: true }); +await mkdir(mobileDist, { recursive: true }); +await cp(webDist, mobileDist, { recursive: true }); + +const html = await readFile(mobileHtml, 'utf8'); +await writeFile(indexHtml, html); diff --git a/packages/mobile/scripts/with-mobile-env.mjs b/packages/mobile/scripts/with-mobile-env.mjs new file mode 100644 index 0000000000..100c0f8b62 --- /dev/null +++ b/packages/mobile/scripts/with-mobile-env.mjs @@ -0,0 +1,48 @@ +import { spawn, spawnSync } from 'node:child_process'; + +const command = process.argv.slice(2).join(' '); + +if (!command) { + console.error('Usage: node scripts/with-mobile-env.mjs '); + process.exit(1); +} + +// Respect an explicit DEVELOPER_DIR, then fall back to whatever the user selected via +// `xcode-select` (so an Xcode beta / non-default install is honoured). Hardcoding +// /Applications/Xcode.app overrode `xcode-select` and forced builds onto the wrong Xcode, +// whose simulator runtimes may not match — xcodebuild then can't find the chosen simulator. +const selectedDeveloperDir = () => { + try { + const result = spawnSync('xcode-select', ['-p'], { encoding: 'utf8' }); + const path = result.status === 0 ? result.stdout.trim() : ''; + return path.length > 0 ? path : null; + } catch { + return null; + } +}; + +const developerDir = + process.env.DEVELOPER_DIR || selectedDeveloperDir() || '/Applications/Xcode.app/Contents/Developer'; +const javaHome = process.env.JAVA_HOME || '/opt/homebrew/opt/openjdk@21'; +const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || '/opt/homebrew/share/android-commandlinetools'; + +const child = spawn(command, { + env: { + ...process.env, + DEVELOPER_DIR: developerDir, + JAVA_HOME: javaHome, + ANDROID_HOME: androidHome, + ANDROID_SDK_ROOT: androidHome, + PATH: `${javaHome}/bin:${androidHome}/platform-tools:${process.env.PATH || ''}`, + }, + shell: true, + stdio: 'inherit', +}); + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); +}); diff --git a/packages/mobile/tsconfig.json b/packages/mobile/tsconfig.json new file mode 100644 index 0000000000..80af150cd3 --- /dev/null +++ b/packages/mobile/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["capacitor.config.ts"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 85c4e41d58..509eb7435c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,7 +11,13 @@ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js" }, "dependencies": { + "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.4.1", + "@capacitor/keyboard": "^8.0.0", + "@capacitor/push-notifications": "^8.1.1", + "@capacitor/status-bar": "^8.0.0", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", "@codemirror/lang-cpp": "^6.0.3", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 8ce5f1b68c..842e837230 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,7 +34,6 @@ import { markSessionViewed } from '@/sync/notification-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; -import { disposeTerminalInputTransport } from '@/lib/terminalApi'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; @@ -57,8 +56,8 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { SyncAppEffects } from '@/apps/AppEffects'; +import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; -import { resetStreamingState } from '@/sync/streaming'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace'; @@ -268,25 +267,7 @@ function App({ apis }: AppProps) { React.useEffect(() => { return subscribeRuntimeEndpointChanged((detail) => { - useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey); - if (detail.previousRuntimeKey) { - useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); - } - disposeTerminalInputTransport(); - opencodeClient.reconnectToRuntimeBaseUrl(); - useConfigStore.setState({ - providers: [], - agents: [], - isConnected: false, - isInitialized: false, - connectionPhase: 'connecting', - lastDisconnectReason: null, - }); - useProjectsStore.getState().resetForRuntimeSwitch(); - useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); - resetStreamingState(); + resetAppForRuntimeEndpointChange(detail); setRuntimeEndpointEpoch((epoch) => epoch + 1); setInitRetryExhausted(false); setInitRetryEpoch((epoch) => epoch + 1); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 1dc9831873..756e564cc1 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -7,6 +7,8 @@ import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; +import { Button } from '@/components/ui/button'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; @@ -29,6 +31,7 @@ import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@ import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -55,7 +58,14 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; +import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; +import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; +import { useFontsReady } from './useFontsReady'; +import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation'; +import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; +import { useNativePushRegistration } from './useNativePushRegistration'; const MOBILE_SETTINGS_PAGES = [ 'appearance', @@ -76,6 +86,177 @@ type MobileAppProps = { apis: RuntimeAPIs; }; +const isCapacitorMobileApp = (): boolean => { + if (typeof window === 'undefined') return false; + const maybeCapacitor = (window as typeof window & { + Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; + }).Capacitor; + if (maybeCapacitor?.isNativePlatform?.() === true) return true; + return window.location.protocol === 'capacitor:'; +}; + +const useNativeMobileChrome = (): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const root = document.documentElement; + // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in + // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). + root.classList.add('oc-capacitor-app'); + // Platform marker: Android resizes the window for the keyboard (no manual inset), so the + // shell's height transition (meant for iOS's animated --oc-keyboard-inset) must be off there + // — otherwise the height animates against the instant native resize and the header bounces. + const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (capacitorPlatform === 'android') { + root.classList.add('oc-platform-android'); + } + + const setInset = (px: number) => { + root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); + }; + + void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => { + if (disposed) return; + // Keep the status bar transparent over the WebView. A custom UIScene lifecycle + // (iOS 26) plus returning from background can silently drop the overlay state, + // letting an opaque status-bar background flash in at the top — so re-assert it + // on mount, once shortly after (startup race), and whenever the app re-activates. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + const applyStatusBar = async () => { + if (platform === 'android') { + // Android doesn't feed env(safe-area-inset-top) to CSS, so overlaying the status bar + // makes content render under it. Inset the WebView below the bar instead and paint the + // bar with the resolved theme background (the splash colours the theme system persists). + const isDark = document.documentElement.classList.contains('dark'); + const themeBg = + (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || + (isDark ? '#171515' : '#fffdf4'); + await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); + await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); + // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), + // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. + await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + return; + } + await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); + await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + }; + await applyStatusBar(); + const retry = window.setTimeout(() => void applyStatusBar(), 400); + cleanup.push(() => window.clearTimeout(retry)); + + const { App } = await import('@capacitor/app'); + const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void applyStatusBar(); + }); + if (disposed) { + void stateHandle.remove(); + return; + } + cleanup.push(() => void stateHandle.remove()); + }).catch(() => undefined); + + void import('@capacitor/keyboard').then(async ({ Keyboard }) => { + if (disposed) return; + // iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard + // overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the + // window for the keyboard (dvh already shrinks), so applying the inset on top double- + // counts and floats the composer a keyboard-height above the keyboard — skip it there. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (platform === 'android') return; + await Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => undefined); + + // `keyboardWillShow` fires at the START of the iOS keyboard animation and + // carries the final height, so we set the inset once here and let the CSS + // transition (tuned to mimic the iOS keyboard curve/duration) carry the rise. + // visualViewport tracking was tried but doesn't shrink under WKWebView's + // `resize: 'none'`, so it never reported the keyboard — this event is the + // reliable signal. + const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + root.classList.add('oc-keyboard-open'); + setInset(info.keyboardHeight); + }); + const hideHandle = await Keyboard.addListener('keyboardWillHide', () => { + root.classList.remove('oc-keyboard-open'); + setInset(0); + }); + if (disposed) { + void showHandle.remove(); + void hideHandle.remove(); + return; + } + cleanup.push(() => void showHandle.remove(), () => void hideHandle.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-platform-android'); + root.style.removeProperty('--oc-keyboard-inset'); + }; + }, []); +}; + +const useNativeMobileLifecycle = (onResume: () => void): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const state = await App.addListener('appStateChange', ({ isActive }) => { + document.documentElement.classList.toggle('oc-native-app-active', isActive); + if (isActive) onResume(); + }); + const resume = await App.addListener('resume', onResume); + if (disposed) { + void state.remove(); + void resume.remove(); + return; + } + cleanup.push(() => void state.remove(), () => void resume.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, [onResume]); +}; + +const useNativeAndroidBackButton = (onBack: () => boolean): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + let remove: (() => void) | null = null; + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const listener = await App.addListener('backButton', () => { + if (onBack()) return; + void App.minimizeApp().catch(() => undefined); + }); + if (disposed) { + void listener.remove(); + return; + } + remove = () => void listener.remove(); + }).catch(() => undefined); + + return () => { + disposed = true; + remove?.(); + }; + }, [onBack]); +}; + const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); @@ -95,6 +276,12 @@ const formatTokens = (value: number): string => { return String(value); }; +const mobileInputKeyboardProps = { + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: false, +} as const; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -103,7 +290,7 @@ const getProjectLabel = (path: string): string => { }; type OverflowItem = { - key: 'files' | 'changes' | 'mcp' | 'update' | 'settings'; + key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings'; icon?: IconName; iconNode?: React.ReactNode; label: string; @@ -122,6 +309,540 @@ const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: return getProjectLabel(fallbackDirectory); }; +const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConnected }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnected); + const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; + const [serverUrl, setServerUrl] = React.useState(''); + const [connectionName, setConnectionName] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + const [password, setPassword] = React.useState(''); + + const handleSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.connect({ url: serverUrl, clientToken, label: connectionName }); + }, [clientToken, conn, connectionName, serverUrl]); + + // Accept a pasted pairing link (openchamber://connect?...) in the URL field and + // split it back into the server URL + token, revealing the token field when present. + const handleUrlChange = React.useCallback((value: string) => { + if (/^openchamber:\/\//i.test(value.trim())) { + const payload = parseConnectionPayload(value); + if (payload) { + setServerUrl(payload.url); + if (payload.label) setConnectionName(payload.label); + if (payload.clientToken) setClientToken(payload.clientToken); + if (payload.label || payload.clientToken) setAdvancedOpen(true); + return; + } + } + setServerUrl(value); + }, []); + + const handleScanQr = React.useCallback(async () => { + if (isScanning || isBusy) return; + conn.setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setServerUrl(result.url); + if (result.label) setConnectionName(result.label); + if (result.clientToken) setClientToken(result.clientToken); + if (result.label || result.clientToken) setAdvancedOpen(true); + await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); + break; + case 'permission-denied': + conn.setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + conn.setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + conn.setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + conn.setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [conn, isBusy, isScanning, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void conn.submitPassword(password); + }, [conn, password]); + + const cancelPassword = React.useCallback(() => { + setPassword(''); + conn.cancelPassword(); + }, [conn]); + + return ( +

+
+
+ +

{t('mobile.connect.welcome.title')}

+
+ + {pendingConnection ? ( +
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + {error ?

{error}

: null} + + +
+ ) : ( +
+
+ setConnectionName(event.target.value)} + placeholder={t('mobile.instances.label.placeholder')} + aria-label={t('mobile.instances.label.label')} + autoComplete="off" + autoCapitalize="words" + autoCorrect="off" + spellCheck={false} + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + handleUrlChange(event.target.value)} + placeholder={t('mobile.connect.url.placeholder')} + aria-label={t('mobile.connect.url.label')} + type="url" + inputMode="url" + autoCapitalize="none" + className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+ +
+
+
+ +

{t('mobile.connect.token.hint')}

+
+
+
+
+ + {error ?

{error}

: null} + + +
+ + + {!qrScanSupported ? ( +

+ {t('mobile.connect.scan.unsupported')} +

+ ) : null} +
+ )} + + {!pendingConnection && connections.length > 0 ? ( +
+

+ {t('mobile.connect.saved.title')} +

+
+ {connections.map((connection) => ( + + ))} +
+
+ ) : null} +
+
+ ); +}; + +const MobileInstancesSurface: React.FC<{ + onConnect: () => void; + onActiveConnectionDeleted: () => void; +}> = ({ onActiveConnectionDeleted, onConnect }) => { + const { t } = useI18n(); + const conn = useMobileConnection(onConnect); + const { + connections, isBusy, isPasswordBusy, error, pendingConnection, + connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, + } = conn; + const [editingId, setEditingId] = React.useState(null); + const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; + const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); + const [url, setUrl] = React.useState(''); + const [label, setLabel] = React.useState(''); + const [clientToken, setClientToken] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [isScanning, setIsScanning] = React.useState(false); + const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); + + // Populate/clear the form imperatively (on edit tap / cancel / save) rather than via + // an effect keyed on the derived connection object. With an effect, any churn of the + // connections list re-fires it and overwrites what the user is typing — the keyboard + // "resets" mid-edit. Imperative population is immune to that. + const resetForm = React.useCallback(() => { + setEditingId(null); + setUrl(''); + setLabel(''); + setClientToken(''); + setError(null); + }, [setError]); + + const saveInstance = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void saveConnection({ url, label, clientToken }).then((saved) => { + if (saved) resetForm(); + }); + }, [clientToken, label, resetForm, saveConnection, url]); + + // Scan a pairing QR into the add/edit form fields (does not change edit mode, so + // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. + const handleScanInstance = React.useCallback(async () => { + if (isScanning) return; + setError(null); + setIsScanning(true); + try { + const result = await scanConnectionQr(); + switch (result.status) { + case 'ok': + setUrl(result.url); + if (result.label) setLabel(result.label); + if (result.clientToken) setClientToken(result.clientToken); + break; + case 'permission-denied': + setError(t('mobile.connect.scan.permissionDenied')); + break; + case 'invalid': + setError(t('mobile.connect.scan.invalid')); + break; + case 'unsupported': + setError(t('mobile.connect.scan.unsupported')); + break; + case 'failed': + setError(t('mobile.connect.scan.failed')); + break; + case 'cancelled': + default: + break; + } + } finally { + setIsScanning(false); + } + }, [isScanning, setError, t]); + + const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { + event.preventDefault(); + void submitPassword(password); + }, [password, submitPassword]); + + const cancelPasswordPrompt = React.useCallback(() => { + setPassword(''); + cancelPassword(); + }, [cancelPassword]); + + // Two-step delete (mirrors the session sheet): the trash icon arms the row, a + // second tap on the destructive button confirms, the X disarms. No hover relied on. + const toggleConfirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId((current) => (current === id ? null : id)); + }, []); + + const confirmDelete = React.useCallback((id: string) => { + setConfirmingDeleteId(null); + if (editingId === id) resetForm(); + void removeConnection(id).then((removed) => { + if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) { + onActiveConnectionDeleted(); + } + }); + }, [editingId, onActiveConnectionDeleted, removeConnection, resetForm]); + + const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20'; + + if (pendingConnection) { + return ( +
+
+
+
+ + + +
+

{pendingConnection.label}

+

{pendingConnection.url}

+
+
+ setPassword(event.target.value)} + placeholder={t('mobile.connect.password.placeholder')} + aria-label={t('mobile.connect.password.label')} + type="password" + autoFocus + className={inputClass} + /> + {error ?

{error}

: null} + + +
+
+
+ ); + } + + return ( +
+
+
+ {connections.length > 0 ? ( +
+ {connections.map((connection) => { + const confirming = confirmingDeleteId === connection.id; + return ( +
+ +
+ {confirming ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ) : ( +

+ {t('mobile.connect.saved.empty')} +

+ )} + +
+
+

+ {editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')} +

+ {editingConnection ? ( + + ) : null} +
+
+ + {!qrScanSupported ? ( +

{t('mobile.connect.scan.unsupported')}

+ ) : null} +
+ + + + {error ?

{error}

: null} + +
+
+
+
+ ); +}; + type MobileUsageLimitRow = { key: string; label: string; @@ -787,12 +1508,13 @@ const MobileHeader: React.FC<{ ); }; -const MobileShell: React.FC = () => { +const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); const [mcpOpen, setMcpOpen] = React.useState(false); + const [instancesOpen, setInstancesOpen] = React.useState(false); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [updateOpen, setUpdateOpen] = React.useState(false); @@ -804,6 +1526,7 @@ const MobileShell: React.FC = () => { const setSettingsPage = useUIStore((state) => state.setSettingsPage); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); + const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); @@ -832,6 +1555,101 @@ const MobileShell: React.FC = () => { setPendingChangesDiff(null); }, []); + // Expose the shell's panel-opening actions to the deep-link layer so openchamber:// URLs + // (and notification taps / widgets) can navigate to these surfaces. Session and + // new-session intents resolve directly against the store, so they aren't wired here. + const deepLinkHandlers = React.useMemo( + () => ({ + openSessions: () => setSessionsSheetOpen(true), + openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { + if (target === 'files') setFilesOpen(true); + else if (target === 'mcp') setMcpOpen(true); + else if (target === 'instances') setInstancesOpen(true); + else if (target === 'update') setUpdateOpen(true); + }, + openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => { + setPendingChangesDiff(path ? { path, staged: staged === true } : null); + setChangesOpen(true); + }, + openSettings: (section?: string) => { + if (section) setSettingsPage(section as Parameters[0]); + setSettingsInitialMobileStage(section ? 'page-content' : 'nav'); + setSettingsOpen(true); + }, + }), + [setSettingsPage], + ); + useDeepLinkHandlers(deepLinkHandlers); + + // Edge swipe (left/right screen edge → centre) switches between sessions, with a directional + // slide+fade on the chat content so it's obvious the session changed. + const chatMainRef = React.useRef(null); + const chatAnimRef = React.useRef(null); + const swipeDirectionRef = React.useRef<'prev' | 'next' | null>(null); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + // Record the swipe direction; the animation itself runs in the layout effect below, once the + // new session's content has committed — running it inline in the swipe callback raced the + // re-render and dropped the animation on roughly every other switch. + const recordSwipeDirection = React.useCallback((direction: 'prev' | 'next') => { + swipeDirectionRef.current = direction; + }, []); + useEdgeSwipeSessionSwitch(chatMainRef, { onSwitch: recordSwipeDirection }); + + React.useLayoutEffect(() => { + const direction = swipeDirectionRef.current; + swipeDirectionRef.current = null; + if (!direction) return; // only animate swipe-driven switches + const element = chatAnimRef.current; + if (!element || typeof element.animate !== 'function') return; + element.getAnimations().forEach((animation) => animation.cancel()); + const fromX = direction === 'prev' ? -70 : 70; + element.animate( + [ + { opacity: 0.1, transform: `translateX(${fromX}px)` }, + { opacity: 1, transform: 'translateX(0)' }, + ], + { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + }, [currentSessionId]); + + const handleNativeBack = React.useCallback(() => { + if (overflowOpen) { + setOverflowOpen(false); + return true; + } + if (sessionsSheetOpen) { + setSessionsSheetOpen(false); + return true; + } + if (filesOpen) { + setFilesOpen(false); + return true; + } + if (changesOpen) { + closeChanges(); + return true; + } + if (mcpOpen) { + setMcpOpen(false); + return true; + } + if (instancesOpen) { + setInstancesOpen(false); + return true; + } + if (settingsOpen) { + setSettingsOpen(false); + return true; + } + if (updateOpen) { + setUpdateOpen(false); + return true; + } + return false; + }, [changesOpen, closeChanges, filesOpen, instancesOpen, mcpOpen, overflowOpen, sessionsSheetOpen, settingsOpen, updateOpen]); + + useNativeAndroidBackButton(handleNativeBack); + const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); const openMcpCreateSettings = React.useCallback(() => { @@ -881,7 +1699,8 @@ const MobileShell: React.FC = () => { }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); const overflowItems: OverflowItem[] = React.useMemo( - () => [ + () => { + const items: OverflowItem[] = [ { key: 'files', icon: 'file-text', @@ -901,13 +1720,24 @@ const MobileShell: React.FC = () => { label: t('mobile.menu.mcp'), onSelect: () => setMcpOpen(true), }, - ...(showUpdateItem ? [{ - key: 'update' as const, - icon: 'download' as const, - label: t('mobile.menu.update'), - onSelect: () => setUpdateOpen(true), - }] : []), - { + ]; + if (showCapacitorOnlyFeatures) { + items.push({ + key: 'instances', + icon: 'server', + label: t('mobile.menu.instances'), + onSelect: () => setInstancesOpen(true), + }); + } + if (showUpdateItem) { + items.push({ + key: 'update', + icon: 'download', + label: t('mobile.menu.update'), + onSelect: () => setUpdateOpen(true), + }); + } + items.push({ key: 'settings', icon: 'settings-3', label: t('mobile.menu.settings'), @@ -915,25 +1745,28 @@ const MobileShell: React.FC = () => { setSettingsInitialMobileStage('nav'); setSettingsOpen(true); }, - }, - ], - [dirtyChangeCount, showUpdateItem, t], + }); + return items; + }, + [dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t], ); return (
setSessionsSheetOpen(true)} onOpenMenu={() => setOverflowOpen(true)} /> -
- - - +
+
+ + + +
{ ) : null} + {instancesOpen && showCapacitorOnlyFeatures ? ( + setInstancesOpen(false)} + ariaLabel={t('mobile.menu.instances')} + title={t('mobile.menu.instances')} + > + setInstancesOpen(false)} + onActiveConnectionDeleted={onActiveConnectionDeleted} + /> + + ) : null} + {settingsOpen ? ( { }; export function MobileApp({ apis }: MobileAppProps) { + const { t } = useI18n(); const initializeApp = useConfigStore((state) => state.initializeApp); const isInitialized = useConfigStore((state) => state.isInitialized); const isConnected = useConfigStore((state) => state.isConnected); + const connectionPhase = useConfigStore((state) => state.connectionPhase); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); @@ -1089,19 +1938,75 @@ export function MobileApp({ apis }: MobileAppProps) { const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); + const [connectionEpoch, setConnectionEpoch] = React.useState(0); + const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0); + const [showConnectionRecovery, setShowConnectionRecovery] = React.useState(false); + // Cold-launch auto-connect to the last instance: 'pending'/'attempting' hold the + // splash so we don't flash the connect screen; 'done' means we either connected or + // exhausted the attempt (then the connect screen shows). + const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); + const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); + + const handleNativeResume = React.useCallback(() => { + if (!getRuntimeApiBaseUrl()) return; + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); + + useNativeMobileChrome(); + useNativeMobileLifecycle(handleNativeResume); React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); }, [apis]); + // Switching instances (or disconnecting) only changes the runtime endpoint; the + // stores still hold the previous instance's data. Mirror the web App.tsx reset + // sequence so the UI fully re-bootstraps against the new server instead of going + // stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too. + React.useEffect(() => { + return subscribeRuntimeEndpointChanged((detail) => { + resetAppForRuntimeEndpointChange(detail); + setRuntimeEndpointEpoch((epoch) => epoch + 1); + setConnectionEpoch((epoch) => epoch + 1); + }); + }, []); + + // On cold launch, silently reconnect to the most-recent saved instance so a + // returning user — and notification deep-links — land in the app instead of the + // connect screen. The splash is held while we try (see render below). If there's + // no saved instance, it's unreachable, or it needs a (re)login, we fall through + // to the connect screen. A successful switchRuntimeEndpoint fires the endpoint- + // changed subscription above, which bumps the epochs and bootstraps the app. + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || getRuntimeApiBaseUrl()) { + setAutoConnectPhase('done'); + return; + } + let cancelled = false; + setAutoConnectPhase('attempting'); + void autoConnectLastInstance() + .catch(() => false) + .then(() => { + if (!cancelled) setAutoConnectPhase('done'); + }); + return () => { + cancelled = true; + }; + // Run once on mount — auto-connect is a cold-launch concern only. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + React.useEffect(() => { setIsMobile(true); }, [setIsMobile]); React.useEffect(() => { void initializeApp(); - }, [initializeApp]); + }, [connectionEpoch, initializeApp]); React.useEffect(() => { if (!isConnected) return; @@ -1187,21 +2092,116 @@ export function MobileApp({ apis }: MobileAppProps) { return () => window.clearTimeout(timeout); }, [clearError, error]); + React.useEffect(() => { + if (!isNativeMobileApp || isConnected || !getRuntimeApiBaseUrl()) { + setShowConnectionRecovery(false); + return; + } + const timeout = window.setTimeout(() => setShowConnectionRecovery(true), 8000); + return () => window.clearTimeout(timeout); + }, [isConnected, isNativeMobileApp, connectionEpoch, runtimeEndpointEpoch]); + useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useUpdatePolling(); useWindowTitle(); useRouter(); + // APNs is the only notification channel on the native app (background-capable, + // focus-suppressed server-side via the visibility beacon). Local notifications are + // intentionally disabled — they can't tell foreground from background in a WKWebView + // (document.hasFocus() is unreliable) and leaked while the app was open; the in-app SSE + // notification dispatch is no-op'd for native in renderMobileApp. + useNativePushRegistration({ enabled: isNativeMobileApp && isConnected }); + // Single native deep-link entry point: notification taps AND the openchamber:// URL + // scheme (widgets, Live Activities, external links). Registered unconditionally so a + // cold-launch tap/open isn't lost on the connect/splash screen; intents stash until + // the app is ready (connected + initialized) and shell handlers are registered. + useDeepLinkSource({ ready: isNativeMobileApp && isConnected && isInitialized }); + const fontsReady = useFontsReady(); + + // `isConnected` is a LIVE flag that flips false on every transient SSE/WS drop and + // back true on reconnect. We must NOT blank the whole app to a loader on those — + // only on the initial connect / instance switch (connectionPhase 'connecting'). + // While 'reconnecting' (we were connected before), keep MobileShell mounted so the + // UI doesn't reload on every network blip. + const isReconnecting = !isConnected && connectionPhase === 'reconnecting'; + + // Hold a logo splash until the UI web font is loaded, so the first UI the user sees + // already uses the real font instead of flashing the fallback and reflowing (FOUT). + if (!fontsReady) { + return ( +
+ +
+ ); + } + + if (!isConnected && !isReconnecting && isNativeMobileApp) { + // A runtime endpoint is already selected (first connect or switching instances): + // show a loader while it re-bootstraps instead of flashing the onboarding screen. + if (getRuntimeApiBaseUrl()) { + return ( +
+
+ + {showConnectionRecovery ? ( + <> +
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+ + + ) : null} +
+
+ ); + } + // Cold-launch auto-connect is still resolving — hold the splash instead of + // flashing the connect screen. Only show the connect screen once we've finished + // (no saved instance, unreachable, or needs re-login). + if (autoConnectPhase !== 'done') { + return ( +
+ +
+ ); + } + return setConnectionEpoch((value) => value + 1)} />; + } + + if (!isConnected && !isReconnecting) { + return ( +
+
+

{t('sessionAuth.error.networkTitle')}

+

{t('sessionAuth.error.networkDescription')}

+
+
+ ); + } return ( - +
- + { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + }} /> {isInitialized ? : null}
diff --git a/packages/ui/src/apps/MobileSurfaceShell.tsx b/packages/ui/src/apps/MobileSurfaceShell.tsx index 5ba246e53c..ad9f4af1bd 100644 --- a/packages/ui/src/apps/MobileSurfaceShell.tsx +++ b/packages/ui/src/apps/MobileSurfaceShell.tsx @@ -67,6 +67,15 @@ export const MobileSurfaceShell: React.FC = ({ const isDraggingRef = React.useRef(false); const surfaceRef = React.useRef(null); const previousFocusRef = React.useRef(null); + // Keep onClose in a ref so the focus/keydown effect below depends only on `open`. + // The parent passes a fresh inline onClose on every render; if the effect depended + // on it, each parent re-render (e.g. an SSE store update) would re-run it and + // refocus the first element — stealing focus from whatever input the user is in + // and collapsing the keyboard mid-edit. + const onCloseRef = React.useRef(onClose); + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); if (typeof document !== 'undefined' && !rootRef.current) { rootRef.current = ensureSurfaceRoot(); @@ -112,7 +121,7 @@ export const MobileSurfaceShell: React.FC = ({ const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - onClose(); + onCloseRef.current(); return; } if (event.key !== 'Tab') return; @@ -145,7 +154,7 @@ export const MobileSurfaceShell: React.FC = ({ previousFocusRef.current?.focus?.({ preventScroll: true }); previousFocusRef.current = null; }; - }, [onClose, open]); + }, [open]); const handleDragStart = (event: React.TouchEvent) => { if (disableSwipeDismiss) return; @@ -208,7 +217,7 @@ export const MobileSurfaceShell: React.FC = ({ return createPortal(
void) | null = null; +let resolved = false; + +export const appBootReadyPromise = new Promise((resolve) => { + resolveBoot = resolve; +}); + +export function markAppBootReady(): void { + if (resolved) return; + resolved = true; + resolveBoot?.(); +} diff --git a/packages/ui/src/apps/deepLinkNavigation.ts b/packages/ui/src/apps/deepLinkNavigation.ts new file mode 100644 index 0000000000..cff0835990 --- /dev/null +++ b/packages/ui/src/apps/deepLinkNavigation.ts @@ -0,0 +1,198 @@ +import React from 'react'; + +import { isCapacitorApp } from '@/lib/platform'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useUIStore } from '@/stores/useUIStore'; + +import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; + +/** + * Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a + * deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents + * in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative + * handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to + * the session store (always available), so those resolve even before the shell has mounted. + * + * Intents that arrive before the app is ready (cold launch from a tap/widget) or before their + * handler is registered are stashed in a module-level holder that survives the connect flow + * and SyncProvider remount, then applied as soon as the app becomes ready / the handler + * appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue. + */ + +export interface DeepLinkHandlers { + /** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */ + openSessions?: (filter?: SessionsFilter) => void; + /** Open a non-session surface (files / mcp / instances / update). */ + openView?: (target: ViewTarget) => void; + /** Open the Changes surface, optionally jumping straight to a file diff. */ + openChanges?: (options?: { path?: string; staged?: boolean }) => void; + /** Open Settings, optionally at a specific section. */ + openSettings?: (section?: string) => void; +} + +let handlers: DeepLinkHandlers = {}; +let ready = false; +let pending: DeepLinkIntent | null = null; + +const execute = (intent: DeepLinkIntent): boolean => { + switch (intent.type) { + case 'session': + void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null); + return true; + + case 'new-session': { + const store = useSessionUIStore.getState(); + store.openNewSessionDraft(); + if (intent.directory || intent.projectId) { + store.setNewSessionDraftTarget({ + directoryOverride: intent.directory ?? null, + projectId: intent.projectId ?? null, + selectedProjectId: intent.projectId ?? null, + }); + } + return true; + } + + case 'sessions': + if (!handlers.openSessions) return false; + handlers.openSessions(intent.filter); + return true; + + case 'status': + // The session status panel is store-backed (useUIStore.mobileSessionPanelOpen), + // so it opens without a shell handler — like session/new-session. + useUIStore.getState().setMobileSessionPanelOpen(true); + return true; + + case 'view': + if (!handlers.openView) return false; + handlers.openView(intent.target); + return true; + + case 'changes': + if (!handlers.openChanges) return false; + handlers.openChanges({ path: intent.path, staged: intent.staged }); + return true; + + case 'settings': + if (!handlers.openSettings) return false; + handlers.openSettings(intent.section); + return true; + } +}; + +const flush = (): void => { + if (!ready || !pending) return; + const intent = pending; + // Drop the stash before executing; if the handler isn't registered yet, execute() returns + // false and we re-stash so a later registerDeepLinkHandlers() flush can retry it. + pending = null; + if (!execute(intent)) { + pending = intent; + } +}; + +/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */ +export 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 intent = parseDeepLink(raw); + if (intent) { + applyDeepLinkIntent(intent); + } +}; + +const setReady = (value: boolean): void => { + ready = value; + flush(); +}; + +/** + * Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes). + * Call from the component that owns those panels; the handlers are torn down on unmount. + * Registering also flushes any pending intent that was waiting for these handlers. + */ +export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => { + React.useEffect(() => { + handlers = next; + flush(); + return () => { + if (handlers === next) { + handlers = {}; + } + }; + }, [next]); +}; + +/** + * Single native entry point for deep links. Subscribes to both the custom URL scheme + * (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps + * (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}. + * Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while + * the app is still connecting; intents stash until `ready` (connected + initialized). + */ +export const useDeepLinkSource = (options: { ready: boolean }): void => { + const { ready: isReady } = options; + + React.useEffect(() => { + setReady(isReady); + }, [isReady]); + + React.useEffect(() => { + if (!isCapacitorApp()) return; + let disposed = false; + const cleanup: Array<() => void> = []; + + void import('@capacitor/app') + .then(async ({ App }) => { + if (disposed) return; + const handle = await App.addListener('appUrlOpen', (event) => { + applyDeepLinkUrl(event?.url); + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + void import('@capacitor/push-notifications') + .then(async ({ PushNotifications }) => { + if (disposed) return; + const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => { + const data = action?.notification?.data as Record | undefined; + // Prefer an explicit deep link in the payload (richest); fall back to a bare + // sessionId for backwards compatibility with existing push senders. + const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined; + if (url) { + applyDeepLinkUrl(url); + return; + } + const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined; + if (sessionId) { + applyDeepLinkIntent({ type: 'session', sessionId }); + } + }); + if (disposed) { + void handle.remove(); + return; + } + cleanup.push(() => void handle.remove()); + }) + .catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, []); +}; + +// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary. +export { buildDeepLink, parseDeepLink }; +export type { DeepLinkIntent, SessionsFilter, ViewTarget }; diff --git a/packages/ui/src/apps/deepLinks.ts b/packages/ui/src/apps/deepLinks.ts new file mode 100644 index 0000000000..f4c3f21394 --- /dev/null +++ b/packages/ui/src/apps/deepLinks.ts @@ -0,0 +1,169 @@ +/** + * OpenChamber deep-link vocabulary — the single source of truth for the `openchamber://` + * URL scheme used across every native entry point: notification taps, home-screen / lock- + * screen widgets, and (later) Live Activities. Anything that wants to drive navigation + * builds a URL with {@link buildDeepLink} and anything that receives one parses it with + * {@link parseDeepLink} into a typed {@link DeepLinkIntent}; the navigation layer + * (deepLinkNavigation) is the only place that knows how to *apply* an intent. + * + * Keep this file pure (no React, no stores, no Capacitor) so it can be imported from any + * context — including, eventually, a tiny encoder shared with the native widget/extension. + */ + +export const DEEP_LINK_SCHEME = 'openchamber'; + +export type SessionsFilter = 'all' | 'attention' | 'recent'; +export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update'; + +/** + * Every navigable destination the app exposes to the outside world. New widget/notification + * ideas should add a variant here first, then teach deepLinkNavigation how to apply it — + * that keeps the "blocks" composable without leaking ad-hoc URL parsing into features. + */ +export type DeepLinkIntent = + | { type: 'session'; sessionId: string; directory?: string } + | { type: 'new-session'; directory?: string; projectId?: string; agent?: string; model?: string } + | { type: 'sessions'; filter?: SessionsFilter } + | { type: 'status' } + | { type: 'settings'; section?: string } + | { type: 'changes'; path?: string; staged?: boolean } + | { type: 'view'; target: ViewTarget }; + +const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ''); + +const segmentsOf = (url: URL): string[] => { + // Custom-scheme URLs put the first route token in `host` (openchamber://session/), + // but be tolerant of authority-less forms (openchamber:/session/) where it lands in + // the pathname instead. + const pathSegments = trimSlashes(url.pathname).split('/').filter(Boolean); + if (url.host) { + return [url.host, ...pathSegments]; + } + return pathSegments; +}; + +/** + * Parse a raw `openchamber://…` string into a typed intent, or `null` if it isn't a + * recognised OpenChamber deep link. Tolerant by design: unknown routes return `null` + * rather than throwing, so callers can fall back without a try/catch. + */ +export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent | null { + if (typeof raw !== 'string' || raw.length === 0) { + return null; + } + + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + + if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { + return null; + } + + const segments = segmentsOf(url); + const route = (segments[0] ?? '').toLowerCase(); + const rest = segments.slice(1); + const query = url.searchParams; + + switch (route) { + case 'session': { + const sessionId = rest[0] || query.get('id') || ''; + if (!sessionId) { + return null; + } + return { type: 'session', sessionId, directory: query.get('dir') ?? undefined }; + } + + case 'new': + case 'new-session': + return { + type: 'new-session', + directory: query.get('dir') ?? undefined, + projectId: query.get('project') ?? undefined, + agent: query.get('agent') ?? undefined, + model: query.get('model') ?? undefined, + }; + + case 'sessions': { + const filter = query.get('filter'); + return { + type: 'sessions', + filter: filter === 'attention' || filter === 'recent' || filter === 'all' ? filter : undefined, + }; + } + + case 'status': + return { type: 'status' }; + + case 'settings': + return { type: 'settings', section: rest[0] || query.get('section') || undefined }; + + case 'changes': + return { + type: 'changes', + path: rest.join('/') || query.get('path') || undefined, + staged: query.get('staged') === 'true', + }; + + case 'view': { + const target = (rest[0] || '').toLowerCase(); + // `changes` has its own richer intent (diff path); route the bare view token to it. + if (target === 'changes') { + return { type: 'changes' }; + } + if (target === 'files' || target === 'mcp' || target === 'instances' || target === 'update') { + return { type: 'view', target }; + } + return null; + } + + default: + 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 => { + 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}`; + } +} diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts new file mode 100644 index 0000000000..35a748afc3 --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.ts @@ -0,0 +1,680 @@ +// Saved-connection storage + the shared connect/unlock flow for the dedicated +// mobile app. Both the onboarding welcome screen and the Instances sheet drive +// connections through `useMobileConnection` so the health-check + progressive +// password unlock + client-token issuance + runtime switch all behave identically. +// +// Persistence model (deliberately simple so it is correct-by-inspection): +// - Instance *metadata* (id/label/url/lastUsedAt + a `hasToken` flag) lives in +// localStorage. On native it NEVER contains the client token. +// - The client token lives in the OS secure store (iOS Keychain / Android +// Keystore) via @aparajita/capacitor-secure-storage, keyed per instance URL. +// - On web (browser-hosted mobile.html) there is no secure store, so the token +// stays inline in localStorage — that surface is not the native security target. +// +// Token writes are AWAITED before we switch the runtime endpoint, so a successful +// unlock guarantees the token is actually persisted (no fire-and-forget). + +import { SecureStorage } from '@aparajita/capacitor-secure-storage'; +import React from 'react'; + +import { useI18n } from '@/lib/i18n'; +import { isCapacitorApp } from '@/lib/platform'; +import { switchRuntimeEndpoint } from '@/lib/runtime-switch'; + +const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1'; +const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.'; +const MOBILE_CONNECTIONS_LIMIT = 12; +const MOBILE_CONNECT_TIMEOUT_MS = 8000; +const MOBILE_NATIVE_HTTP_TIMEOUT_MS = 2500; +const MOBILE_SECURE_TIMEOUT_MS = 3000; + +export type MobileSavedConnection = { + id: string; + label: string; + url: string; + lastUsedAt: number; + // Native: indicates a token exists in the secure store. Web: unused. + hasToken?: boolean; + // Web only: the token stored inline. On native this stays undefined in the list. + clientToken?: string; +}; + +export type MobilePendingConnection = { + label: string; + url: string; +}; + +export type MobileConnectInput = { + url: string; + clientToken?: string; + label?: string; +}; + +type MobileFetchResponse = { + ok: boolean; + status: number; + source: 'native-http' | 'browser-fetch'; + json: () => Promise; +}; + +type MobileSessionStatus = { + authenticated?: boolean; + disabled?: boolean; + scope?: string; +}; + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +export const normalizeConnectionUrl = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withScheme); + url.hash = ''; + url.search = ''; + url.pathname = url.pathname.replace(/\/+$/, ''); + return url.toString().replace(/\/+$/, ''); +}; + +export const getConnectionLabel = (url: string): string => { + try { + return new URL(url).host; + } catch { + return url; + } +}; + +const getConnectionStorageKey = (url: string): string => { + try { + return normalizeConnectionUrl(url); + } catch { + return url.trim().replace(/\/+$/g, ''); + } +}; + +export const isSameConnectionUrl = (left: string, right: string): boolean => + getConnectionStorageKey(left) === getConnectionStorageKey(right); + +// --------------------------------------------------------------------------- +// Request helpers (native CapacitorHttp first — needed to reach plain-http LAN +// servers the secure webview cannot fetch — then a browser-fetch fallback). +// --------------------------------------------------------------------------- + +const logConnect = (step: string, detail: Record = {}): void => { + console.info('[mobile-connect]', step, detail); +}; + +const logStorage = (step: string, detail: Record = {}): void => { + console.info('[mobile-storage]', step, detail); +}; + +const parseMaybeJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +}; + +const getJsonRequestData = (body: BodyInit | null | undefined): unknown => { + if (typeof body !== 'string') return body ?? undefined; + try { + return JSON.parse(body) as unknown; + } catch { + return body; + } +}; + +const nativeHttpRequest = async (url: string, init?: RequestInit): Promise => { + if (!isCapacitorApp()) return null; + try { + const { CapacitorHttp } = await import('@capacitor/core'); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + const response = await CapacitorHttp.request({ + url, + method: init?.method || 'GET', + headers, + data: getJsonRequestData(init?.body), + }); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + source: 'native-http', + json: async () => parseMaybeJson(response.data), + }; + } catch (error) { + console.warn('[mobile-connect] native-http failed', { url, error }); + return null; + } +}; + +const browserFetchRequest = async (url: string, init?: RequestInit): Promise => { + const response = await fetch(url, init).catch((error) => { + console.warn('[mobile-connect] browser-fetch failed', { url, error }); + return null; + }); + if (!response) return null; + return { ok: response.ok, status: response.status, source: 'browser-fetch', json: () => response.json() }; +}; + +const raceWithTimeout = async (timeoutMs: number, operation: Promise, onTimeout?: () => void): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => { + onTimeout?.(); + resolve(null); + }, timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } catch { + return null; + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +const requestWithTimeout = async (url: string, init?: RequestInit): Promise => { + const startedAt = Date.now(); + const native = await raceWithTimeout( + Math.min(MOBILE_NATIVE_HTTP_TIMEOUT_MS, MOBILE_CONNECT_TIMEOUT_MS), + nativeHttpRequest(url, init), + ); + if (native) return native; + + const controller = new AbortController(); + const remainingMs = Math.max(1000, MOBILE_CONNECT_TIMEOUT_MS - (Date.now() - startedAt)); + return raceWithTimeout( + remainingMs, + browserFetchRequest(url, { ...init, signal: controller.signal }), + () => controller.abort(), + ); +}; + +const readSessionStatus = async (response: MobileFetchResponse | null): Promise => { + if (!response) return null; + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== 'object') return null; + const record = payload as Record; + return { + authenticated: typeof record.authenticated === 'boolean' ? record.authenticated : undefined, + disabled: typeof record.disabled === 'boolean' ? record.disabled : undefined, + scope: typeof record.scope === 'string' ? record.scope : undefined, + }; +}; + +// --------------------------------------------------------------------------- +// Metadata storage (localStorage) — never holds the token on native. +// --------------------------------------------------------------------------- + +const readConnections = (): MobileSavedConnection[] => { + if (typeof window === 'undefined') return []; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + const native = isCapacitorApp(); + return parsed + .flatMap((item): MobileSavedConnection[] => { + if (!item || typeof item !== 'object') return []; + const c = item as Partial; + if (typeof c.id !== 'string' || typeof c.url !== 'string') return []; + const inlineToken = typeof c.clientToken === 'string' && c.clientToken.trim() ? c.clientToken : undefined; + const base: MobileSavedConnection = { + id: c.id, + label: typeof c.label === 'string' && c.label.trim() ? c.label : getConnectionLabel(c.url), + url: c.url, + lastUsedAt: typeof c.lastUsedAt === 'number' ? c.lastUsedAt : 0, + }; + if (native) return [{ ...base, hasToken: Boolean(c.hasToken) || Boolean(inlineToken) }]; + return [{ ...base, clientToken: inlineToken, hasToken: Boolean(inlineToken) }]; + }) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt); +}; + +const writeConnections = (connections: MobileSavedConnection[]): void => { + if (typeof window === 'undefined') return; + const native = isCapacitorApp(); + const serialized = connections.slice(0, MOBILE_CONNECTIONS_LIMIT).map((c) => ( + native + ? { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, hasToken: Boolean(c.hasToken || c.clientToken) } + : { id: c.id, label: c.label, url: c.url, lastUsedAt: c.lastUsedAt, clientToken: c.clientToken } + )); + try { + window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(serialized)); + } catch (error) { + console.warn('[mobile-storage] failed to persist connection metadata', error); + } +}; + +const upsertConnectionInList = ( + connections: MobileSavedConnection[], + draft: { label: string; url: string; clientToken?: string; hasToken?: boolean }, +): MobileSavedConnection[] => { + const key = getConnectionStorageKey(draft.url); + const existing = connections.find((item) => getConnectionStorageKey(item.url) === key); + const native = isCapacitorApp(); + const next: MobileSavedConnection = { + id: existing?.id || crypto.randomUUID(), + label: draft.label, + url: draft.url, + lastUsedAt: Date.now(), + ...(native + ? { hasToken: draft.hasToken ?? (Boolean(draft.clientToken) || existing?.hasToken || false) } + : { clientToken: draft.clientToken ?? existing?.clientToken, hasToken: Boolean(draft.clientToken ?? existing?.clientToken) }), + }; + return [ + next, + ...connections.filter((item) => item.id !== next.id && getConnectionStorageKey(item.url) !== key), + ].slice(0, MOBILE_CONNECTIONS_LIMIT); +}; + +// --------------------------------------------------------------------------- +// Secure token storage (native only), per-instance URL. Every call is bounded +// so a hung/unavailable Keychain can never block the connect flow. +// --------------------------------------------------------------------------- + +// We call the plugin's NATIVE methods (`internalSetItem`/`internalGetItem`/ +// `internalRemoveItem`) directly. Capacitor routes native methods straight to the +// iOS/Android plugin via the bridge — unlike the high-level `setItem`/`setKeyPrefix` +// JS methods, which make the `registerPlugin` proxy lazy-load its platform JS module +// (the step that stalls in this webview). We also build the prefixed key ourselves +// so we never touch the JS-only `setKeyPrefix`. +type NativeSecureStorage = { + internalSetItem: (options: { prefixedKey: string; data: string; sync: boolean; access: number }) => Promise; + internalGetItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ data: string | null }>; + internalRemoveItem: (options: { prefixedKey: string; sync: boolean }) => Promise<{ success: boolean }>; +}; + +const nativeSecure = SecureStorage as unknown as NativeSecureStorage; +const KEYCHAIN_ACCESS_WHEN_UNLOCKED = 0; // KeychainAccess.whenUnlocked + +const prefixedTokenKey = (url: string): string => + `${MOBILE_SECURE_STORAGE_PREFIX}token.${encodeURIComponent(getConnectionStorageKey(url))}`; + +const withTimeout = async (operation: Promise, fallback: T): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((resolve) => { + timeoutId = window.setTimeout(() => resolve(fallback), MOBILE_SECURE_TIMEOUT_MS); + }); + try { + return await Promise.race([operation.catch(() => fallback), timeout]); + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + } +}; + +// Bound a native Keychain call so a stalled/failed bridge can never hang the flow. +const boundedSecure = async (label: string, run: () => Promise, fallback: T): Promise => { + if (!isCapacitorApp()) return fallback; + return withTimeout( + run().catch((error) => { + console.warn(`[mobile-storage] ${label} failed`, error); + return fallback; + }), + fallback, + ); +}; + +const readSecureToken = async (url: string): Promise => { + logStorage('secure:read-start', { url }); + const value = await boundedSecure( + 'secure:read', + async () => (await nativeSecure.internalGetItem({ prefixedKey: prefixedTokenKey(url), sync: false })).data, + null, + ); + const token = typeof value === 'string' && value.trim() ? value : undefined; + logStorage('secure:read', { url, hasToken: Boolean(token) }); + return token; +}; + +const writeSecureToken = async (url: string, token: string): Promise => { + logStorage('secure:write-start', { url }); + const ok = await boundedSecure('secure:write', async () => { + await nativeSecure.internalSetItem({ + prefixedKey: prefixedTokenKey(url), + data: token, + sync: false, + access: KEYCHAIN_ACCESS_WHEN_UNLOCKED, + }); + return true; + }, false); + logStorage('secure:write', { url, ok }); + return ok; +}; + +const deleteSecureToken = async (url: string): Promise => { + await boundedSecure('secure:delete', async () => { + await nativeSecure.internalRemoveItem({ prefixedKey: prefixedTokenKey(url), sync: false }); + return true; + }, false); +}; + +// --------------------------------------------------------------------------- +// Public storage API +// --------------------------------------------------------------------------- + +// One-time migration: a legacy localStorage record on native might still carry an +// inline `clientToken`. Move it into the secure store and strip the metadata. +const migrateLegacyInlineTokens = async (): Promise => { + if (typeof window === 'undefined' || !isCapacitorApp()) return; + let parsed: unknown; + try { + parsed = JSON.parse(window.localStorage.getItem(MOBILE_CONNECTIONS_STORAGE_KEY) || '[]'); + } catch { + return; + } + if (!Array.isArray(parsed)) return; + const legacy = parsed.filter((item): item is { url: string; clientToken: string } => + Boolean(item) && typeof item === 'object' + && typeof (item as { url?: unknown }).url === 'string' + && typeof (item as { clientToken?: unknown }).clientToken === 'string' + && Boolean((item as { clientToken: string }).clientToken.trim())); + if (legacy.length === 0) return; + logStorage('secure:migrate-start', { count: legacy.length }); + for (const { url, clientToken } of legacy) { + await writeSecureToken(url, clientToken); + } + writeConnections(readConnections()); + logStorage('secure:migrate-done', { count: legacy.length }); +}; + +export const loadMobileConnections = async (): Promise => { + await migrateLegacyInlineTokens(); + return readConnections(); +}; + +export const upsertMobileConnection = async ( + connection: { label: string; url: string; clientToken?: string }, +): Promise => { + const next = upsertConnectionInList(readConnections(), connection); + writeConnections(next); + if (isCapacitorApp() && connection.clientToken) { + await writeSecureToken(connection.url, connection.clientToken); + } + return next; +}; + +export const deleteMobileConnection = async (id: string): Promise => { + const connections = readConnections(); + const removed = connections.find((connection) => connection.id === id) ?? null; + const next = connections.filter((connection) => connection.id !== id); + writeConnections(next); + if (removed && isCapacitorApp()) await deleteSecureToken(removed.url); + return next; +}; + +// Cold-launch auto-connect: silently reconnect to the most-recently-used saved +// instance so a returning user (and notification deep-links) land straight in the +// app instead of the connect screen. Returns true and switches the runtime endpoint +// when the instance is reachable AND we already have a usable bearer token; returns +// false — caller shows the connect screen — when there is no saved instance, it's +// unreachable, or it needs a (re)login. Mirrors the success path of +// `useMobileConnection.connect`, with no prompts or UI state. +export const autoConnectLastInstance = async (): Promise => { + await migrateLegacyInlineTokens(); + const candidate = readConnections()[0]; // sorted most-recent-first + if (!candidate) return false; + + const url = normalizeConnectionUrl(candidate.url); + if (!url) return false; + + // The native runtime transport needs a bearer token; only auto-connect when one is + // already saved. A missing/expired token must go through the login UI, not silently. + let token: string | undefined; + if (isCapacitorApp()) { + if (!candidate.hasToken) return false; + token = await readSecureToken(url); + if (!token) return false; + } else { + token = candidate.clientToken; + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + // Token rejected / session invalid → fall back to the login screen. + if (!session || (!session.ok && session.status !== 404)) return false; + const status = await readSessionStatus(session); + if (status && status.disabled !== true && status.authenticated === false) return false; + + await upsertMobileConnection({ label: candidate.label, url }); // bump lastUsedAt (keeps hasToken) + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + return true; +}; + +// --------------------------------------------------------------------------- +// Shared connection controller +// --------------------------------------------------------------------------- + +export type UseMobileConnection = { + connections: MobileSavedConnection[]; + isBusy: boolean; + isPasswordBusy: boolean; + error: string | null; + pendingConnection: MobilePendingConnection | null; + connect: (input: MobileConnectInput) => Promise; + submitPassword: (password: string) => Promise; + cancelPassword: () => void; + saveConnection: (input: MobileConnectInput) => Promise; + removeConnection: (id: string) => Promise; + setError: (message: string | null) => void; +}; + +// `onConnected` fires once the runtime endpoint is switched (the caller navigates +// away / closes its surface from there). +export const useMobileConnection = (onConnected: () => void): UseMobileConnection => { + const { t } = useI18n(); + const [connections, setConnections] = React.useState(() => readConnections()); + const [busyOperation, setBusyOperation] = React.useState<'connect' | 'password' | null>(null); + const [error, setError] = React.useState(null); + const [pendingConnection, setPendingConnection] = React.useState(null); + const connectionsRef = React.useRef(connections); + const busyRef = React.useRef<'connect' | 'password' | null>(null); + + const applyConnections = React.useCallback((next: MobileSavedConnection[]) => { + connectionsRef.current = next; + setConnections(next); + }, []); + + const beginBusy = React.useCallback((operation: 'connect' | 'password') => { + busyRef.current = operation; + setBusyOperation(operation); + }, []); + + const endBusy = React.useCallback((operation: 'connect' | 'password') => { + if (busyRef.current !== operation) return; + busyRef.current = null; + setBusyOperation(null); + }, []); + + // Refresh from storage on mount (runs the legacy-token migration too). + React.useEffect(() => { + let disposed = false; + void loadMobileConnections().then((loaded) => { + if (!disposed) applyConnections(loaded); + }); + return () => { disposed = true; }; + }, [applyConnections]); + + // Persist metadata for a connection and reflect it in state immediately. + const persistMetadata = React.useCallback((draft: { label: string; url: string; clientToken?: string }) => { + const next = upsertConnectionInList(connectionsRef.current, draft); + applyConnections(next); + writeConnections(next); + return next; + }, [applyConnections]); + + const connect = React.useCallback(async (input: MobileConnectInput) => { + setError(null); + beginBusy('connect'); + try { + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return; + } + + const label = input.label?.trim() + || connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url))?.label + || getConnectionLabel(url); + + // Resolve a token: explicit input wins, otherwise read the saved one from + // the secure store (single bounded read — never blocks the flow). + let token = input.clientToken?.trim() || undefined; + const tokenIsNew = Boolean(token); + if (!token && isCapacitorApp()) { + const saved = connectionsRef.current.find((c) => isSameConnectionUrl(c.url, url)); + if (saved?.hasToken) token = await readSecureToken(url); + } + + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + logConnect('health:start', { url }); + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + logConnect('health:done', { ok: health?.ok === true, source: health?.source ?? null, status: health?.status ?? null }); + if (!health?.ok) { + setError(t('mobile.connect.error.unreachable')); + return; + } + + logConnect('session:start', { url, hasToken: Boolean(token) }); + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + const status = await readSessionStatus(session); + logConnect('session:done', { ok: session?.ok === true, status: session?.status ?? null, scope: status?.scope ?? null, disabled: status?.disabled === true }); + + // A cookie-only native session (authenticated, but not a `client` bearer + // scope and not auth-disabled) is not enough — the runtime transport needs a + // bearer token, so fall through to the password flow to mint one. + const cookieOnlyNeedsToken = isCapacitorApp() + && session?.ok === true + && !token + && status?.authenticated === true + && status.disabled !== true + && status.scope !== 'client'; + + if (!token && (session?.status === 401 || cookieOnlyNeedsToken)) { + persistMetadata({ label, url }); + setPendingConnection({ label, url }); + return; + } + + if (!session || (!session.ok && session.status !== 404)) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Connected. If the token came from the user (not the secure store), persist + // it first so a cold restart won't re-prompt. + if (token && tokenIsNew && isCapacitorApp()) { + await writeSecureToken(url, token); + } + persistMetadata({ label, url, clientToken: token }); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: token ?? null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] connect threw', error); + setError(t('mobile.connect.error.invalidUrl')); + } finally { + endBusy('connect'); + } + }, [beginBusy, endBusy, onConnected, persistMetadata, t]); + + const submitPassword = React.useCallback(async (password: string) => { + if (!pendingConnection || !password.trim() || busyRef.current === 'password') return; + setError(null); + beginBusy('password'); + const { url, label } = pendingConnection; + try { + logConnect('password:start', { url }); + const response = await requestWithTimeout(`${url}/auth/session`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ password, trustDevice: true, issueClientToken: true, clientLabel: 'OpenChamber Mobile' }), + }); + logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null }); + if (!response?.ok) { + setError(t('mobile.connect.error.passwordFailed')); + return; + } + + const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; + const issuedToken = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + logConnect('password:token', { issued: Boolean(issuedToken) }); + + // Native runtime transport needs a bearer token; a cookie-only success is + // not acceptable for a saved protected instance. + if (isCapacitorApp() && !issuedToken) { + setError(t('mobile.connect.error.authRequired')); + return; + } + + // Guarantee the token is persisted BEFORE switching (no fire-and-forget). + if (isCapacitorApp() && issuedToken) { + await writeSecureToken(url, issuedToken); + } + persistMetadata({ label, url, clientToken: issuedToken || undefined }); + setPendingConnection(null); + switchRuntimeEndpoint({ apiBaseUrl: url, clientToken: issuedToken || null }); + onConnected(); + } catch (error) { + console.warn('[mobile-connect] password threw', error); + setError(t('mobile.connect.error.passwordFailed')); + } finally { + endBusy('password'); + } + }, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]); + + const cancelPassword = React.useCallback(() => { + setPendingConnection(null); + setError(null); + }, []); + + const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise => { + setError(null); + const url = normalizeConnectionUrl(input.url); + if (!url) { + setError(t('mobile.connect.error.urlRequired')); + return null; + } + const clientToken = input.clientToken?.trim() || undefined; + const label = input.label?.trim() || getConnectionLabel(url); + // Awaited token write so "Save" truly persisted the secret before returning. + if (isCapacitorApp() && clientToken) { + await writeSecureToken(url, clientToken); + } + const next = persistMetadata({ label, url, clientToken }); + return next.find((connection) => isSameConnectionUrl(connection.url, url)) ?? null; + }, [persistMetadata, t]); + + const removeConnection = React.useCallback(async (id: string): Promise => { + const removed = connectionsRef.current.find((connection) => connection.id === id) ?? null; + const next = await deleteMobileConnection(id); + applyConnections(next); + return removed; + }, [applyConnections]); + + return { + connections, + isBusy: busyOperation !== null, + isPasswordBusy: busyOperation === 'password', + error, + pendingConnection, + connect, + submitPassword, + cancelPassword, + saveConnection, + removeConnection, + setError, + }; +}; diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts new file mode 100644 index 0000000000..7fd77b1a89 --- /dev/null +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -0,0 +1,176 @@ +// Connection payload parsing + native QR scanning for the dedicated mobile app. +// +// The pairing link format is produced by `openchamber connect-url --qr`: +// openchamber://connect?v=1&server=&token=&label=
- {nativeNotificationsEnabled && canShowNotifications && ( + {/* The native Capacitor app never notifies while focused (hard rule) and uses + generic, non-customizable text, so the "notify while focused" toggle and the + test button are hidden there. */} + {nativeNotificationsEnabled && canShowNotifications && !isNativeApp && ( <>
{
- {/* --- Template Customization --- */} + {/* --- Template Customization (not on the native app — it uses generic text) --- */} + {!isNativeApp && (

@@ -666,6 +680,7 @@ export const NotificationSettings: React.FC = () => { ))}

+ )} )} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 509a7ca315..f25a821c6c 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -7,6 +7,7 @@ import type { ThemeMode } from '@/types/theme'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { cn } from '@/lib/utils'; +import { isCapacitorApp } from '@/lib/platform'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { NumberInput } from '@/components/ui/number-input'; @@ -321,6 +322,10 @@ export const OpenChamberVisualSettings: React.FC const setShowSplitAssistantMessageActions = useUIStore(state => state.setShowSplitAssistantMessageActions); const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport); const setMessageStreamTransport = useConfigStore((state) => state.setSettingsMessageStreamTransport); + // Capacitor apps are locked to SSE (native WebSocket streaming is unreliable on mobile); + // sync-context forces it too. Show SSE selected and disable the other options here. + const isCapacitorAppRuntime = React.useMemo(() => isCapacitorApp(), []); + const effectiveMessageStreamTransport = isCapacitorAppRuntime ? 'sse' : messageStreamTransport; const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const setSettingsDefaultFileViewerPreview = useConfigStore((state) => state.setSettingsDefaultFileViewerPreview); const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen); @@ -1525,7 +1530,8 @@ export const OpenChamberVisualSettings: React.FC key={option.id} variant="chip" size="xs" - aria-pressed={messageStreamTransport === option.id} + aria-pressed={effectiveMessageStreamTransport === option.id} + disabled={isCapacitorAppRuntime && option.id !== 'sse'} className="!font-normal" onClick={() => handleMessageStreamTransportChange(option.id)} > @@ -1535,7 +1541,7 @@ export const OpenChamberVisualSettings: React.FC {(() => { - const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === messageStreamTransport); + const option = MESSAGE_STREAM_TRANSPORT_OPTIONS.find((item) => item.id === effectiveMessageStreamTransport); return option?.descriptionKey ? tUnsafe(option.descriptionKey) : ''; })()} diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index acf21d3b4c..156fe911ff 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -85,7 +85,7 @@ export const MobileOverlayPanel: React.FC = ({ const content = (
{ }; const sendVisibility = (visible: boolean) => { - if (!isWebRuntime()) { + if (!isWebRuntime() && !isCapacitorApp()) { return; } @@ -19,13 +20,62 @@ const sendVisibility = (visible: boolean) => { return; } - void apis.push.setVisibility({ visible }); + // platform lets the server distinguish mobile (push recipients) from interactive surfaces + // (desktop/web/vscode) so it can suppress phone push only while an interactive client is visible. + void apis.push.setVisibility({ visible, platform: getClientPlatform() }); }; export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => { const enabled = options?.enabled ?? true; React.useEffect(() => { - if (!enabled || !isWebRuntime() || typeof document === 'undefined') { + if (!enabled || (!isWebRuntime() && !isCapacitorApp()) || typeof window === 'undefined') { + return; + } + + // Native (Capacitor): drive visibility AUTHORITATIVELY from App.appStateChange. The + // web signals (document.visibilityState / hasFocus) are unreliable in a WKWebView — + // hasFocus() often returns false while the app is active — which made the app report + // "hidden" while foregrounded and leaked push notifications. The server's focus gate + // suppresses push whenever a UI client is visible, so getting this right is what + // guarantees "no push while the app is active". + if (isCapacitorApp()) { + let active = true; + let disposed = false; + let removeListener: (() => void) | null = null; + const reportActive = () => sendVisibility(active); + + void import('@capacitor/app') + .then(async ({ App }) => { + if (disposed) return; + const state = await App.getState().catch(() => null); + if (state) active = state.isActive === true; + reportActive(); + const handle = await App.addListener('appStateChange', ({ isActive }) => { + active = isActive === true; + reportActive(); + }); + if (disposed) { + void handle.remove(); + return; + } + removeListener = () => void handle.remove(); + }) + .catch(() => undefined); + + // Heartbeat so the server's visibility TTL never expires while the app is active. + const interval = window.setInterval(() => { + if (active) sendVisibility(true); + }, HEARTBEAT_MS); + + return () => { + disposed = true; + window.clearInterval(interval); + removeListener?.(); + }; + } + + // Web / desktop: document-based visibility. + if (typeof document === 'undefined') { return; } @@ -45,7 +95,6 @@ export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => { report(); - // Heartbeat while visible so server TTL (30s) never expires. const interval = window.setInterval(reportVisibleOnly, HEARTBEAT_MS); document.addEventListener('visibilitychange', report); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d3539c293c..f2e3f3c403 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -759,17 +759,28 @@ export interface PushSubscribePayload { auth: string; }; origin?: string; + /** Runtime surface ('ios' | 'android' | 'vscode' | 'desktop' | 'web') for presence-aware routing. */ + platform?: string; } export interface PushUnsubscribePayload { endpoint: string; } +export interface ApnsTokenPayload { + token: string; + /** 'ios' (APNs) or 'android' (FCM) — lets the relay route the token to the right service. */ + platform?: string; +} + export interface PushAPI { getVapidPublicKey(): Promise<{ publicKey: string } | null>; subscribe(payload: PushSubscribePayload): Promise<{ ok: true } | null>; unsubscribe(payload: PushUnsubscribePayload): Promise<{ ok: true } | null>; - setVisibility(payload: { visible: boolean }): Promise<{ ok: true } | null>; + setVisibility(payload: { visible: boolean; platform?: string }): Promise<{ ok: true } | null>; + /** Register a native iOS APNs device token (Capacitor mobile app only). */ + registerApnsToken(payload: ApnsTokenPayload): Promise<{ ok: true } | null>; + unregisterApnsToken(payload: ApnsTokenPayload): Promise<{ ok: true } | null>; } export type GitHubUserSummary = { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f5dcb6d5a9..18375b2bdf 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -30,6 +30,44 @@ export const dict = { 'layout.mainTab.terminal': 'Terminal', 'layout.mainTab.context': 'Context', 'mobile.nav.aria': 'Mobile navigation', + 'mobile.connect.welcome.title': 'Connect to OpenChamber', + 'mobile.connect.welcome.description': 'Add a server URL or scan a pairing QR code to start using the mobile app.', + 'mobile.connect.url.label': 'Server URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Client token', + 'mobile.connect.token.placeholder': 'Paste access token', + 'mobile.connect.token.hint': 'Only needed if your server requires a token instead of a password.', + 'mobile.connect.password.label': 'Password', + 'mobile.connect.password.placeholder': 'OpenChamber password', + 'mobile.connect.connectButton': 'Connect', + 'mobile.connect.unlockButton': 'Unlock and connect', + 'mobile.connect.cancelPassword': 'Use another server', + 'mobile.connect.connecting': 'Connecting...', + 'mobile.connect.scanQr': 'Scan QR code', + 'mobile.connect.advanced': 'Advanced', + 'mobile.connect.scan.permissionDenied': 'Camera access is off. Enable it in Settings to scan a QR code.', + 'mobile.connect.scan.failed': 'Could not scan that QR code. Try again or enter the URL manually.', + 'mobile.connect.scan.invalid': 'That QR code is not an OpenChamber connection code.', + 'mobile.connect.scan.unsupported': 'QR scanning is only available in the installed mobile app.', + 'mobile.connect.saved.title': 'Saved connections', + 'mobile.connect.saved.empty': 'No saved connections yet.', + 'mobile.connect.error.urlRequired': 'Enter a server URL.', + 'mobile.connect.error.invalidUrl': 'That server URL is not valid.', + 'mobile.connect.error.unreachable': 'Could not reach that OpenChamber server.', + 'mobile.connect.error.authRequired': 'This server needs a password or client token.', + 'mobile.connect.error.passwordFailed': 'Could not unlock that server. Check the password.', + 'mobile.instances.addTitle': 'Add instance', + 'mobile.instances.editTitle': 'Edit instance', + 'mobile.instances.edit': 'Edit', + 'mobile.instances.delete': 'Delete', + 'mobile.instances.deleteAria': 'Delete {label}', + 'mobile.instances.confirmDeleteAria': 'Confirm deleting {label}', + 'mobile.instances.cancelDeleteAria': 'Keep {label}', + 'mobile.instances.cancelEdit': 'Cancel', + 'mobile.instances.label.label': 'Name', + 'mobile.instances.label.placeholder': 'Optional display name', + 'mobile.instances.saveNew': 'Save instance', + 'mobile.instances.saveEdit': 'Save changes', 'mobile.nav.changes': 'Changes', 'mobile.nav.settings': 'Settings', 'mobile.surface.closeAria': 'Close', @@ -42,6 +80,7 @@ export const dict = { 'mobile.menu.files': 'Files', 'mobile.menu.changes': 'Changes', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instances', 'mobile.menu.update': 'Update', 'mobile.menu.settings': 'Settings', 'mobile.sessions.newChatCta': 'New chat in {project}', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 7d4bba4f5f..d1a9300516 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -31,6 +31,44 @@ export const dict: Record = { "layout.mainTab.terminal": "Terminal", "layout.mainTab.context": "Contexto", "mobile.nav.aria": "Navegación móvil", + "mobile.connect.welcome.title": "Conéctate a OpenChamber", + "mobile.connect.welcome.description": "Agrega una URL de servidor o escanea un código QR de emparejamiento para empezar a usar la app móvil.", + "mobile.connect.url.label": "URL del servidor", + "mobile.connect.url.placeholder": "http://192.168.1.74:2606", + "mobile.connect.token.label": "Token de cliente", + "mobile.connect.token.placeholder": "Pega el token de acceso", + "mobile.connect.token.hint": "Solo es necesario si tu servidor requiere un token en lugar de una contraseña.", + "mobile.connect.password.label": "Contraseña", + "mobile.connect.password.placeholder": "Contraseña de OpenChamber", + "mobile.connect.connectButton": "Conectar", + "mobile.connect.unlockButton": "Desbloquear y conectar", + "mobile.connect.cancelPassword": "Usar otro servidor", + "mobile.connect.connecting": "Conectando...", + "mobile.connect.scanQr": "Escanear código QR", + "mobile.connect.advanced": "Avanzado", + "mobile.connect.scan.permissionDenied": "El acceso a la cámara está desactivado. Actívalo en Ajustes para escanear un código QR.", + "mobile.connect.scan.failed": "No se pudo escanear ese código QR. Inténtalo de nuevo o introduce la URL manualmente.", + "mobile.connect.scan.invalid": "Ese código QR no es un código de conexión de OpenChamber.", + "mobile.connect.scan.unsupported": "El escaneo de QR solo está disponible en la app móvil instalada.", + "mobile.connect.saved.title": "Conexiones guardadas", + "mobile.connect.saved.empty": "Aún no hay conexiones guardadas.", + "mobile.connect.error.urlRequired": "Introduce una URL de servidor.", + "mobile.connect.error.invalidUrl": "Esa URL de servidor no es válida.", + "mobile.connect.error.unreachable": "No se pudo conectar con ese servidor de OpenChamber.", + "mobile.connect.error.authRequired": "Este servidor requiere una contraseña o un token de cliente.", + "mobile.connect.error.passwordFailed": "No se pudo desbloquear ese servidor. Revisa la contraseña.", + "mobile.instances.addTitle": "Agregar instancia", + "mobile.instances.editTitle": "Editar instancia", + "mobile.instances.edit": "Editar", + "mobile.instances.delete": "Eliminar", + "mobile.instances.deleteAria": "Eliminar {label}", + "mobile.instances.confirmDeleteAria": "Confirmar la eliminación de {label}", + "mobile.instances.cancelDeleteAria": "Conservar {label}", + "mobile.instances.cancelEdit": "Cancelar", + "mobile.instances.label.label": "Nombre", + "mobile.instances.label.placeholder": "Nombre para mostrar (opcional)", + "mobile.instances.saveNew": "Guardar instancia", + "mobile.instances.saveEdit": "Guardar cambios", "mobile.nav.changes": "Cambios", "mobile.nav.settings": "Ajustes", "mobile.surface.closeAria": "Cerrar", @@ -43,6 +81,7 @@ export const dict: Record = { "mobile.menu.files": "Archivos", "mobile.menu.changes": "Cambios", "mobile.menu.mcp": "MCP", + "mobile.menu.instances": "Instancias", "mobile.menu.update": "Actualizar", "mobile.menu.settings": "Ajustes", "mobile.sessions.newChatCta": "Nuevo chat en {project}", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 335d431f1c..c4eb7f12d7 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2448,6 +2448,44 @@ export const dict = { 'quota.window.premiumInteractions': 'Interactions premium', 'layout.mainTab.diagram': 'Diagramme', 'mobile.nav.aria': 'Navigation mobile', + 'mobile.connect.welcome.title': 'Se connecter à OpenChamber', + 'mobile.connect.welcome.description': 'Ajoutez une URL de serveur ou scannez un code QR d\'appairage pour commencer à utiliser l\'app mobile.', + 'mobile.connect.url.label': 'URL du serveur', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Jeton client', + 'mobile.connect.token.placeholder': 'Collez le jeton d\'accès', + 'mobile.connect.token.hint': 'Nécessaire uniquement si votre serveur exige un jeton au lieu d\'un mot de passe.', + 'mobile.connect.password.label': 'Mot de passe', + 'mobile.connect.password.placeholder': 'Mot de passe OpenChamber', + 'mobile.connect.connectButton': 'Se connecter', + 'mobile.connect.unlockButton': 'Déverrouiller et se connecter', + 'mobile.connect.cancelPassword': 'Utiliser un autre serveur', + 'mobile.connect.connecting': 'Connexion...', + 'mobile.connect.scanQr': 'Scanner le code QR', + 'mobile.connect.advanced': 'Avancé', + 'mobile.connect.scan.permissionDenied': 'L\'accès à la caméra est désactivé. Activez-le dans les Réglages pour scanner un code QR.', + 'mobile.connect.scan.failed': 'Impossible de scanner ce code QR. Réessayez ou saisissez l\'URL manuellement.', + 'mobile.connect.scan.invalid': 'Ce code QR n\'est pas un code de connexion OpenChamber.', + 'mobile.connect.scan.unsupported': 'Le scan QR est disponible uniquement dans l\'app mobile installée.', + 'mobile.connect.saved.title': 'Connexions enregistrées', + 'mobile.connect.saved.empty': 'Aucune connexion enregistrée pour le moment.', + 'mobile.connect.error.urlRequired': 'Saisissez une URL de serveur.', + 'mobile.connect.error.invalidUrl': 'Cette URL de serveur n\'est pas valide.', + 'mobile.connect.error.unreachable': 'Impossible de joindre ce serveur OpenChamber.', + 'mobile.connect.error.authRequired': 'Ce serveur nécessite un mot de passe ou un jeton client.', + 'mobile.connect.error.passwordFailed': 'Impossible de déverrouiller ce serveur. Vérifiez le mot de passe.', + 'mobile.instances.addTitle': 'Ajouter une instance', + 'mobile.instances.editTitle': 'Modifier l\'instance', + 'mobile.instances.edit': 'Modifier', + 'mobile.instances.delete': 'Supprimer', + 'mobile.instances.deleteAria': 'Supprimer {label}', + 'mobile.instances.confirmDeleteAria': 'Confirmer la suppression de {label}', + 'mobile.instances.cancelDeleteAria': 'Conserver {label}', + 'mobile.instances.cancelEdit': 'Annuler', + 'mobile.instances.label.label': 'Nom', + 'mobile.instances.label.placeholder': 'Nom d\'affichage facultatif', + 'mobile.instances.saveNew': 'Enregistrer l\'instance', + 'mobile.instances.saveEdit': 'Enregistrer les modifications', 'mobile.nav.changes': 'Modifications', 'mobile.nav.settings': 'Paramètres', 'mobile.surface.closeAria': 'Fermer', @@ -2460,6 +2498,7 @@ export const dict = { 'mobile.menu.files': 'Fichiers', 'mobile.menu.changes': 'Modifications', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instances', 'mobile.menu.update': 'Mettre à jour', 'mobile.menu.settings': 'Paramètres', 'mobile.sessions.newChatCta': 'Nouveau chat dans {project}', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 97e58f3747..306ae0fe7e 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -33,6 +33,45 @@ export const dict: Record = { 'mobile.nav.aria': 'モバイルナビゲーション', 'mobile.nav.changes': '変更', 'mobile.nav.settings': '設定', + 'mobile.menu.instances': 'インスタンス', + 'mobile.connect.welcome.title': 'OpenChamber に接続', + 'mobile.connect.welcome.description': 'サーバー URL を追加するか、ペアリング QR コードをスキャンしてモバイルアプリを使い始めましょう。', + 'mobile.connect.url.label': 'サーバー URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.scanQr': 'QR コードをスキャン', + 'mobile.connect.advanced': '詳細設定', + 'mobile.connect.token.label': 'クライアントトークン', + 'mobile.connect.token.placeholder': 'アクセストークンを貼り付け', + 'mobile.connect.token.hint': 'サーバーがパスワードの代わりにトークンを必要とする場合のみ必要です。', + 'mobile.connect.connectButton': '接続', + 'mobile.connect.connecting': '接続中...', + 'mobile.connect.password.label': 'パスワード', + 'mobile.connect.password.placeholder': 'OpenChamber のパスワード', + 'mobile.connect.unlockButton': 'ロックを解除して接続', + 'mobile.connect.cancelPassword': '別のサーバーを使用', + 'mobile.connect.saved.title': '保存された接続', + 'mobile.connect.saved.empty': '保存された接続はまだありません。', + 'mobile.connect.error.urlRequired': 'サーバー URL を入力してください。', + 'mobile.connect.error.invalidUrl': 'そのサーバー URL は無効です。', + 'mobile.connect.error.unreachable': 'その OpenChamber サーバーに接続できませんでした。', + 'mobile.connect.error.authRequired': 'このサーバーにはパスワードまたはクライアントトークンが必要です。', + 'mobile.connect.error.passwordFailed': 'サーバーのロックを解除できませんでした。パスワードを確認してください。', + 'mobile.connect.scan.unsupported': 'QR スキャンはインストール済みのモバイルアプリでのみ利用できます。', + 'mobile.connect.scan.permissionDenied': 'カメラへのアクセスがオフになっています。QR コードを読み取るには設定で有効にしてください。', + 'mobile.connect.scan.invalid': 'その QR コードは OpenChamber の接続コードではありません。', + 'mobile.connect.scan.failed': 'その QR コードを読み取れませんでした。もう一度試すか、URL を手動で入力してください。', + 'mobile.instances.addTitle': 'インスタンスを追加', + 'mobile.instances.editTitle': 'インスタンスを編集', + 'mobile.instances.label.label': '名前', + 'mobile.instances.label.placeholder': '表示名(任意)', + 'mobile.instances.saveNew': 'インスタンスを保存', + 'mobile.instances.saveEdit': '変更を保存', + 'mobile.instances.cancelEdit': 'キャンセル', + 'mobile.instances.edit': '編集', + 'mobile.instances.delete': '削除', + 'mobile.instances.deleteAria': '{label} を削除', + 'mobile.instances.confirmDeleteAria': '{label} の削除を確定', + 'mobile.instances.cancelDeleteAria': '{label} を残す', 'mobile.surface.closeAria': '閉じる', 'mobile.header.openMenuAria': 'メニューを開く', 'mobile.header.openMetadataAria': 'セッションメタデータを開く', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0e59093434..d329885d26 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -31,6 +31,44 @@ export const dict: Record = { 'layout.mainTab.terminal': '터미널', 'layout.mainTab.context': '컨텍스트', 'mobile.nav.aria': '모바일 내비게이션', + 'mobile.connect.welcome.title': 'OpenChamber에 연결', + 'mobile.connect.welcome.description': '서버 URL을 추가하거나 페어링 QR 코드를 스캔하여 모바일 앱을 시작하세요.', + 'mobile.connect.url.label': '서버 URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': '클라이언트 토큰', + 'mobile.connect.token.placeholder': '액세스 토큰 붙여넣기', + 'mobile.connect.token.hint': '서버가 비밀번호 대신 토큰을 요구하는 경우에만 필요합니다.', + 'mobile.connect.password.label': '비밀번호', + 'mobile.connect.password.placeholder': 'OpenChamber 비밀번호', + 'mobile.connect.connectButton': '연결', + 'mobile.connect.unlockButton': '잠금 해제 후 연결', + 'mobile.connect.cancelPassword': '다른 서버 사용', + 'mobile.connect.connecting': '연결 중...', + 'mobile.connect.scanQr': 'QR 코드 스캔', + 'mobile.connect.advanced': '고급', + 'mobile.connect.scan.permissionDenied': '카메라 접근이 꺼져 있습니다. QR 코드를 스캔하려면 설정에서 사용 설정하세요.', + 'mobile.connect.scan.failed': 'QR 코드를 스캔하지 못했습니다. 다시 시도하거나 URL을 직접 입력하세요.', + 'mobile.connect.scan.invalid': '이 QR 코드는 OpenChamber 연결 코드가 아닙니다.', + 'mobile.connect.scan.unsupported': 'QR 스캔은 설치된 모바일 앱에서만 사용할 수 있습니다.', + 'mobile.connect.saved.title': '저장된 연결', + 'mobile.connect.saved.empty': '아직 저장된 연결이 없습니다.', + 'mobile.connect.error.urlRequired': '서버 URL을 입력하세요.', + 'mobile.connect.error.invalidUrl': '유효하지 않은 서버 URL입니다.', + 'mobile.connect.error.unreachable': '해당 OpenChamber 서버에 연결할 수 없습니다.', + 'mobile.connect.error.authRequired': '이 서버에는 비밀번호 또는 클라이언트 토큰이 필요합니다.', + 'mobile.connect.error.passwordFailed': '서버 잠금을 해제할 수 없습니다. 비밀번호를 확인하세요.', + 'mobile.instances.addTitle': '인스턴스 추가', + 'mobile.instances.editTitle': '인스턴스 편집', + 'mobile.instances.edit': '편집', + 'mobile.instances.delete': '삭제', + 'mobile.instances.deleteAria': '{label} 삭제', + 'mobile.instances.confirmDeleteAria': '{label} 삭제 확인', + 'mobile.instances.cancelDeleteAria': '{label} 유지', + 'mobile.instances.cancelEdit': '취소', + 'mobile.instances.label.label': '이름', + 'mobile.instances.label.placeholder': '표시 이름 (선택 사항)', + 'mobile.instances.saveNew': '인스턴스 저장', + 'mobile.instances.saveEdit': '변경 사항 저장', 'mobile.nav.changes': '변경사항', 'mobile.nav.settings': '설정', 'mobile.surface.closeAria': '닫기', @@ -43,6 +81,7 @@ export const dict: Record = { 'mobile.menu.files': '파일', 'mobile.menu.changes': '변경사항', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': '인스턴스', 'mobile.menu.update': '업데이트', 'mobile.menu.settings': '설정', 'mobile.sessions.newChatCta': '{project}에서 새 채팅', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f4c2f7286c..30cd07a15b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -32,6 +32,44 @@ export const dict: Record = { 'layout.mainTab.terminal': 'Terminal', 'layout.mainTab.context': 'Kontekst', 'mobile.nav.aria': 'Nawigacja mobilna', + 'mobile.connect.welcome.title': 'Połącz z OpenChamber', + 'mobile.connect.welcome.description': 'Dodaj adres URL serwera lub zeskanuj kod QR parowania, aby zacząć korzystać z aplikacji mobilnej.', + 'mobile.connect.url.label': 'Adres URL serwera', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Token klienta', + 'mobile.connect.token.placeholder': 'Wklej token dostępu', + 'mobile.connect.token.hint': 'Potrzebny tylko, gdy serwer wymaga tokenu zamiast hasła.', + 'mobile.connect.password.label': 'Hasło', + 'mobile.connect.password.placeholder': 'Hasło OpenChamber', + 'mobile.connect.connectButton': 'Połącz', + 'mobile.connect.unlockButton': 'Odblokuj i połącz', + 'mobile.connect.cancelPassword': 'Użyj innego serwera', + 'mobile.connect.connecting': 'Łączenie...', + 'mobile.connect.scanQr': 'Skanuj kod QR', + 'mobile.connect.advanced': 'Zaawansowane', + 'mobile.connect.scan.permissionDenied': 'Dostęp do aparatu jest wyłączony. Włącz go w Ustawieniach, aby zeskanować kod QR.', + 'mobile.connect.scan.failed': 'Nie udało się zeskanować tego kodu QR. Spróbuj ponownie lub wpisz adres URL ręcznie.', + 'mobile.connect.scan.invalid': 'Ten kod QR nie jest kodem połączenia OpenChamber.', + 'mobile.connect.scan.unsupported': 'Skanowanie QR jest dostępne tylko w zainstalowanej aplikacji mobilnej.', + 'mobile.connect.saved.title': 'Zapisane połączenia', + 'mobile.connect.saved.empty': 'Brak zapisanych połączeń.', + 'mobile.connect.error.urlRequired': 'Podaj adres URL serwera.', + 'mobile.connect.error.invalidUrl': 'Ten adres URL serwera jest nieprawidłowy.', + 'mobile.connect.error.unreachable': 'Nie udało się połączyć z tym serwerem OpenChamber.', + 'mobile.connect.error.authRequired': 'Ten serwer wymaga hasła lub tokenu klienta.', + 'mobile.connect.error.passwordFailed': 'Nie udało się odblokować tego serwera. Sprawdź hasło.', + 'mobile.instances.addTitle': 'Dodaj instancję', + 'mobile.instances.editTitle': 'Edytuj instancję', + 'mobile.instances.edit': 'Edytuj', + 'mobile.instances.delete': 'Usuń', + 'mobile.instances.deleteAria': 'Usuń {label}', + 'mobile.instances.confirmDeleteAria': 'Potwierdź usunięcie {label}', + 'mobile.instances.cancelDeleteAria': 'Zachowaj {label}', + 'mobile.instances.cancelEdit': 'Anuluj', + 'mobile.instances.label.label': 'Nazwa', + 'mobile.instances.label.placeholder': 'Opcjonalna nazwa wyświetlana', + 'mobile.instances.saveNew': 'Zapisz instancję', + 'mobile.instances.saveEdit': 'Zapisz zmiany', 'mobile.nav.changes': 'Zmiany', 'mobile.nav.settings': 'Ustawienia', 'mobile.surface.closeAria': 'Zamknij', @@ -44,6 +82,7 @@ export const dict: Record = { 'mobile.menu.files': 'Pliki', 'mobile.menu.changes': 'Zmiany', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instancje', 'mobile.menu.update': 'Aktualizuj', 'mobile.menu.settings': 'Ustawienia', 'mobile.sessions.newChatCta': 'Nowy czat w {project}', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 5063bb75ea..bcbf1aed3d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -31,6 +31,44 @@ export const dict: Record = { "layout.mainTab.terminal": "Terminal", "layout.mainTab.context": "Contexto", "mobile.nav.aria": "Navegação móvel", + "mobile.connect.welcome.title": "Conectar ao OpenChamber", + "mobile.connect.welcome.description": "Adicione a URL de um servidor ou leia um código QR de pareamento para começar a usar o app móvel.", + "mobile.connect.url.label": "URL do servidor", + "mobile.connect.url.placeholder": "http://192.168.1.74:2606", + "mobile.connect.token.label": "Token do cliente", + "mobile.connect.token.placeholder": "Cole o token de acesso", + "mobile.connect.token.hint": "Só é necessário se o seu servidor exigir um token em vez de senha.", + "mobile.connect.password.label": "Senha", + "mobile.connect.password.placeholder": "Senha do OpenChamber", + "mobile.connect.connectButton": "Conectar", + "mobile.connect.unlockButton": "Desbloquear e conectar", + "mobile.connect.cancelPassword": "Usar outro servidor", + "mobile.connect.connecting": "Conectando...", + "mobile.connect.scanQr": "Ler código QR", + "mobile.connect.advanced": "Avançado", + "mobile.connect.scan.permissionDenied": "O acesso à câmera está desativado. Ative-o nos Ajustes para ler um código QR.", + "mobile.connect.scan.failed": "Não foi possível ler esse código QR. Tente novamente ou digite a URL manualmente.", + "mobile.connect.scan.invalid": "Esse código QR não é um código de conexão do OpenChamber.", + "mobile.connect.scan.unsupported": "A leitura de QR só está disponível no app móvel instalado.", + "mobile.connect.saved.title": "Conexões salvas", + "mobile.connect.saved.empty": "Nenhuma conexão salva ainda.", + "mobile.connect.error.urlRequired": "Informe a URL de um servidor.", + "mobile.connect.error.invalidUrl": "Essa URL de servidor não é válida.", + "mobile.connect.error.unreachable": "Não foi possível acessar esse servidor OpenChamber.", + "mobile.connect.error.authRequired": "Este servidor requer uma senha ou token do cliente.", + "mobile.connect.error.passwordFailed": "Não foi possível desbloquear esse servidor. Verifique a senha.", + "mobile.instances.addTitle": "Adicionar instância", + "mobile.instances.editTitle": "Editar instância", + "mobile.instances.edit": "Editar", + "mobile.instances.delete": "Excluir", + "mobile.instances.deleteAria": "Excluir {label}", + "mobile.instances.confirmDeleteAria": "Confirmar exclusão de {label}", + "mobile.instances.cancelDeleteAria": "Manter {label}", + "mobile.instances.cancelEdit": "Cancelar", + "mobile.instances.label.label": "Nome", + "mobile.instances.label.placeholder": "Nome de exibição opcional", + "mobile.instances.saveNew": "Salvar instância", + "mobile.instances.saveEdit": "Salvar alterações", "mobile.nav.changes": "Alterações", "mobile.nav.settings": "Configurações", "mobile.surface.closeAria": "Fechar", @@ -43,6 +81,7 @@ export const dict: Record = { "mobile.menu.files": "Arquivos", "mobile.menu.changes": "Alterações", "mobile.menu.mcp": "MCP", + "mobile.menu.instances": "Instâncias", "mobile.menu.update": "Atualizar", "mobile.menu.settings": "Configurações", "mobile.sessions.newChatCta": "Novo chat em {project}", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 5fca9424c2..38414a0aa5 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -31,6 +31,44 @@ export const dict: Record = { "layout.mainTab.terminal": "Термінал", "layout.mainTab.context": "Контекст", "mobile.nav.aria": "Мобільна навігація", + "mobile.connect.welcome.title": "Підключись до OpenChamber", + "mobile.connect.welcome.description": "Додай адресу сервера або відскануй QR-код pairing, щоб почати користуватись мобільною апкою.", + "mobile.connect.url.label": "Адреса сервера", + "mobile.connect.url.placeholder": "http://192.168.1.74:2606", + "mobile.connect.token.label": "Токен клієнта", + "mobile.connect.token.placeholder": "Встав токен доступу", + "mobile.connect.token.hint": "Потрібен, лише якщо сервер вимагає токен замість пароля.", + "mobile.connect.password.label": "Пароль", + "mobile.connect.password.placeholder": "Пароль OpenChamber", + "mobile.connect.connectButton": "Підключити", + "mobile.connect.unlockButton": "Розблокувати і підключити", + "mobile.connect.cancelPassword": "Інший сервер", + "mobile.connect.connecting": "Підключення...", + "mobile.connect.scanQr": "Сканувати QR-код", + "mobile.connect.advanced": "Додатково", + "mobile.connect.scan.permissionDenied": "Доступ до камери вимкнено. Увімкни його в Налаштуваннях, щоб сканувати QR-код.", + "mobile.connect.scan.failed": "Не вдалося відсканувати QR-код. Спробуй ще раз або введи адресу вручну.", + "mobile.connect.scan.invalid": "Це не QR-код підключення OpenChamber.", + "mobile.connect.scan.unsupported": "Сканування QR доступне лише у встановленій мобільній апці.", + "mobile.connect.saved.title": "Збережені підключення", + "mobile.connect.saved.empty": "Збережених підключень ще немає.", + "mobile.connect.error.urlRequired": "Введи адресу сервера.", + "mobile.connect.error.invalidUrl": "Ця адреса сервера некоректна.", + "mobile.connect.error.unreachable": "Не вдалося достукатись до цього OpenChamber сервера.", + "mobile.connect.error.authRequired": "Цьому серверу потрібен пароль або client token.", + "mobile.connect.error.passwordFailed": "Не вдалося розблокувати сервер. Перевір пароль.", + "mobile.instances.addTitle": "Додати інстанс", + "mobile.instances.editTitle": "Редагувати інстанс", + "mobile.instances.edit": "Редагувати", + "mobile.instances.delete": "Видалити", + "mobile.instances.deleteAria": "Видалити {label}", + "mobile.instances.confirmDeleteAria": "Підтвердити видалення {label}", + "mobile.instances.cancelDeleteAria": "Залишити {label}", + "mobile.instances.cancelEdit": "Скасувати", + "mobile.instances.label.label": "Назва", + "mobile.instances.label.placeholder": "Необовʼязкова назва", + "mobile.instances.saveNew": "Зберегти інстанс", + "mobile.instances.saveEdit": "Зберегти зміни", "mobile.nav.changes": "Зміни", "mobile.nav.settings": "Налаштування", "mobile.surface.closeAria": "Закрити", @@ -43,6 +81,7 @@ export const dict: Record = { "mobile.menu.files": "Файли", "mobile.menu.changes": "Зміни", "mobile.menu.mcp": "MCP", + "mobile.menu.instances": "Інстанси", "mobile.menu.update": "Оновити", "mobile.menu.settings": "Налаштування", "mobile.sessions.newChatCta": "Новий чат у {project}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 82b4dced04..09ad583343 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -31,6 +31,44 @@ export const dict: Record = { 'layout.mainTab.terminal': '终端', 'layout.mainTab.context': '上下文', 'mobile.nav.aria': '移动导航', + 'mobile.connect.welcome.title': '连接到 OpenChamber', + 'mobile.connect.welcome.description': '添加服务器 URL 或扫描配对二维码即可开始使用移动应用。', + 'mobile.connect.url.label': '服务器 URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': '客户端令牌', + 'mobile.connect.token.placeholder': '粘贴访问令牌', + 'mobile.connect.token.hint': '仅当服务器需要令牌而非密码时才需要填写。', + 'mobile.connect.password.label': '密码', + 'mobile.connect.password.placeholder': 'OpenChamber 密码', + 'mobile.connect.connectButton': '连接', + 'mobile.connect.unlockButton': '解锁并连接', + 'mobile.connect.cancelPassword': '使用其他服务器', + 'mobile.connect.connecting': '连接中...', + 'mobile.connect.scanQr': '扫描二维码', + 'mobile.connect.advanced': '高级', + 'mobile.connect.scan.permissionDenied': '相机访问已关闭。请在“设置”中开启以扫描二维码。', + 'mobile.connect.scan.failed': '无法扫描该二维码。请重试或手动输入网址。', + 'mobile.connect.scan.invalid': '该二维码不是 OpenChamber 连接码。', + 'mobile.connect.scan.unsupported': '二维码扫描仅在已安装的移动应用中可用。', + 'mobile.connect.saved.title': '已保存的连接', + 'mobile.connect.saved.empty': '暂无已保存的连接。', + 'mobile.connect.error.urlRequired': '请输入服务器 URL。', + 'mobile.connect.error.invalidUrl': '该服务器 URL 无效。', + 'mobile.connect.error.unreachable': '无法连接到该 OpenChamber 服务器。', + 'mobile.connect.error.authRequired': '该服务器需要密码或客户端令牌。', + 'mobile.connect.error.passwordFailed': '无法解锁该服务器。请检查密码。', + 'mobile.instances.addTitle': '添加实例', + 'mobile.instances.editTitle': '编辑实例', + 'mobile.instances.edit': '编辑', + 'mobile.instances.delete': '删除', + 'mobile.instances.deleteAria': '删除 {label}', + 'mobile.instances.confirmDeleteAria': '确认删除 {label}', + 'mobile.instances.cancelDeleteAria': '保留 {label}', + 'mobile.instances.cancelEdit': '取消', + 'mobile.instances.label.label': '名称', + 'mobile.instances.label.placeholder': '可选显示名称', + 'mobile.instances.saveNew': '保存实例', + 'mobile.instances.saveEdit': '保存更改', 'mobile.nav.changes': '更改', 'mobile.nav.settings': '设置', 'mobile.surface.closeAria': '关闭', @@ -43,6 +81,7 @@ export const dict: Record = { 'mobile.menu.files': '文件', 'mobile.menu.changes': '更改', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': '实例', 'mobile.menu.update': '更新', 'mobile.menu.settings': '设置', 'mobile.sessions.newChatCta': '在 {project} 中新建会话', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 5b1d666c41..34cdf13410 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -31,6 +31,44 @@ export const dict: Record = { 'layout.mainTab.terminal': '終端機', 'layout.mainTab.context': '上下文', 'mobile.nav.aria': '行動導覽', + 'mobile.connect.welcome.title': '連線至 OpenChamber', + 'mobile.connect.welcome.description': '新增伺服器網址或掃描配對 QR 碼,即可開始使用行動應用程式。', + 'mobile.connect.url.label': '伺服器網址', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': '用戶端權杖', + 'mobile.connect.token.placeholder': '貼上存取權杖', + 'mobile.connect.token.hint': '僅當伺服器需要權杖而非密碼時才需要填寫。', + 'mobile.connect.password.label': '密碼', + 'mobile.connect.password.placeholder': 'OpenChamber 密碼', + 'mobile.connect.connectButton': '連線', + 'mobile.connect.unlockButton': '解鎖並連線', + 'mobile.connect.cancelPassword': '使用其他伺服器', + 'mobile.connect.connecting': '連線中...', + 'mobile.connect.scanQr': '掃描 QR code', + 'mobile.connect.advanced': '進階', + 'mobile.connect.scan.permissionDenied': '相機存取已關閉。請在「設定」中開啟以掃描 QR code。', + 'mobile.connect.scan.failed': '無法掃描該 QR code。請重試或手動輸入網址。', + 'mobile.connect.scan.invalid': '此 QR code 不是 OpenChamber 連線代碼。', + 'mobile.connect.scan.unsupported': 'QR code 掃描僅在已安裝的行動應用程式中可用。', + 'mobile.connect.saved.title': '已儲存的連線', + 'mobile.connect.saved.empty': '尚未儲存任何連線。', + 'mobile.connect.error.urlRequired': '請輸入伺服器網址。', + 'mobile.connect.error.invalidUrl': '該伺服器網址無效。', + 'mobile.connect.error.unreachable': '無法連線至該 OpenChamber 伺服器。', + 'mobile.connect.error.authRequired': '此伺服器需要密碼或用戶端權杖。', + 'mobile.connect.error.passwordFailed': '無法解鎖該伺服器。請檢查密碼。', + 'mobile.instances.addTitle': '新增執行個體', + 'mobile.instances.editTitle': '編輯執行個體', + 'mobile.instances.edit': '編輯', + 'mobile.instances.delete': '刪除', + 'mobile.instances.deleteAria': '刪除 {label}', + 'mobile.instances.confirmDeleteAria': '確認刪除 {label}', + 'mobile.instances.cancelDeleteAria': '保留 {label}', + 'mobile.instances.cancelEdit': '取消', + 'mobile.instances.label.label': '名稱', + 'mobile.instances.label.placeholder': '選填顯示名稱', + 'mobile.instances.saveNew': '儲存執行個體', + 'mobile.instances.saveEdit': '儲存變更', 'mobile.nav.changes': '變更', 'mobile.nav.settings': '設定', 'mobile.surface.closeAria': '關閉', @@ -43,6 +81,7 @@ export const dict: Record = { 'mobile.menu.files': '檔案', 'mobile.menu.changes': '變更', 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': '執行個體', 'mobile.menu.update': '更新', 'mobile.menu.settings': '設定', 'mobile.sessions.newChatCta': '在 {project} 中新增聊天', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 216ef690d5..040c85d11b 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -30,6 +30,7 @@ import { // Can be overridden with VITE_OPENCODE_URL for absolute URLs in special deployments const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || "/api"; const CONFIG_CACHE_TTL_MS = 10_000; +const OPENCODE_HEALTH_TIMEOUT_MS = 4_000; /** * Render an SDK error payload into a short string for Error messages. @@ -157,6 +158,26 @@ const resolveRuntimeBaseUrl = (): string | null => { } }; +type AbortSignalConstructorWithTimeout = typeof AbortSignal & { + timeout?: (milliseconds: number) => AbortSignal; +}; + +const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup: () => void } => { + const abortSignal = typeof AbortSignal !== 'undefined' + ? AbortSignal as AbortSignalConstructorWithTimeout + : undefined; + if (typeof abortSignal?.timeout === 'function') { + return { signal: abortSignal.timeout(timeoutMs), cleanup: () => undefined }; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + return { + signal: controller.signal, + cleanup: () => clearTimeout(timeoutId), + }; +}; + const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => { return createOpencodeClient({ ...config, @@ -1543,7 +1564,8 @@ class OpencodeService { ? '/api/opencode/health' : `${normalizedBase}/opencode/health`; markStartupTrace('opencodeClient.checkHealth:url', { baseUrl: this.baseUrl, healthUrl }); - const response = await runtimeFetch(healthUrl); + const timeout = createTimeoutSignal(OPENCODE_HEALTH_TIMEOUT_MS); + const response = await runtimeFetch(healthUrl, { signal: timeout.signal }).finally(timeout.cleanup); markStartupTrace('opencodeClient.checkHealth:response', { status: response.status }); if (!response.ok) { return false; diff --git a/packages/ui/src/lib/platform.ts b/packages/ui/src/lib/platform.ts new file mode 100644 index 0000000000..73615a4d74 --- /dev/null +++ b/packages/ui/src/lib/platform.ts @@ -0,0 +1,26 @@ +import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; + +/** True when running inside the native Capacitor shell (iOS/Android app), not the web/PWA. */ +export const isCapacitorApp = (): boolean => { + if (typeof window === 'undefined') return false; + const capacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor; + return capacitor?.isNativePlatform?.() === true || window.location.protocol === 'capacitor:'; +}; + +export type ClientPlatform = 'ios' | 'android' | 'vscode' | 'desktop' | 'web'; + +/** + * The runtime surface this client is. Used by the push presence model: only 'ios'/'android' + * count as mobile (push recipients); everything else is an interactive surface that suppresses + * mobile push while visible. + */ +export const getClientPlatform = (): ClientPlatform => { + if (typeof window !== 'undefined') { + const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; + const native = capacitor?.getPlatform?.(); + if (native === 'ios' || native === 'android') return native; + } + if (isVSCodeRuntime()) return 'vscode'; + if (isDesktopShell()) return 'desktop'; + return 'web'; +}; diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index c00ea86ae1..11724eff2e 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -508,3 +508,88 @@ } } } + +/* Small app-wide bottom safe area for the native shell. The phone's rounded hardware + corners clip controls flush against the bottom edge, and the PWA's own safe-area + padding is gated behind display-mode: standalone — which the Capacitor WebView does + not match — so nothing reserves bottom room in the native app. Expose it as a token + so any native surface can consume it; the chat shell does so below. */ +:root.oc-capacitor-app { + --oc-app-bottom-safe: max(16px, calc(env(safe-area-inset-bottom, 0px) * 0.5)); +} + +/* Paint the document canvas with the theme background in the native app — the same + thing .desktop-runtime does for body/#root, which the Capacitor shell never got. + The status bar is overlaid (transparent), and in dark mode `color-scheme: dark` + makes the bare UA canvas dark, so any sliver not covered by content (notably the + area behind the status bar) bled through as a dark band at the top. It only showed + in dark mode, which is why it tracked the system theme. */ +:root.oc-capacitor-app, +:root.oc-capacitor-app body, +:root.oc-capacitor-app #root { + background: var(--background) !important; + background-color: var(--background) !important; +} + +/* Native (Capacitor) keyboard handling. + The Keyboard plugin runs in `resize: 'none'` mode so the WebView keeps its full + height; instead we shrink the app shell by the keyboard frame height, exposed as + --oc-keyboard-inset and set once from `keyboardWillShow` (see useNativeMobileChrome). + Scoped to .oc-capacitor-app so the browser PWA keeps its dvh / interactive-widget + behaviour untouched. + + `keyboardWillShow` fires at the start of the iOS keyboard animation, so the inset + is set once and the transition carries the rise. The duration/curve are tuned to + mimic the native iOS keyboard (≈0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) so our + layout and the keyboard move together. (visualViewport live-tracking would be exact + but doesn't report under WKWebView's `resize: 'none'`, so this is the best signal.) */ +:root.oc-capacitor-app .oc-mobile-app-shell { + height: calc(100dvh - var(--oc-keyboard-inset, 0px)); + /* Reserve the bottom safe area only while the keyboard is down — when it's up the + inset cancels it out (the home indicator is hidden and the composer should sit + flush above the keyboard). The shell keeps its own bg behind this padding. */ + padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-keyboard-inset, 0px))); + transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), + padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); +} + +/* Android resizes the window for the keyboard natively (no manual --oc-keyboard-inset), + so 100dvh changes instantly. Animating height against that instant resize makes the + header/content bounce on keyboard open — disable the transition on Android. */ +:root.oc-capacitor-app.oc-platform-android .oc-mobile-app-shell { + transition: none; +} + +/* Portal surfaces (bottom sheets, overlay panels) render at level, outside + the app shell, so they don't inherit the shell's keyboard inset. They're full- + height `fixed inset-0` scrims with a bottom-anchored (`mt-auto`) sheet, so raising + their bottom edge by the keyboard height shrinks scrim + sheet together and lifts + any input above the keyboard instead of hiding it underneath. The opacity term + preserves the scrim's enter fade (Tailwind's `transition-opacity` would otherwise + be overridden by this rule's `transition` shorthand). */ +:root.oc-capacitor-app .oc-keyboard-inset-surface { + bottom: var(--oc-keyboard-inset, 0px); + transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), opacity 0.2s ease-out; +} + +/* Full-screen scroll views (e.g. the connect/login screen) live outside the app + shell, so shrink them by the keyboard height the same way the shell does. Capping + the height (instead of min-height: 100dvh) is what makes overflow-y-auto actually + scroll, so a field near the bottom lifts above the keyboard rather than staying + hidden behind it. min-height: 0 neutralises the Tailwind min-h-dvh baseline. */ +:root.oc-capacitor-app .oc-keyboard-fill-screen { + height: calc(100dvh - var(--oc-keyboard-inset, 0px)); + min-height: 0; + transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); +} + +/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing + room above the home indicator), but that gap looks artificial sitting above the + keyboard's accessory bar — so tighten it while the keyboard is open. Animated to + match the keyboard motion. */ +:root.oc-capacitor-app .oc-mobile-composer { + transition: padding-bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1); +} +:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer { + padding-bottom: 6px; +} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 9387eaf7b0..da34f3a47a 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -8,6 +8,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" +import { isCapacitorApp } from "@/lib/platform" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" import { useGlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" @@ -1584,7 +1585,12 @@ export function SyncProvider(props: { directory: string children: React.ReactNode }) { - const messageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport) + const storedMessageStreamTransport = useConfigStore((state) => state.settingsMessageStreamTransport) + // Capacitor apps are locked to SSE: native WebSocket streaming is unreliable there (on + // Android events only arrive once the run finishes), while SSE streams correctly. The Chat + // settings UI disables the other options on mobile, but force it here too so the effective + // transport can't drift. Remove this override (and the UI lock) to re-enable WS on mobile. + const messageStreamTransport: 'auto' | 'ws' | 'sse' = isCapacitorApp() ? 'sse' : storedMessageStreamTransport const childStoresRef = useRef(null) if (!childStoresRef.current) childStoresRef.current = new ChildStoreManager() const childStores = childStoresRef.current @@ -2053,7 +2059,7 @@ export function useDirectorySync(selector: (state: State) => T, directory?: s return useStore(store, selector) } -/** Get session messages for a specific session */ +/** Get session messages for a specific session */ export function useSessionMessages(sessionID: string, directory?: string) { const store = useDirectoryStore(directory) const getSnapshot = useCallback(() => { diff --git a/packages/web/bin/lib/commands-connect-url.js b/packages/web/bin/lib/commands-connect-url.js index b9ae0f502b..c2b9470593 100644 --- a/packages/web/bin/lib/commands-connect-url.js +++ b/packages/web/bin/lib/commands-connect-url.js @@ -35,6 +35,16 @@ async function resolveConnectUrlServerUrl(options) { } const bindHost = resolveConfiguredBindHost(hostOverride); + + // A host that's already a full http(s) URL is a public/server URL, not a bind + // address (e.g. `--host https://devchamber.example.com` for a remote deploy + // behind a reverse proxy). Use it directly instead of feeding it to + // buildLocalUrl, which would produce `http://https://...:port`. + const hostAsServerUrl = normalizeServerUrlForConnection(bindHost); + if (hostAsServerUrl) { + return { serverUrl: hostAsServerUrl, source: 'configured-host' }; + } + if (!isWildcardBindHost(bindHost)) { return { serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''), diff --git a/packages/web/mobile.html b/packages/web/mobile.html index 18a298ac1f..907e8c4180 100644 --- a/packages/web/mobile.html +++ b/packages/web/mobile.html @@ -4,6 +4,65 @@ OpenChamber Mobile + + + diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 21238b5e90..28f6c2dd8c 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -9,6 +9,7 @@ import net from 'net'; import { fileURLToPath } from 'url'; import os from 'os'; import crypto from 'crypto'; +import http2 from 'node:http2'; import { createUiAuth } from './lib/ui-auth/ui-auth.js'; import { createTunnelAuth } from './lib/opencode/tunnel-auth.js'; import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js'; @@ -79,6 +80,7 @@ import { registerNotificationRoutes } from './lib/notifications/routes.js'; import { createNotificationEmitterRuntime } from './lib/notifications/emitter-runtime.js'; import { createNotificationTriggerRuntime } from './lib/notifications/runtime.js'; import { createPushRuntime } from './lib/notifications/push-runtime.js'; +import { createApnsRuntime } from './lib/notifications/apns-runtime.js'; import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import { createProjectConfigRuntime } from './lib/projects/project-config.js'; @@ -275,6 +277,7 @@ const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR : path.join(os.homedir(), '.config', 'openchamber'); const SETTINGS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'settings.json'); const PUSH_SUBSCRIPTIONS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'push-subscriptions.json'); +const APNS_TOKENS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'apns-tokens.json'); const REMOTE_CLIENTS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'remote-clients.json'); const CLOUDFLARE_MANAGED_REMOTE_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-managed-remote-tunnels.json'); const CLOUDFLARE_LEGACY_NAMED_TUNNELS_FILE_PATH = path.join(OPENCHAMBER_DATA_DIR, 'cloudflare-named-tunnels.json'); @@ -377,12 +380,34 @@ const getOrCreateVapidKeys = (...args) => pushRuntime.getOrCreateVapidKeys(...ar const addOrUpdatePushSubscription = (...args) => pushRuntime.addOrUpdatePushSubscription(...args); const removePushSubscription = (...args) => pushRuntime.removePushSubscription(...args); const sendPushToAllUiSessions = (...args) => pushRuntime.sendPushToAllUiSessions(...args); -const updateUiVisibility = (...args) => pushRuntime.updateUiVisibility(...args); +// Set once the notification trigger runtime exists (declared later). When a UI +// client reports it became visible, reset the native push badge set — the same +// moment the device zeroes its icon badge on becomeActive, keeping them in sync. +let clearPendingPushBadge = () => {}; +const updateUiVisibility = (token, visible, platform) => { + if (visible === true) clearPendingPushBadge(); + return pushRuntime.updateUiVisibility(token, visible, platform); +}; const isAnyUiVisible = (...args) => pushRuntime.isAnyUiVisible(...args); +const isAnyInteractiveClientVisible = (...args) => pushRuntime.isAnyInteractiveClientVisible(...args); const isUiVisible = (...args) => pushRuntime.isUiVisible(...args); const ensurePushInitialized = (...args) => pushRuntime.ensurePushInitialized(...args); const setPushInitialized = (...args) => pushRuntime.setPushInitialized(...args); +const apnsRuntime = createApnsRuntime({ + fsPromises, + path, + crypto, + http2, + APNS_TOKENS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, +}); + +const addOrUpdateApnsToken = (...args) => apnsRuntime.addOrUpdateApnsToken(...args); +const removeApnsToken = (...args) => apnsRuntime.removeApnsToken(...args); +const sendApnsToAllUiSessions = (...args) => apnsRuntime.sendApnsToAllUiSessions(...args); + const TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW = 128; const TERMINAL_INPUT_WS_REBIND_WINDOW_MS = 60 * 1000; const TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000; @@ -676,12 +701,15 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({ emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions, + sendApnsToAllUiSessions, + isAnyInteractiveClientVisible, buildOpenCodeUrl, getOpenCodeAuthHeaders, }); const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args); const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args); +clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge(); const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, @@ -1103,7 +1131,13 @@ async function main(options = {}) { const app = express(); const serverStartedAt = new Date().toISOString(); - const packagedClientOrigins = new Set(['openchamber-ui://app']); + const packagedClientOrigins = new Set([ + 'openchamber-ui://app', + 'capacitor://localhost', + 'http://localhost', + 'https://localhost', + ]); + const isLocalDevClientOrigin = (origin) => /^https?:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin); app.set('trust proxy', true); // Keep self-hosted instances out of search engines. The app shell is served // publicly (it loads before prompting for the UI password), so without this @@ -1118,7 +1152,7 @@ async function main(options = {}) { }); app.use((req, res, next) => { const origin = typeof req.headers.origin === 'string' ? req.headers.origin : ''; - if (packagedClientOrigins.has(origin)) { + if (packagedClientOrigins.has(origin) || isLocalDevClientOrigin(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'); @@ -1193,7 +1227,10 @@ async function main(options = {}) { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge: () => clearPendingPushBadge(), isUiVisible, getUiNotificationClients: () => uiNotificationClients, writeSseEvent, diff --git a/packages/web/server/lib/notifications/APNS.md b/packages/web/server/lib/notifications/APNS.md new file mode 100644 index 0000000000..61821a3e7d --- /dev/null +++ b/packages/web/server/lib/notifications/APNS.md @@ -0,0 +1,131 @@ +# APNs remote push — signed relay mode + +Native iOS background push (notifications even when the app is **suspended or killed**) is +delivered via APNs through a **central relay**, so no user configures an Apple key. Each server +signs its relay requests with an auto-generated keypair, and tokens are bound to the server that +registered them — so a leaked device token alone can't be used to push. + +## How it works + +1. The app registers its APNs device token with **its own server** (`POST /api/push/apns-token`, + `useNativePushRegistration`). PWA/desktop never register — only the native Capacitor app. +2. The server **binds the token on the relay**: it POSTs `{ token, publicKeyJwk, ts, sig }` to + `POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key + (`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records + `token → serverId` where `serverId = SHA-256(publicKey)`. +3. On a trigger (ready/error/question/permission), the server composes **generic, content-free** + text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent + needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/ + message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body, + badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send` + (`apns-runtime.js` → `sendViaRelay`). It does **not** gate on UI visibility (see below). +4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature + + `ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds + the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each + token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop` + (410 / BadDeviceToken). The relay stores no secret — only `token → serverId` hashes. +5. Tapping a push deep-links to its session via the forwarded `sessionId`. + +## Foreground suppression + +APNs is **not** gated on UI visibility. A backgrounded WKWebView can't reliably report "hidden" +before iOS suspends it, so a server-side visibility gate dropped background push for short +responses. Instead the server always sends, and **iOS** suppresses the foreground banner +(`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification +while the app is active, with no race. APNs is the native app's **only** channel; local +notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()` +is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native +app with notifications on has a registered token and a trigger fires. + +## App-icon badge + +Each push carries an **absolute** `aps.badge` = the number of **distinct collapse-ids (`tag`) +pushed since the app was last foregrounded**. It mirrors the lock-screen banner stack. + +The count is a `Set` (`pendingPushTags`) in the trigger runtime (`runtime.js`): +`toApnsGenericPayload` adds the push `tag` and returns the set size as the badge. We key by **`tag`, +not sessionId**, because the tag *is* the banner identity — iOS uses it as `apns-collapse-id`, so +same-tag pushes replace one banner while different tags are distinct banners. One session can raise +several banners (`ready-`, `question-`, `permission-` are different tags), so +counting sessionIds both over- and under-counts the stack; counting tags matches it. + +It is deliberately **not** derived from the live attention snapshot (`needsAttention`/`isViewed`): +that machinery drives in-app indicators on *connected* clients, where a backgrounded client stays +"viewing" and `needsAttention` is set by a separate `session.status` event that races the push +trigger. The set self-clears via `clearPendingPushBadge` on any signal that the user is engaging +with the app: the visibility beacon (`updateUiVisibility` wrapper, `visible:true`), **plus** opening +a session (`POST /api/sessions/:id/view`) and sending a message (`POST /api/sessions/:id/ +message-sent`). The latter two need no auth and fire reliably on the native app when it foregrounds, +so they are the dependable reset — the visibility beacon alone proved unreliable in WKWebView. This +mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegate.swift`), keeping +server and device in sync. + +The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body / +direct-mode `aps.badge`) → relay (`pushSendSchema.badge` → `aps.badge`). It is **not** signed (like +`body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every +device token of a server sees the same badge. + +## Modes + +- **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to + `https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`). +- **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/ + TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed. + +## Config + +Server (`apns-runtime.js`): +- `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT` + (`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set. +- Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` + (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`. + +Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`, +`APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens` +binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy). + +## Apple setup (one-time) + +1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID; + enable **Push Notifications** on App ID `com.openchamber.app`. +2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`, + `APNS_TEAM_ID`. Push to `main` → relay deploys, secrets sync, D1 migrations apply. +3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device. + +## Security posture + +- The device token is a per-install secret, but no longer the *only* defence: every relay request + is signed by the server's private key, and the relay only delivers to a token from its bound + `serverId`. A leaked token alone is useless — an attacker has neither the private key nor a + matching binding. +- `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak + exposes only `token → serverId` hashes. The signed `ts` (±5 min window) blocks replay. +- Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since + registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth. + +## Data confidentiality (what the relay / Apple can see) + +The push payload is **not** application-encrypted, so there is no decryption step. The text is +sent in plaintext, protected only by **TLS in transit** (HTTPS to the relay, TLS from the relay +to APNs). The request **signature is authentication, not encryption** — the relay *verifies* it +(valid / invalid), it does not hide anything. + +Who can read the alert text: + +- **Network hops:** nothing (TLS). +- **The relay (Cloudflare):** the generic title + body (session name), the device token, and + `sessionId`. It stores only `token → serverId` hashes (no text, no payload). +- **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push. +- **The device:** displays it. + +This is acceptable **because the text is deliberately content-free**: a fixed scenario title + +the session name only — no model, project, or message content (`runtime.js` → +`toApnsGenericPayload`). The session name is the single semi-personal field that crosses the +relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload** +(`mutable-content` + a Notification Service Extension that decrypts on-device with a key never +sent to the relay) — not implemented, and unnecessary for generic text. + +## Android (FCM) note + +The Android equivalent is **FCM** (not implemented): the same relay would forward to FCM with a +server key, and the client would register an FCM token (same store/routes + signing). diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 01ff1f6f24..bf736a1e5e 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -7,6 +7,7 @@ This module provides notification message preparation utilities for the web serv - `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`. - `packages/web/server/lib/notifications/routes.js`: route registration for push, visibility, and session status/attention endpoints. - `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime. +- `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery. Two modes: **relay** (default — sign + POST tokens + generic text to the central Cloudflare relay `https://api.openchamber.dev/v1/push/send`, which holds the single project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the native app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. Mobile push carries only generic text (scenario title + session name) — see `APNS.md`. - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime. - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout. - `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only. @@ -24,6 +25,8 @@ This module provides notification message preparation utilities for the web serv - `GET /api/push/vapid-public-key` - `POST /api/push/subscribe` - `DELETE /api/push/subscribe` + - `POST /api/push/apns-token` (native iOS APNs device-token registration) + - `DELETE /api/push/apns-token` - `POST /api/push/visibility` - `GET /api/push/visibility` - `GET /api/notifications/stream` @@ -61,6 +64,16 @@ This module provides notification message preparation utilities for the web serv - `isAnyUiVisible()` - `isUiVisible(token)` +### APNs runtime API (apns-runtime.js) +- `createApnsRuntime(dependencies)`: creates runtime for native iOS APNs push and device-token state. Dependencies: `fsPromises`, `path`, `crypto`, `http2`, `APNS_TOKENS_FILE_PATH`, `readSettingsFromDiskMigrated`, `writeSettingsToDisk` (persists the auto-generated relay signing keypair). +- Returned API: + - `addOrUpdateApnsToken(uiSessionToken, deviceToken, userAgent)` — also binds a newly-seen token on the relay (signed `/v1/push/register-token`). + - `removeApnsToken(uiSessionToken, deviceToken)` + - `removeApnsTokenFromAllSessions(deviceToken)` + - `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`. + - `resolveApnsConfig()` +- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`). + ### Emitter runtime API (emitter-runtime.js) - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels. - Returned API: diff --git a/packages/web/server/lib/notifications/apns-runtime.js b/packages/web/server/lib/notifications/apns-runtime.js new file mode 100644 index 0000000000..4f3040e94f --- /dev/null +++ b/packages/web/server/lib/notifications/apns-runtime.js @@ -0,0 +1,512 @@ +// APNs (Apple Push Notification service) runtime for the native iOS mobile app. +// +// Device tokens are persisted per UI session (mirrors push-runtime.js). Delivery has two +// modes, chosen at send time: +// - Relay (default): POST tokens + generic text to the central Cloudflare relay, which +// holds the single project APNs key and signs+sends — so users configure nothing. +// - Direct (fallback): sign an ES256 JWT with Node crypto and send over HTTP/2 ourselves, +// for self-hosters who set OPENCHAMBER_APNS_* and OPENCHAMBER_PUSH_RELAY_DISABLED=true. +// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only +// generic, model-based text (no session content) — see APNS.md. + +const APNS_TOKENS_VERSION = 1; +const APNS_HOST_PRODUCTION = 'https://api.push.apple.com'; +const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com'; +// APNs rejects auth tokens older than 1h; refresh well inside that window. +const JWT_TTL_MS = 50 * 60 * 1000; +const DEFAULT_BUNDLE_ID = 'com.openchamber.app'; +const DEFAULT_RELAY_URL = 'https://api.openchamber.dev/v1/push/send'; +const MAX_TOKENS_PER_SESSION = 10; +// APNs reasons that mean the token is permanently invalid → drop it. +const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']); + +const trimmedEnv = (name) => { + const value = process.env[name]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +}; + +// Env vars commonly store the .p8 with literal "\n" sequences; restore real newlines. +const normalizePem = (value) => (typeof value === 'string' ? value.replace(/\\n/g, '\n').trim() : ''); + +export const createApnsRuntime = (deps) => { + const { + fsPromises, + path, + crypto, + http2, + APNS_TOKENS_FILE_PATH, + readSettingsFromDiskMigrated, + writeSettingsToDisk, + } = deps; + + let persistLock = Promise.resolve(); + let cachedJwt = null; // { token, issuedAtMs, keyId } + let cachedRelayKey = null; // { privateKey, publicJwk } + let warnedUnconfigured = false; + + // --------------------------------------------------------------------------- + // Per-server relay signing identity (ECDSA P-256). Auto-generated + persisted in settings + // (mirrors getOrCreateVapidKeys). The relay derives serverId = SHA-256(publicKey), verifies + // each request's signature, and only delivers to tokens this server registered — so a leaked + // device token alone can't be used to push. Zero-config: the keypair generates on first use. + // --------------------------------------------------------------------------- + + const getOrCreateRelayKeypair = async () => { + if (cachedRelayKey) return cachedRelayKey; + const settings = await readSettingsFromDiskMigrated(); + const existing = settings?.relaySigningKey; + if (existing && existing.privateJwk && existing.publicJwk) { + cachedRelayKey = { + privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }), + publicJwk: existing.publicJwk, + }; + return cachedRelayKey; + } + const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const privateJwk = privateKey.export({ format: 'jwk' }); + const publicJwk = publicKey.export({ format: 'jwk' }); + await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } }); + cachedRelayKey = { privateKey, publicJwk }; + return cachedRelayKey; + }; + + const signRelayMessage = (privateKey, message) => + crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); + + // Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash). + const relayPublicJwk = (publicJwk) => ({ + kty: publicJwk.kty, + crv: publicJwk.crv, + x: publicJwk.x, + y: publicJwk.y, + }); + + const registerTokenWithRelay = async (token, platform = 'ios') => { + const relay = resolveRelayConfig(); + if (!relay) return; // direct mode — no relay binding needed + try { + const { privateKey, publicJwk } = await getOrCreateRelayKeypair(); + const ts = Date.now(); + // platform is part of the signed message so it can't be tampered en route. + const sig = signRelayMessage(privateKey, `${ts}.${token}.${platform}`); + const res = await fetch(relay.registerUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token, platform, publicKeyJwk: relayPublicJwk(publicJwk), ts, sig }), + }); + if (!res.ok) console.warn(`[Push relay] register-token failed status=${res.status}`); + } catch (error) { + console.warn('[Push relay] register-token request failed:', error?.message ?? error); + } + }; + + // --------------------------------------------------------------------------- + // Token persistence (same shape + write-lock pattern as push-runtime.js) + // --------------------------------------------------------------------------- + + const emptyStore = () => ({ version: APNS_TOKENS_VERSION, tokensBySession: {} }); + + const readTokensFromDisk = async () => { + try { + const raw = await fsPromises.readFile(APNS_TOKENS_FILE_PATH, 'utf8'); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || parsed.version !== APNS_TOKENS_VERSION) { + return emptyStore(); + } + const tokensBySession = + parsed.tokensBySession && typeof parsed.tokensBySession === 'object' ? parsed.tokensBySession : {}; + return { version: APNS_TOKENS_VERSION, tokensBySession }; + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return emptyStore(); + } + console.warn('Failed to read APNs tokens file:', error); + return emptyStore(); + } + }; + + const writeTokensToDisk = async (data) => { + await fsPromises.mkdir(path.dirname(APNS_TOKENS_FILE_PATH), { recursive: true }); + await fsPromises.writeFile(APNS_TOKENS_FILE_PATH, JSON.stringify(data, null, 2), 'utf8'); + }; + + const persistTokenUpdate = async (mutate) => { + persistLock = persistLock.then(async () => { + const current = await readTokensFromDisk(); + const next = mutate({ version: APNS_TOKENS_VERSION, tokensBySession: current.tokensBySession || {} }); + await writeTokensToDisk(next); + return next; + }); + return persistLock; + }; + + const normalizeTokens = (record) => { + if (!Array.isArray(record)) return []; + return record + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const deviceToken = entry.deviceToken; + if (typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return null; + return { + deviceToken: deviceToken.trim(), + createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + lastSeenAt: typeof entry.lastSeenAt === 'number' ? entry.lastSeenAt : null, + userAgent: typeof entry.userAgent === 'string' ? entry.userAgent : undefined, + // 'ios' (APNs) or 'android' (FCM). Older entries without one are APNs by default. + platform: entry.platform === 'android' ? 'android' : 'ios', + }; + }) + .filter(Boolean); + }; + + // Normalize an incoming platform hint to the two we support; default to APNs/iOS since that + // was the only registrant before Android/FCM existed. + const normalizePlatform = (platform) => (platform === 'android' ? 'android' : 'ios'); + + const addOrUpdateApnsToken = async (uiSessionToken, deviceToken, userAgent, platform) => { + if (!uiSessionToken || typeof deviceToken !== 'string' || deviceToken.trim().length === 0) return; + const token = deviceToken.trim(); + const tokenPlatform = normalizePlatform(platform); + const now = Date.now(); + + await persistTokenUpdate((current) => { + const tokensBySession = { ...(current.tokensBySession || {}) }; + const existing = normalizeTokens(tokensBySession[uiSessionToken]); + const filtered = existing.filter((entry) => entry.deviceToken !== token); + filtered.unshift({ + deviceToken: token, + createdAt: now, + lastSeenAt: now, + userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, + platform: tokenPlatform, + }); + tokensBySession[uiSessionToken] = filtered.slice(0, MAX_TOKENS_PER_SESSION); + return { version: APNS_TOKENS_VERSION, tokensBySession }; + }); + + // (Re)bind this token to our server on the relay so only we can push to it. The device + // re-sends its token on each launch; this is an idempotent upsert relay-side, and binding + // every time (not just for new tokens) keeps existing tokens bound after a relay/server + // upgrade rather than silently going unbound. Platform is bound too so the relay routes + // it to APNs vs FCM. + await registerTokenWithRelay(token, tokenPlatform); + }; + + const removeApnsToken = async (uiSessionToken, deviceToken) => { + if (!uiSessionToken || !deviceToken) return; + await persistTokenUpdate((current) => { + const tokensBySession = { ...(current.tokensBySession || {}) }; + const filtered = normalizeTokens(tokensBySession[uiSessionToken]).filter( + (entry) => entry.deviceToken !== deviceToken, + ); + if (filtered.length === 0) delete tokensBySession[uiSessionToken]; + else tokensBySession[uiSessionToken] = filtered; + return { version: APNS_TOKENS_VERSION, tokensBySession }; + }); + }; + + const removeApnsTokenFromAllSessions = async (deviceToken) => { + if (!deviceToken) return; + await persistTokenUpdate((current) => { + const tokensBySession = { ...(current.tokensBySession || {}) }; + for (const [session, entries] of Object.entries(tokensBySession)) { + const filtered = normalizeTokens(entries).filter((entry) => entry.deviceToken !== deviceToken); + if (filtered.length === 0) delete tokensBySession[session]; + else tokensBySession[session] = filtered; + } + return { version: APNS_TOKENS_VERSION, tokensBySession }; + }); + }; + + // --------------------------------------------------------------------------- + // Config (env first, then settings.apnsConfig) — mirrors resolveVapidSubject + // --------------------------------------------------------------------------- + + const resolveApnsConfig = async () => { + let keyId = trimmedEnv('OPENCHAMBER_APNS_KEY_ID'); + let teamId = trimmedEnv('OPENCHAMBER_APNS_TEAM_ID'); + let bundleId = trimmedEnv('OPENCHAMBER_APNS_BUNDLE_ID'); + let environment = (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || '').toLowerCase(); + let p8 = normalizePem(process.env.OPENCHAMBER_APNS_P8 || ''); + + const p8Path = trimmedEnv('OPENCHAMBER_APNS_P8_PATH'); + if (!p8 && p8Path) { + try { + p8 = (await fsPromises.readFile(p8Path, 'utf8')).trim(); + } catch (error) { + console.warn('[APNs] Failed to read OPENCHAMBER_APNS_P8_PATH:', error?.message ?? error); + } + } + + if (!keyId || !teamId || !p8) { + try { + const settings = await readSettingsFromDiskMigrated(); + const stored = settings?.apnsConfig; + if (stored && typeof stored === 'object') { + keyId = keyId || (typeof stored.keyId === 'string' ? stored.keyId.trim() : null); + teamId = teamId || (typeof stored.teamId === 'string' ? stored.teamId.trim() : null); + bundleId = bundleId || (typeof stored.bundleId === 'string' ? stored.bundleId.trim() : null); + environment = environment || (typeof stored.environment === 'string' ? stored.environment.toLowerCase() : ''); + if (!p8 && typeof stored.p8 === 'string') p8 = normalizePem(stored.p8); + } + } catch { + // settings unavailable — fall through to the unconfigured result + } + } + + if (!keyId || !teamId || !p8) return null; + + return { + keyId, + teamId, + p8, + bundleId: bundleId || DEFAULT_BUNDLE_ID, + environment: environment === 'production' ? 'production' : 'sandbox', + }; + }; + + // --------------------------------------------------------------------------- + // JWT (ES256, JOSE/raw signature) + HTTP/2 send + // --------------------------------------------------------------------------- + + const signApnsJwt = (config) => { + const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: config.keyId })).toString('base64url'); + const claims = Buffer.from( + JSON.stringify({ iss: config.teamId, iat: Math.floor(Date.now() / 1000) }), + ).toString('base64url'); + const signingInput = `${header}.${claims}`; + const signature = crypto + .sign('sha256', Buffer.from(signingInput), { key: config.p8, dsaEncoding: 'ieee-p1363' }) + .toString('base64url'); + return `${signingInput}.${signature}`; + }; + + const getJwt = (config) => { + const now = Date.now(); + if (cachedJwt && cachedJwt.keyId === config.keyId && now - cachedJwt.issuedAtMs < JWT_TTL_MS) { + return cachedJwt.token; + } + const token = signApnsJwt(config); + cachedJwt = { token, issuedAtMs: now, keyId: config.keyId }; + return token; + }; + + const buildBody = (payload) => { + const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {}; + return JSON.stringify({ + aps: { + alert: { + title: typeof payload?.title === 'string' ? payload.title : undefined, + body: typeof payload?.body === 'string' ? payload.body : undefined, + }, + badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined, + sound: 'default', + 'thread-id': typeof payload?.tag === 'string' ? payload.tag : undefined, + // Wakes the Notification Service Extension so it can refresh the home/lock-screen + // widgets (attention count + unread dot) from the push, even when the app is closed. + // No extra network call — just an extra key on the push we already send. + 'mutable-content': 1, + }, + ...data, + }); + }; + + const sendOne = (client, deviceToken, body, jwt, config) => + new Promise((resolve) => { + const headers = { + ':method': 'POST', + ':path': `/3/device/${deviceToken}`, + authorization: `bearer ${jwt}`, + 'apns-topic': config.bundleId, + 'apns-push-type': 'alert', + 'apns-priority': '10', + }; + // collapse-id dedups like web-push tags; APNs caps it at 64 bytes. + const collapseId = typeof config.tag === 'string' ? config.tag.slice(0, 64) : undefined; + if (collapseId) headers['apns-collapse-id'] = collapseId; + + let req; + try { + req = client.request(headers); + } catch (error) { + console.warn('[APNs] request open failed:', error?.message ?? error); + resolve(); + return; + } + + let status = 0; + let responseBody = ''; + req.on('response', (resHeaders) => { + status = Number(resHeaders[':status']) || 0; + }); + req.setEncoding('utf8'); + req.on('data', (chunk) => { + responseBody += chunk; + }); + req.on('end', async () => { + if (status === 200) { + resolve(); + return; + } + let reason = ''; + try { + reason = JSON.parse(responseBody)?.reason || ''; + } catch { + // non-JSON error body + } + if (status === 410 || DEAD_TOKEN_REASONS.has(reason)) { + await removeApnsTokenFromAllSessions(deviceToken); + } else { + console.warn(`[APNs] push failed status=${status} reason=${reason || 'unknown'}`); + } + resolve(); + }); + req.on('error', (error) => { + console.warn('[APNs] request error:', error?.message ?? error); + resolve(); + }); + req.end(body); + }); + + // Relay mode (default): the single APNs key lives in the central Cloudflare relay, not on + // each user's server — so users configure nothing. The server just POSTs device tokens + + // generic text; the relay signs + sends and reports which tokens to drop. Direct mode (below) + // is the fallback for self-hosters who set OPENCHAMBER_APNS_* and disable the relay. + const resolveRelayConfig = () => { + if (trimmedEnv('OPENCHAMBER_PUSH_RELAY_DISABLED') === 'true') return null; + const url = trimmedEnv('OPENCHAMBER_PUSH_RELAY_URL') || DEFAULT_RELAY_URL; + return { + url, + registerUrl: url.replace(/\/send$/, '/register-token'), + environment: + (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production' + ? 'production' + : 'sandbox', + }; + }; + + const sendViaRelay = async (deviceTokens, payload, relay) => { + const tokens = deviceTokens.slice(0, 100); + const title = typeof payload?.title === 'string' && payload.title.length > 0 ? payload.title : 'OpenChamber'; + const { privateKey, publicJwk } = await getOrCreateRelayKeypair(); + const ts = Date.now(); + // Sign over the same canonical form the relay verifies: ts.sortedTokens.title. + const sig = signRelayMessage(privateKey, `${ts}.${[...tokens].sort().join(',')}.${title}`); + const requestBody = JSON.stringify({ + tokens, + title, + body: typeof payload?.body === 'string' ? payload.body : '', + badge: Number.isFinite(payload?.badge) && payload.badge >= 0 ? Math.trunc(payload.badge) : undefined, + collapseId: typeof payload?.tag === 'string' ? payload.tag.slice(0, 64) : undefined, + env: relay.environment, + data: payload?.data && typeof payload.data === 'object' ? payload.data : undefined, + publicKeyJwk: relayPublicJwk(publicJwk), + ts, + sig, + }); + try { + const res = await fetch(relay.url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + }); + if (!res.ok) { + console.warn(`[APNs relay] send failed status=${res.status}`); + return; + } + const data = await res.json().catch(() => null); + const results = Array.isArray(data?.results) ? data.results : []; + for (const result of results) { + if (result && result.drop === true && typeof result.token === 'string') { + await removeApnsTokenFromAllSessions(result.token); + } + } + } catch (error) { + console.warn('[APNs relay] request failed:', error?.message ?? error); + } + }; + + const sendViaDirectApns = async (deviceTokens, payload) => { + const config = await resolveApnsConfig(); + if (!config) { + if (!warnedUnconfigured) { + warnedUnconfigured = true; + console.warn( + '[APNs] Relay disabled and no direct config; set OPENCHAMBER_APNS_KEY_ID / OPENCHAMBER_APNS_TEAM_ID / OPENCHAMBER_APNS_P8 for direct send.', + ); + } + return; + } + + const host = config.environment === 'production' ? APNS_HOST_PRODUCTION : APNS_HOST_SANDBOX; + const jwt = getJwt(config); + const body = buildBody(payload); + const sendConfig = { ...config, tag: typeof payload?.tag === 'string' ? payload.tag : undefined }; + + let client; + try { + client = http2.connect(host); + } catch (error) { + console.warn('[APNs] connect failed:', error?.message ?? error); + return; + } + + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + try { + client.close(); + } catch { + // ignore close errors + } + resolve(); + }; + client.on('error', (error) => { + console.warn('[APNs] session error:', error?.message ?? error); + finish(); + }); + Promise.all( + deviceTokens.map((token) => sendOne(client, token, body, jwt, sendConfig)), + ).finally(finish); + }); + }; + + // NOT gated on UI visibility (unlike web push). A backgrounded WKWebView can't reliably + // report "hidden" before iOS suspends it, so a visibility gate wrongly suppressed + // background push for short responses. Instead we always send, and rely on iOS to NOT + // display the alert while the app is foreground (presentationOptions: [] in + // capacitor.config) — so there is no notification when the app is active, with no race. + const sendApnsToAllUiSessions = async (payload, _options = {}) => { + const store = await readTokensFromDisk(); + const deviceTokens = []; + const seen = new Set(); + for (const record of Object.values(store.tokensBySession || {})) { + for (const entry of normalizeTokens(record)) { + if (!seen.has(entry.deviceToken)) { + seen.add(entry.deviceToken); + deviceTokens.push(entry.deviceToken); + } + } + } + if (deviceTokens.length === 0) return; + + const relay = resolveRelayConfig(); + if (relay) { + await sendViaRelay(deviceTokens, payload, relay); + return; + } + await sendViaDirectApns(deviceTokens, payload); + }; + + return { + addOrUpdateApnsToken, + removeApnsToken, + removeApnsTokenFromAllSessions, + sendApnsToAllUiSessions, + resolveApnsConfig, + // exposed for tests + signApnsJwt, + }; +}; diff --git a/packages/web/server/lib/notifications/apns-runtime.test.js b/packages/web/server/lib/notifications/apns-runtime.test.js new file mode 100644 index 0000000000..5605ddc730 --- /dev/null +++ b/packages/web/server/lib/notifications/apns-runtime.test.js @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createApnsRuntime } from './apns-runtime.js'; + +// A real P-256 key so the ES256 signing path (direct mode) runs for real. +const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); +const P8 = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); +const APNS_CONFIG = { keyId: 'KEY123', teamId: 'TEAM123', p8: P8, bundleId: 'com.openchamber.app', environment: 'sandbox' }; + +// In-memory fs so add-then-read reflects within a test. +const createMemoryFs = () => { + let content = null; + return { + mkdir: vi.fn(async () => {}), + readFile: vi.fn(async () => { + if (content == null) { + const err = new Error('ENOENT'); + err.code = 'ENOENT'; + throw err; + } + return content; + }), + writeFile: vi.fn(async (_path, data) => { + content = data; + }), + }; +}; + +const makeDeps = (overrides = {}) => { + // Stateful settings so the auto-generated relay signing keypair persists + reads back. + let settings = {}; + return { + fsPromises: createMemoryFs(), + path: { dirname: () => '/tmp' }, + crypto, + http2: { connect: vi.fn(() => { throw new Error('http2 must not be used in relay mode'); }) }, + APNS_TOKENS_FILE_PATH: '/tmp/apns-tokens.json', + readSettingsFromDiskMigrated: vi.fn(async () => settings), + writeSettingsToDisk: vi.fn(async (next) => { settings = next; }), + ...overrides, + }; +}; + +const jsonResponse = (data, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); + +// Mirror of the relay's verifier (crypto.subtle), to prove the server's signatures are valid. +const verifyRelaySignature = async (publicKeyJwk, message, sigB64Url) => { + const key = await crypto.subtle.importKey( + 'jwk', + { kty: publicKeyJwk.kty, crv: publicKeyJwk.crv, x: publicKeyJwk.x, y: publicKeyJwk.y }, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new Uint8Array(Buffer.from(sigB64Url, 'base64url')), + new TextEncoder().encode(message), + ); +}; + +const isRegister = ([url]) => String(url).endsWith('/register-token'); +const isSend = ([url]) => String(url) === 'https://relay.test/v1/push/send'; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENCHAMBER_PUSH_RELAY_URL; + delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED; +}); + +describe('apns runtime relay mode (default)', () => { + it('registers tokens (signed) and posts signed generic text, dropping dead tokens', async () => { + const fetchMock = vi.fn(async (url) => + isRegister([url]) + ? jsonResponse({ ok: true }) + : jsonResponse({ + results: [ + { token: 'tokenA', ok: true, drop: false }, + { token: 'tokenDead', ok: false, drop: true }, + ], + }), + ); + vi.stubGlobal('fetch', fetchMock); + process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; + + const runtime = createApnsRuntime(makeDeps()); + await runtime.addOrUpdateApnsToken('s1', 'tokenA'); + await runtime.addOrUpdateApnsToken('s2', 'tokenDead'); + + // Each new token is bound on the relay with a signed register-token call. + const registerCalls = fetchMock.mock.calls.filter(isRegister); + expect(registerCalls).toHaveLength(2); + for (const [url, init] of registerCalls) { + expect(url).toBe('https://relay.test/v1/push/register-token'); + const body = JSON.parse(init.body); + expect(body.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + expect(typeof body.ts).toBe('number'); + expect(body.platform).toBe('ios'); + expect(await verifyRelaySignature(body.publicKeyJwk, `${body.ts}.${body.token}.${body.platform}`, body.sig)).toBe(true); + } + + fetchMock.mockClear(); + await runtime.sendApnsToAllUiSessions( + { title: 'Agent response is ready', body: 'My session', badge: 3, tag: 'ready-x', data: { sessionId: 'sess1' } }, + {}, + ); + + const sendCall = fetchMock.mock.calls.find(isSend); + expect(sendCall).toBeTruthy(); + const sent = JSON.parse(sendCall[1].body); + expect(sendCall[1].headers.authorization).toBeUndefined(); + expect(new Set(sent.tokens)).toEqual(new Set(['tokenA', 'tokenDead'])); + expect(sent.title).toBe('Agent response is ready'); + expect(sent.body).toBe('My session'); + expect(sent.badge).toBe(3); + expect(sent.data).toEqual({ sessionId: 'sess1' }); + expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`; + expect(await verifyRelaySignature(sent.publicKeyJwk, sendMessage, sent.sig)).toBe(true); + + // tokenDead should have been dropped → next send targets only tokenA. + fetchMock.mockClear(); + await runtime.sendApnsToAllUiSessions({ title: 'x', body: 'y', tag: 't' }, {}); + expect(JSON.parse(fetchMock.mock.calls.find(isSend)[1].body).tokens).toEqual(['tokenA']); + }); + + it('reuses one persisted keypair (same serverId) across register + send', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); + vi.stubGlobal('fetch', fetchMock); + process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; + + const deps = makeDeps(); + const runtime = createApnsRuntime(deps); + await runtime.addOrUpdateApnsToken('s1', 'tokenA'); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'x' }, {}); + + const keys = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).publicKeyJwk); + expect(keys.length).toBeGreaterThanOrEqual(2); + expect(keys.every((k) => k.x === keys[0].x && k.y === keys[0].y)).toBe(true); + // Keypair was generated + persisted exactly once. + expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1); + }); + + it('no-ops (no relay call) when no tokens are registered', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const runtime = createApnsRuntime(makeDeps()); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('apns runtime direct fallback (relay disabled)', () => { + it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => { + process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true'; + const targeted = []; + const http2 = { + connect: () => ({ + on: () => {}, + close: () => {}, + request: (headers) => { + targeted.push(String(headers[':path']).replace('/3/device/', '')); + const listeners = {}; + const req = { + on: (event, cb) => { listeners[event] = cb; return req; }, + setEncoding: () => req, + end: () => { + queueMicrotask(() => { + listeners.response?.({ ':status': '200' }); + listeners.end?.(); + }); + }, + }; + return req; + }, + }), + }; + const runtime = createApnsRuntime( + makeDeps({ http2, readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: APNS_CONFIG })) }), + ); + await runtime.addOrUpdateApnsToken('s', 'tokenDirect'); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b', tag: 'ready-x' }); + expect(targeted).toEqual(['tokenDirect']); + }); + + it('signApnsJwt produces a 3-part ES256 token with the expected header/claims', () => { + const runtime = createApnsRuntime(makeDeps()); + const parts = runtime.signApnsJwt(APNS_CONFIG).split('.'); + expect(parts).toHaveLength(3); + expect(JSON.parse(Buffer.from(parts[0], 'base64url').toString())).toEqual({ alg: 'ES256', kid: 'KEY123' }); + expect(JSON.parse(Buffer.from(parts[1], 'base64url').toString()).iss).toBe('TEAM123'); + }); +}); diff --git a/packages/web/server/lib/notifications/push-runtime.js b/packages/web/server/lib/notifications/push-runtime.js index ab776a8d63..01abcb088c 100644 --- a/packages/web/server/lib/notifications/push-runtime.js +++ b/packages/web/server/lib/notifications/push-runtime.js @@ -115,12 +115,13 @@ export const createPushRuntime = (deps) => { p256dh, auth, createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null, + platform: typeof entry.platform === 'string' ? entry.platform : undefined, }; }) .filter(Boolean); }; - const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent) => { + const addOrUpdatePushSubscription = async (uiSessionToken, subscription, userAgent, platform) => { if (!uiSessionToken) { return; } @@ -135,6 +136,7 @@ export const createPushRuntime = (deps) => { const filtered = existing.filter((entry) => entry && typeof entry.endpoint === 'string' && entry.endpoint !== subscription.endpoint); + const previous = existing.find((entry) => entry && entry.endpoint === subscription.endpoint); filtered.unshift({ endpoint: subscription.endpoint, p256dh: subscription.p256dh, @@ -142,6 +144,13 @@ export const createPushRuntime = (deps) => { createdAt: now, lastSeenAt: now, userAgent: typeof userAgent === 'string' && userAgent.length > 0 ? userAgent : undefined, + // Platform lets the sender route mobile PWA push through the same presence gate as APNs. + platform: + typeof platform === 'string' && platform + ? platform + : typeof previous?.platform === 'string' + ? previous.platform + : undefined, }); subsBySession[uiSessionToken] = filtered.slice(0, 10); @@ -230,18 +239,32 @@ export const createPushRuntime = (deps) => { } await Promise.all(Array.from(subscriptionsByEndpoint.values()).map(async (sub) => { - if (requireNoSse && isAnyUiVisible()) { - return; + if (requireNoSse) { + // Mobile PWA subscriptions follow the same presence model as native push: suppress only + // when an interactive (desktop/web) client is visible. The phone PWA's own foreground is + // handled in the service worker (focused-client check), so it won't double-notify. + // Non-mobile (desktop/web) subscriptions keep the existing any-visible gate. + const suppressed = isMobilePlatform(sub.platform) ? isAnyInteractiveClientVisible() : isAnyUiVisible(); + if (suppressed) return; } await sendPushToSubscription(sub, payload); })); }; - const updateUiVisibility = (token, visible) => { + // A client is "mobile" if it reports a native mobile platform. Anything else (web, desktop, + // vscode, or an older client that doesn't report a platform) is treated as interactive — i.e. + // a surface where the user would actually see the in-app notification. + const MOBILE_PLATFORMS = new Set(['ios', 'android']); + const isMobilePlatform = (platform) => typeof platform === 'string' && MOBILE_PLATFORMS.has(platform); + + const updateUiVisibility = (token, visible, platform) => { if (!token) return; const now = Date.now(); const nextVisible = Boolean(visible); - uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now }); + const existing = uiVisibilityByToken.get(token); + // Keep the last known platform if this beacon didn't carry one (e.g. a heartbeat). + const nextPlatform = typeof platform === 'string' && platform ? platform : existing?.platform; + uiVisibilityByToken.set(token, { visible: nextVisible, updatedAt: now, platform: nextPlatform }); }; const isAnyUiVisible = () => { @@ -255,6 +278,25 @@ export const createPushRuntime = (deps) => { return false; }; + // True when at least one NON-mobile client (desktop/web/vscode) is currently visible. Used to + // suppress native push to the phone: an active desktop already shows the notification, so the + // phone doesn't need it. Deliberately based on the desktop's visibility (reliable), never the + // phone's own (a backgrounded WKWebView can't report "hidden" before iOS suspends it). + const isAnyInteractiveClientVisible = () => { + const now = Date.now(); + pruneUiVisibility(now); + for (const state of uiVisibilityByToken.values()) { + if ( + state.visible === true && + now - state.updatedAt <= UI_VISIBILITY_TTL_MS && + !isMobilePlatform(state.platform) + ) { + return true; + } + } + return false; + }; + const isUiVisible = (token) => { const now = Date.now(); pruneUiVisibility(now); @@ -317,6 +359,7 @@ export const createPushRuntime = (deps) => { sendPushToAllUiSessions, updateUiVisibility, isAnyUiVisible, + isAnyInteractiveClientVisible, isUiVisible, ensurePushInitialized, setPushInitialized, diff --git a/packages/web/server/lib/notifications/push-runtime.test.js b/packages/web/server/lib/notifications/push-runtime.test.js index cfcb756e89..20de23a867 100644 --- a/packages/web/server/lib/notifications/push-runtime.test.js +++ b/packages/web/server/lib/notifications/push-runtime.test.js @@ -42,4 +42,38 @@ describe('push runtime visibility tracking', () => { expect(runtime.isAnyUiVisible()).toBe(false); expect(runtime.isUiVisible('visible-client')).toBe(false); }); + + it('treats only mobile platforms as non-interactive for isAnyInteractiveClientVisible', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const runtime = createRuntime(); + + // Only the phone (foreground) is connected → no interactive client to absorb the notification. + runtime.updateUiVisibility('phone', true, 'ios'); + expect(runtime.isAnyUiVisible()).toBe(true); + expect(runtime.isAnyInteractiveClientVisible()).toBe(false); + + // A visible desktop counts as interactive → suppress mobile push. + runtime.updateUiVisibility('desktop', true, 'desktop'); + expect(runtime.isAnyInteractiveClientVisible()).toBe(true); + + // Desktop hidden again → back to mobile-only, push should flow to the phone. + runtime.updateUiVisibility('desktop', false, 'desktop'); + expect(runtime.isAnyInteractiveClientVisible()).toBe(false); + + // A client that never reported a platform is treated as interactive (conservative). + runtime.updateUiVisibility('legacy', true); + expect(runtime.isAnyInteractiveClientVisible()).toBe(true); + }); + + it('remembers the last platform when a heartbeat omits it', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const runtime = createRuntime(); + runtime.updateUiVisibility('phone', true, 'android'); + runtime.updateUiVisibility('phone', true); // heartbeat without platform + expect(runtime.isAnyInteractiveClientVisible()).toBe(false); + }); }); diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js index 32ea30e45e..4f291a28cc 100644 --- a/packages/web/server/lib/notifications/routes.js +++ b/packages/web/server/lib/notifications/routes.js @@ -35,7 +35,10 @@ export const registerNotificationRoutes = (app, dependencies) => { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, @@ -106,6 +109,7 @@ export const registerNotificationRoutes = (app, dependencies) => { } } + const platform = typeof req.body?.platform === 'string' ? req.body.platform : undefined; await addOrUpdatePushSubscription( uiToken, { @@ -113,7 +117,8 @@ export const registerNotificationRoutes = (app, dependencies) => { p256dh: keys.p256dh, auth: keys.auth, }, - req.headers['user-agent'] + req.headers['user-agent'], + platform ); return res.json({ ok: true }); @@ -138,6 +143,50 @@ export const registerNotificationRoutes = (app, dependencies) => { return res.json({ ok: true }); }); + // Native iOS APNs device token registration (mirrors /api/push/subscribe). The token + // is a hex APNs device token from @capacitor/push-notifications, scoped to the UI + // session like web-push subscriptions. + app.post('/api/push/apns-token', async (req, res) => { + await ensureSessionWatcher(); + + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if (!deviceToken) { + return res.status(400).json({ error: 'Invalid body' }); + } + + const platform = req.body?.platform === 'android' ? 'android' : 'ios'; + if (typeof addOrUpdateApnsToken === 'function') { + await addOrUpdateApnsToken(uiToken, deviceToken, req.headers['user-agent'], platform); + } + return res.json({ ok: true }); + }); + + app.delete('/api/push/apns-token', async (req, res) => { + const uiToken = uiAuthController?.ensureSessionToken + ? await uiAuthController.ensureSessionToken(req, res) + : getUiSessionTokenFromRequest(req); + if (!uiToken) { + return res.status(401).json({ error: 'UI session missing' }); + } + + const deviceToken = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if (!deviceToken) { + return res.status(400).json({ error: 'Invalid body' }); + } + + if (typeof removeApnsToken === 'function') { + await removeApnsToken(uiToken, deviceToken); + } + return res.json({ ok: true }); + }); + app.post('/api/push/visibility', async (req, res) => { const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) @@ -146,8 +195,9 @@ export const registerNotificationRoutes = (app, dependencies) => { return res.status(401).json({ error: 'UI session missing' }); } - const visible = req.body && typeof req.body === 'object' ? req.body.visible : null; - updateUiVisibility(uiToken, visible === true); + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const platform = typeof body.platform === 'string' ? body.platform : undefined; + updateUiVisibility(uiToken, body.visible === true, platform); return res.json({ ok: true }); }); @@ -301,6 +351,10 @@ export const registerNotificationRoutes = (app, dependencies) => { const clientId = req.headers['x-client-id'] || req.ip || 'anonymous'; markSessionViewed(sessionId, clientId); + // The user is engaging with the app, so the native push badge no longer + // applies — reset it here too (not only on the visibility beacon), since + // opening the app reliably marks the opened session viewed. + if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge(); return res.json({ success: true, @@ -326,6 +380,9 @@ export const registerNotificationRoutes = (app, dependencies) => { const sessionId = req.params.id; markUserMessageSent(sessionId); + // Sending a message means the user is active in the app; reset the native + // push badge so it counts only notifications since this engagement. + if (typeof clearPendingPushBadge === 'function') clearPendingPushBadge(); return res.json({ success: true, diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js index 5a2d825918..01e8a24557 100644 --- a/packages/web/server/lib/notifications/runtime.js +++ b/packages/web/server/lib/notifications/runtime.js @@ -10,10 +10,84 @@ export const createNotificationTriggerRuntime = (deps) => { emitDesktopNotification, broadcastUiNotification, sendPushToAllUiSessions, + sendApnsToAllUiSessions, + isAnyInteractiveClientVisible, buildOpenCodeUrl, getOpenCodeAuthHeaders, } = deps; + // App-icon badge for native push: the set of DISTINCT collapse-ids (the push + // `tag`, e.g. `ready-` / `permission-`) we've sent since + // the app was last foregrounded. The badge is the absolute APNs `aps.badge`. + // + // We key by `tag`, not sessionId, because the tag IS the banner identity: iOS + // uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while + // different tags are distinct banners. One session can raise several banners + // (ready + question + permission are different tags), so counting sessionIds + // both over- and under-counts the lock-screen stack; counting tags mirrors it. + // + // We deliberately do NOT derive this from the live attention snapshot + // (needsAttention/isViewed): that machinery is for in-app indicators on + // connected clients — a backgrounded client stays "viewing", and needsAttention + // is set by a separate session.status event that races the push trigger. The + // set is cleared when a UI client reports visible (`clearPendingPushBadge`), + // the same moment the device zeroes its icon badge on becomeActive. + const pendingPushTags = new Set(); + const clearPendingPushBadge = () => { + pendingPushTags.clear(); + }; + const trackPushAndCountBadge = (tag) => { + if (typeof tag === 'string' && tag.length > 0) { + pendingPushTags.add(tag); + } + return pendingPushTags.size; + }; + + // Generic notification for native push (per the mobile design): a fixed, scenario-based + // title + the session name as the body. No model/project/message content crosses the relay. + const APNS_TITLE_BY_TYPE = { + ready: 'Agent response is ready', + error: 'Agent hit an error', + question: 'Agent needs your input', + permission: 'Agent needs permission', + }; + + const toApnsGenericPayload = (payload) => { + const data = payload?.data && typeof payload.data === 'object' ? payload.data : {}; + const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0 + ? data.sessionName.trim() + : 'Session'; + return { + title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update', + body: sessionName, + badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined), + tag: payload?.tag, + // sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content. + data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined, + }; + }; + + // Fan a notification out to every delivery channel: browser web-push (full templated + // payload) and native iOS APNs (generic model-based text). Both share the dedup tag and + // `requireNoSse` focus gate; a failure in one channel must not block the other. + const fanoutPush = (payload, options) => { + // Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is + // currently visible, it already shows the in-app notification, so skip the native push to the + // phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we + // also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push. + const interactiveVisible = isAnyInteractiveClientVisible?.() === true; + return Promise.all([ + Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => { + console.warn('[Push] web-push fanout failed:', error?.message ?? error); + }), + interactiveVisible + ? Promise.resolve() + : Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => { + console.warn('[APNs] fanout failed:', error?.message ?? error); + }), + ]); + }; + let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function' ? deps.getIsWindowFocused : null; @@ -240,6 +314,7 @@ export const createNotificationTriggerRuntime = (deps) => { let title = `${formatMode(info?.mode)} agent is ready`; let body = `${formatModelId(info?.modelID)} completed the task`; + let sessionName = ''; try { const templates = settings.notificationTemplates || {}; @@ -249,6 +324,7 @@ export const createNotificationTriggerRuntime = (deps) => { : (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' }); const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; const messageId = info?.id; let lastMessage = extractLastMessageText(payload); @@ -283,7 +359,7 @@ export const createNotificationTriggerRuntime = (deps) => { broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); } - await sendPushToAllUiSessions( + await fanoutPush( { title, body, @@ -291,6 +367,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'ready', }, }, @@ -308,9 +385,11 @@ export const createNotificationTriggerRuntime = (deps) => { let title = 'Tool error'; let body = 'An error occurred'; + let sessionName = ''; try { const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; const errorMessageId = info?.id; let lastMessage = extractLastMessageText(payload); if (!lastMessage) { @@ -345,7 +424,7 @@ export const createNotificationTriggerRuntime = (deps) => { broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); } - await sendPushToAllUiSessions( + await fanoutPush( { title, body, @@ -353,6 +432,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'error', }, }, @@ -391,9 +471,11 @@ export const createNotificationTriggerRuntime = (deps) => { ? 'Switch to build mode' : header || 'Input needed'; let body = questionText || 'Agent is waiting for your response'; + let sessionName = ''; try { const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; variables.last_message = questionText || header || ''; const templates = settings.notificationTemplates || {}; @@ -421,7 +503,7 @@ export const createNotificationTriggerRuntime = (deps) => { broadcastUiNotification(notificationPayload, { desktopNotificationDelivered }); } - void sendPushToAllUiSessions( + void fanoutPush( { title, body, @@ -429,6 +511,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'question', }, }, @@ -505,9 +588,11 @@ export const createNotificationTriggerRuntime = (deps) => { let title = 'Permission required'; let body = fallbackMessage; + let sessionName = ''; try { const variables = await buildTemplateVariables(payload, sessionId); + sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName; variables.last_message = fallbackMessage; const templates = settings.notificationTemplates || {}; @@ -539,7 +624,7 @@ export const createNotificationTriggerRuntime = (deps) => { notifiedPermissionRequests.add(requestKey); } - void sendPushToAllUiSessions( + void fanoutPush( { title, body, @@ -547,6 +632,7 @@ export const createNotificationTriggerRuntime = (deps) => { data: { url: buildSessionDeepLinkUrl(sessionId), sessionId, + sessionName, type: 'permission', }, }, @@ -562,5 +648,6 @@ export const createNotificationTriggerRuntime = (deps) => { maybeSendPushForTrigger, setAutoAcceptSession, setGetIsWindowFocused, + clearPendingPushBadge, }; }; diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 7b41d17c55..49d43e9825 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -32,7 +32,10 @@ export const createBootstrapRuntime = (dependencies) => { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, @@ -95,7 +98,10 @@ export const createBootstrapRuntime = (dependencies) => { writeSettingsToDisk, addOrUpdatePushSubscription, removePushSubscription, + addOrUpdateApnsToken, + removeApnsToken, updateUiVisibility, + clearPendingPushBadge, isUiVisible, getUiNotificationClients, writeSseEvent, diff --git a/packages/web/server/lib/security/request-security.js b/packages/web/server/lib/security/request-security.js index 183c384736..5fb85cde22 100644 --- a/packages/web/server/lib/security/request-security.js +++ b/packages/web/server/lib/security/request-security.js @@ -1,6 +1,6 @@ export const createRequestSecurityRuntime = (deps) => { const { readSettingsFromDiskMigrated } = deps; - const packagedClientOrigins = new Set(['openchamber-ui://app']); + const packagedClientOrigins = new Set(['openchamber-ui://app', 'capacitor://localhost']); const getUiSessionTokenFromRequest = (req) => { const cookieHeader = req?.headers?.cookie; diff --git a/packages/web/server/lib/security/request-security.test.js b/packages/web/server/lib/security/request-security.test.js index a37cb05729..031e8bef1a 100644 --- a/packages/web/server/lib/security/request-security.test.js +++ b/packages/web/server/lib/security/request-security.test.js @@ -6,7 +6,7 @@ const createRuntime = () => createRequestSecurityRuntime({ }); describe('request security runtime', () => { - test('allows packaged client origin for remote client transports', async () => { + test('allows packaged client origins for remote client transports', async () => { const runtime = createRuntime(); await expect(runtime.isRequestOriginAllowed({ @@ -16,5 +16,13 @@ describe('request security runtime', () => { }, socket: {}, })).resolves.toBe(true); + + await expect(runtime.isRequestOriginAllowed({ + headers: { + origin: 'capacitor://localhost', + host: '192.168.1.130:1202', + }, + socket: {}, + })).resolves.toBe(true); }); }); diff --git a/packages/web/src/api/push.ts b/packages/web/src/api/push.ts index 525e4f1fdf..d93047c2a3 100644 --- a/packages/web/src/api/push.ts +++ b/packages/web/src/api/push.ts @@ -1,4 +1,4 @@ -import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types'; +import type { ApnsTokenPayload, PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types'; import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; const fetchJson = async (input: string | URL | Request, init?: RequestInit): Promise => { @@ -47,7 +47,7 @@ export const createWebPushAPI = (): PushAPI => ({ }); }, - async setVisibility(payload: { visible: boolean }) { + async setVisibility(payload: { visible: boolean; platform?: string }) { return fetchJson<{ ok: true }>('/api/push/visibility', { method: 'POST', headers: { @@ -57,4 +57,24 @@ export const createWebPushAPI = (): PushAPI => ({ keepalive: true, }); }, + + async registerApnsToken(payload: ApnsTokenPayload) { + return fetchJson<{ ok: true }>('/api/push/apns-token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + }, + + async unregisterApnsToken(payload: ApnsTokenPayload) { + return fetchJson<{ ok: true }>('/api/push/apns-token', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + }, }); From e2e6ec73ffedb0f544cb6ad153a06a6b319ff553 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 13:25:43 +0300 Subject: [PATCH 36/88] feat: add Clack-based local dev helper Adds a cross-platform oc-dev menu for web, mobile, Electron, VS Code, and release workflows Supports user-level config for remote deploys, iOS device preferences, and maintainer-only release tools Ports local deploy flows from Bash snippets to Node-native operations --- bun.lock | 1 + package.json | 2 + scripts/oc-dev.config.example.json | 28 ++ scripts/oc-dev.mjs | 606 +++++++++++++++++++++++++++++ 4 files changed, 637 insertions(+) create mode 100644 scripts/oc-dev.config.example.json create mode 100755 scripts/oc-dev.mjs diff --git a/bun.lock b/bun.lock index 72a8a905a5..52f0ff1d4c 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "zustand": "^5.0.8", }, "devDependencies": { + "@clack/prompts": "^1.1.0", "@eslint/js": "^9.33.0", "@remixicon/react": "^4.7.0", "@tailwindcss/postcss": "^4.0.0", diff --git a/package.json b/package.json index 3d6f9a0c98..6e9de3d392 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "license": "MIT", "scripts": { "dev": "node ./scripts/dev-web-hmr.mjs", + "oc-dev": "bun scripts/oc-dev.mjs", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", @@ -148,6 +149,7 @@ "@codemirror/view": "6.39.13" }, "devDependencies": { + "@clack/prompts": "^1.1.0", "@eslint/js": "^9.33.0", "@tailwindcss/postcss": "^4.0.0", "@types/dom-speech-recognition": "^0.0.12", diff --git a/scripts/oc-dev.config.example.json b/scripts/oc-dev.config.example.json new file mode 100644 index 0000000000..971c7f9674 --- /dev/null +++ b/scripts/oc-dev.config.example.json @@ -0,0 +1,28 @@ +{ + "ios": { + "deviceName": "iPhone Example", + "useXcodeBeta": false, + "xcodeAppName": "Xcode" + }, + "features": { + "releaseTools": false + }, + "remoteDeployments": [ + { + "id": "example-api", + "label": "example API-only", + "host": "example-host", + "port": 3002, + "dir": "testing-dev", + "apiOnly": true + }, + { + "id": "example-ui", + "label": "example with UI", + "host": "example-host", + "port": 3002, + "dir": "testing-dev", + "apiOnly": false + } + ] +} diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs new file mode 100755 index 0000000000..b6c46dba21 --- /dev/null +++ b/scripts/oc-dev.mjs @@ -0,0 +1,606 @@ +#!/usr/bin/env node +/** + * OpenChamber local development helper. + * + * This script owns the interactive `bun run oc-dev` menu and the equivalent + * non-interactive commands for common local workflows: web deploys, mobile + * builds/device deploys, Electron, VS Code, and maintainer release tasks. + * + * Personal or machine-specific options are intentionally kept out of git. + * The only supported user config is: + * + * ~/.config/openchamber/oc-dev.json + * + * See `scripts/oc-dev.config.example.json` for the shape. The config can set + * local device/app preferences such as `ios.deviceName`, `ios.useXcodeBeta`, + * and `ios.xcodeAppName`, and can define `remoteDeployments`. Remote deploy + * menu entries are shown only when configured. Maintainer-only actions such as + * release creation are hidden unless `features.releaseTools` is true. + * + * Menus are platform-aware: macOS-only iOS/Xcode actions are hidden off macOS. + * Direct unsupported commands fail with a clear error instead of relying on + * prompts for safety. + */ +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { cancel, intro, isCancel, log, outro, select, text } from '@clack/prompts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '..'); +const configPath = path.join(os.homedir(), '.config', 'openchamber', 'oc-dev.json'); + +const GLOBAL_PORT = '2606'; +const TESTING_PORT = '1202'; +const TESTING_DIR = 'testing-dev'; +const REMOTE_RUNTIME_ENV = 'PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH; if [ -z "${OPENCODE_BINARY:-}" ]; then OPENCODE_CANDIDATE=$(command -v opencode 2>/dev/null || true); if [ -n "$OPENCODE_CANDIDATE" ]; then export OPENCODE_BINARY="$OPENCODE_CANDIDATE"; fi; fi'; + +const isTty = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY); +const isMac = process.platform === 'darwin'; + +function printHelp() { + console.log(`Usage: + bun run oc-dev [action] [options] + bun scripts/oc-dev.mjs [action] [options] + +Actions: + build-deploy-web Build web package and deploy + remote-deploy-web Deploy to configured remote target + start-web-dev Start web development loop + start-mobile-dev Start mobile app with dev server live reload + mobile-tools Mobile build/sync/deploy helper menu + start-electron-app Start Electron app in dev mode + build-electron-app Build Electron app artifacts + start-vscode-extension Build + launch VS Code extension host + install-vscode-extension-local Build, package, and install local VSIX + create-release Validate and bump release version + +Options: + -a, --action + --deployment-mode + --remote-id Remote deployment id from ${configPath} + --target Compatibility alias for remote deployment selection + --web-mode + --mobile-mode + --mobile-task + --vsix-cleanup + --version + -h, --help + +Mobile tasks: + build, sync, android-devices, android-deploy-usb, android-run, android-logcat, + ios-sim-build, ios-sim-run, ios-sim-serve, ios-sim-kill, ios-device-sync-debug +`); +} + +function parseArgs(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) throw new Error(`Missing value for ${arg}`); + index += 1; + return value; + }; + + switch (arg) { + case '-h': + case '--help': + options.help = true; + break; + case '-a': + case '--action': + options.action = readValue(); + break; + case '--deployment-mode': + options.deploymentMode = readValue(); + break; + case '--remote-id': + options.remoteId = readValue(); + break; + case '--target': + options.target = readValue(); + break; + case '--web-mode': + options.webMode = readValue(); + break; + case '--mobile-mode': + options.mobileMode = readValue(); + break; + case '--mobile-task': + options.mobileTask = readValue(); + break; + case '--vsix-cleanup': + options.vsixCleanup = readValue(); + break; + case '--version': + options.version = readValue(); + break; + default: + if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`); + if (options.action) throw new Error(`Unexpected argument: ${arg}`); + options.action = arg; + break; + } + } + return options; +} + +function loadConfig() { + if (!existsSync(configPath)) return { remoteDeployments: [] }; + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf8')); + return { + ...parsed, + remoteDeployments: Array.isArray(parsed.remoteDeployments) ? parsed.remoteDeployments : [], + }; + } catch (error) { + throw new Error(`Failed to read ${configPath}: ${error.message}`); + } +} + +function quote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd || repoRoot, + env: { ...process.env, ...(options.env || {}) }, + stdio: options.capture ? 'pipe' : 'inherit', + encoding: 'utf8', + shell: options.shell || false, + }); + if (result.status !== 0 && !options.allowFail) { + throw new Error(`${options.label || [command, ...args].join(' ')} failed`); + } + return result.stdout?.trim() || ''; +} + +function step(label, fn) { + log.step(label); + const result = fn(); + log.success(`${label} completed`); + return result; +} + +function normalizeAction(action = '') { + const normalized = action.toLowerCase(); + const aliases = { + 'deploy-web': 'build-deploy-web', + 'build/deploy-web': 'build-deploy-web', + 'web-dev': 'start-web-dev', + 'mobile-dev': 'start-mobile-dev', + 'ios-sim-dev': 'start-mobile-dev', + mobile: 'mobile-tools', + 'mobile-menu': 'mobile-tools', + 'remote-deploy-web': 'remote-deploy-web', + 'electron-dev': 'start-electron-app', + 'electron-build': 'build-electron-app', + 'vscode-dev': 'start-vscode-extension', + 'vscode-install-local': 'install-vscode-extension-local', + release: 'create-release', + }; + return aliases[normalized] || normalized; +} + +function ensurePromptable() { + if (!isTty) throw new Error('Missing required option and no TTY is available for prompting.'); +} + +async function chooseValue(current, choices, message) { + if (current) return current; + ensurePromptable(); + const value = await select({ message, options: choices }); + if (isCancel(value)) { + cancel('Operation cancelled.'); + process.exit(130); + } + return value; +} + +function detectLanIp() { + for (const addresses of Object.values(os.networkInterfaces())) { + for (const address of addresses || []) { + if (address.family === 'IPv4' && !address.internal) return address.address; + } + } + return ''; +} + +function removeFilesByPrefixSuffix(directory, prefix, suffix) { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory)) { + if (!entry.startsWith(prefix) || !entry.endsWith(suffix)) continue; + unlinkSync(path.join(directory, entry)); + } +} + +function latestFileByExtensions(directory, extensions) { + if (!existsSync(directory)) return ''; + return readdirSync(directory) + .filter((entry) => extensions.some((extension) => entry.endsWith(extension))) + .map((entry) => { + const filePath = path.join(directory, entry); + return { filePath, mtimeMs: statSync(filePath).mtimeMs }; + }) + .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || ''; +} + +function resetDirectory(directory) { + mkdirSync(directory, { recursive: true }); + for (const entry of ['package.json', 'package-lock.json', 'pnpm-lock.yaml', 'bun.lockb']) { + rmSync(path.join(directory, entry), { force: true }); + } + rmSync(path.join(directory, 'node_modules'), { recursive: true, force: true }); +} + +function installedWebCli(directory) { + const cliPath = path.join(directory, 'node_modules', '@openchamber', 'web', 'bin', 'cli.js'); + return existsSync(cliPath) ? cliPath : ''; +} + +function stopInstalledInstance(directory, port) { + const cliPath = installedWebCli(directory); + if (!cliPath) return; + run('node', [cliPath, 'stop', '--port', port], { cwd: directory, allowFail: true, label: `stop instance on ${port}` }); +} + +function startInstalledInstance(directory, port) { + const cliPath = installedWebCli(directory); + if (!cliPath) throw new Error(`OpenChamber CLI was not installed in ${directory}`); + run('node', [cliPath, '--port', port], { + cwd: directory, + env: { + OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', + OPENCHAMBER_HOST: '0.0.0.0', + }, + label: `start instance on ${port}`, + }); +} + +function packageWeb() { + step('Building web bundle', () => run('bun', ['run', '--cwd', 'packages/web', 'build'])); + const packOutput = step('Creating web package archive', () => run('npm', ['pack', '--pack-destination', repoRoot], { cwd: path.join(repoRoot, 'packages/web'), capture: true })); + const packageName = packOutput.split('\n').find((line) => line.trim().endsWith('.tgz'))?.trim(); + if (!packageName) throw new Error('Archive creation failed: npm pack did not print a .tgz file.'); + return path.join(repoRoot, packageName); +} + +async function selectRemoteDeployment(config, options) { + if (options.remoteId) { + const remote = config.remoteDeployments.find((entry) => entry.id === options.remoteId); + if (!remote) throw new Error(`No remote deployment with id "${options.remoteId}" in ${configPath}`); + return remote; + } + + if (options.target) { + const normalizedTarget = options.target.toLowerCase(); + const apiOnly = ['test', 'testing', 'test-api', 'api', 'api-only'].includes(normalizedTarget); + const withUi = ['test-ui', 'ui', 'with-ui'].includes(normalizedTarget); + if (!apiOnly && !withUi) throw new Error('Invalid --target. Use test-api or test-ui.'); + const remote = config.remoteDeployments.find((entry) => Boolean(entry.apiOnly) === apiOnly || (!entry.apiOnly && withUi)); + if (remote) return remote; + } + + if (config.remoteDeployments.length === 0) { + throw new Error(`No remoteDeployments configured in ${configPath}`); + } + + return chooseValue( + '', + config.remoteDeployments.map((remote) => ({ value: remote.id, label: remote.label || remote.id, hint: `${remote.host}:${remote.port}` })), + 'Select remote deployment', + ).then((id) => config.remoteDeployments.find((entry) => entry.id === id)); +} + +async function deployWeb(options, config) { + const deploymentMode = (await chooseValue(options.deploymentMode, [ + { value: 'global', label: 'Global' }, + { value: 'testing', label: 'Testing' }, + ], 'Select installation mode')).toLowerCase(); + + if (!['global', 'testing'].includes(deploymentMode)) { + throw new Error('Invalid deployment mode. Use global or testing. Use remote-deploy-web for configured remote deployments.'); + } + + const packageFile = packageWeb(); + + if (deploymentMode === 'testing') { + const testingDir = path.join(os.homedir(), TESTING_DIR); + step(`Stopping testing instance on ${TESTING_PORT}`, () => stopInstalledInstance(testingDir, TESTING_PORT)); + step('Preparing testing install directory', () => { + resetDirectory(testingDir); + run('bun', ['init', '-y'], { cwd: testingDir }); + }); + step('Installing testing package', () => run('bun', ['add', packageFile], { cwd: testingDir })); + step(`Starting testing instance on ${TESTING_PORT}`, () => startInstalledInstance(testingDir, TESTING_PORT)); + return; + } + + step(`Stopping global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['stop', '--port', GLOBAL_PORT], { allowFail: true, label: `stop global instance on ${GLOBAL_PORT}` })); + step('Removing old global package', () => { + run('bun', ['remove', '-g', '@openchamber/web'], { allowFail: true, label: 'remove @openchamber/web' }); + run('bun', ['remove', '-g', 'openchamber'], { allowFail: true, label: 'remove openchamber' }); + }); + step('Installing package globally', () => run('bun', ['add', '-g', packageFile])); + step(`Starting global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } })); +} + +async function deployRemoteWeb(options, config) { + const remote = await selectRemoteDeployment(config, options); + const packageFile = packageWeb(); + const host = remote.host; + const dir = remote.dir; + const port = String(remote.port); + const apiOnly = remote.apiOnly ? 'true' : 'false'; + const packageBase = path.basename(packageFile); + + if (!host || !dir || !port) throw new Error(`Remote deployment ${remote.id} must define host, dir, and port.`); + + step('Preparing remote directories', () => run('ssh', [host, `mkdir -p ~/${dir}/releases`])); + step(`Stopping remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; ${REMOTE_RUNTIME_ENV}; cd ~/${dir} 2>/dev/null || exit 0; PORT=${quote(port)}; TMPDIR=$(node -p "require('os').tmpdir()" 2>/dev/null || echo /tmp); PIDFILE="$TMPDIR/openchamber-${port}.pid"; INSTANCEFILE="$TMPDIR/openchamber-${port}.json"; if [ -f ./node_modules/@openchamber/web/bin/cli.js ]; then bun ./node_modules/@openchamber/web/bin/cli.js stop --port "$PORT" >/dev/null 2>&1 || node ./node_modules/@openchamber/web/bin/cli.js stop --port "$PORT" >/dev/null 2>&1 || true; fi; if command -v lsof >/dev/null 2>&1; then lsof -ti :"$PORT" | xargs -r kill >/dev/null 2>&1 || true; sleep 0.5; lsof -ti :"$PORT" | xargs -r kill -9 >/dev/null 2>&1 || true; fi; rm -f "$PIDFILE" "$INSTANCEFILE"`], { label: 'stop remote instance' })); + step('Copying package to remote', () => { + run('ssh', [host, `mkdir -p ~/${dir}/releases && rm -f ~/${dir}/releases/*.tgz`]); + run('scp', ['-q', packageFile, `${host}:~/${dir}/releases/${packageBase}`]); + }); + step('Resetting remote install state', () => run('ssh', [host, `cd ~/${dir} && rm -f package.json package-lock.json pnpm-lock.yaml bun.lockb && rm -rf node_modules`])); + step('Preparing remote package manifest', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm init -y >/dev/null 2>&1`])); + step('Installing remote package', () => run('ssh', [host, `cd ~/${dir} && ${REMOTE_RUNTIME_ENV}; npm install ./releases/${packageBase}`])); + step(`Starting remote instance on ${host}:${port}`, () => run('ssh', [host, `set -e; cd ~/${dir}; ${REMOTE_RUNTIME_ENV}; PASSWORD_VALUE=$(grep '^export OPENCHAMBER_UI_PASSWORD=' ~/.bashrc 2>/dev/null | sed -E 's/.*=["“]?([^"”]+)["”]?/\\1/' || true); if [ -n "$PASSWORD_VALUE" ]; then export OPENCHAMBER_UI_PASSWORD="$PASSWORD_VALUE"; fi; if [ ${quote(apiOnly)} = 'true' ]; then export OPENCHAMBER_API_ONLY=true; fi; OPENCHAMBER_HOST=0.0.0.0 node ./node_modules/@openchamber/web/bin/cli.js --port ${quote(port)} >/dev/null 2>&1; sleep 0.5; if command -v lsof >/dev/null 2>&1; then lsof -ti :${quote(port)} >/dev/null 2>&1 || exit 1; fi`])); + log.success(`Remote deployment ready: ${host}:${port}`); +} + +async function startWebDev(options) { + const mode = await chooseValue(options.webMode, [ + { value: 'hmr', label: 'Web HMR' }, + { value: 'hmr-lan', label: 'Web HMR LAN/mobile' }, + { value: 'full', label: 'Web prod-like' }, + ], 'Select web dev mode'); + + if (mode === 'hmr-lan') { + log.info('Starting web HMR LAN/mobile loop. Open the LAN URL printed after startup.'); + run('bun', ['run', 'dev:web:hmr'], { env: { OPENCHAMBER_HMR_HOST: '0.0.0.0' } }); + } else if (mode === 'full') { + run('bun', ['run', 'dev:web:full']); + } else { + run('bun', ['run', 'dev:web:hmr']); + } +} + +async function startMobileDev(options) { + const mobileModeChoices = [ + { value: 'ios-sim-local', label: 'iOS Simulator local' }, + { value: 'ios-sim-lan', label: 'iOS Simulator LAN' }, + { value: 'android-local', label: 'Android emulator local' }, + { value: 'android-lan', label: 'Android device LAN' }, + ].filter((choice) => isMac || !choice.value.startsWith('ios-')); + const mode = await chooseValue(options.mobileMode, mobileModeChoices, 'Select mobile dev mode'); + + if (mode.startsWith('ios-') && !isMac) { + throw new Error('iOS mobile dev actions require macOS and Xcode.'); + } + + const hmrPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180'; + let hmrBindHost = '127.0.0.1'; + let liveReloadHost = '127.0.0.1'; + let platform = 'ios'; + let extraArgs = []; + + if (mode === 'ios-sim-lan' || mode === 'android-lan') { + hmrBindHost = '0.0.0.0'; + liveReloadHost = detectLanIp(); + if (!liveReloadHost) throw new Error('Could not detect LAN IP.'); + } + if (mode.startsWith('android')) platform = 'android'; + if (mode === 'android-local') extraArgs = ['--forwardPorts', `${hmrPort}:${hmrPort}`]; + + log.step(`Starting mobile UI dev server on ${hmrBindHost}:${hmrPort}`); + const devServer = spawn('bun', ['x', 'vite', '--config', 'local-dev-mobile-vite.config.mjs', '--host', hmrBindHost, '--port', hmrPort, '--strictPort'], { + cwd: repoRoot, + stdio: 'inherit', + env: { ...process.env, OPENCHAMBER_DISABLE_PWA_DEV: '1' }, + }); + + const stopDevServer = () => { + if (!devServer.killed) devServer.kill('SIGTERM'); + }; + process.once('SIGINT', () => { + stopDevServer(); + process.exit(130); + }); + process.once('SIGTERM', () => { + stopDevServer(); + process.exit(143); + }); + + await new Promise((resolve) => setTimeout(resolve, 6000)); + run('node', ['scripts/with-mobile-env.mjs', `bunx cap run ${platform} --live-reload --host ${liveReloadHost} --port ${hmrPort} ${extraArgs.join(' ')}`], { cwd: path.join(repoRoot, 'packages/mobile') }); + log.info('Mobile UI dev server is still running. Press Ctrl+C to stop.'); + await new Promise((resolve) => devServer.on('exit', resolve)); +} + +async function mobileTools(options, config) { + const mobileTaskChoices = [ + { value: 'build', label: 'Build mobile web assets' }, + { value: 'sync', label: 'Sync native projects' }, + { value: 'android-devices', label: 'Android: list USB devices' }, + { value: 'android-deploy-usb', label: 'Android: rebuild + deploy to USB device' }, + { value: 'android-run', label: 'Android: install + launch existing APK' }, + { value: 'android-logcat', label: 'Android: logcat' }, + { value: 'ios-sim-build', label: 'iOS Simulator: build' }, + { value: 'ios-sim-run', label: 'iOS Simulator: install + launch' }, + { value: 'ios-sim-serve', label: 'iOS Simulator: browser preview' }, + { value: 'ios-sim-kill', label: 'iOS Simulator: stop browser preview' }, + { value: 'ios-device-sync-debug', label: 'iOS Device: sync + open debugger workspace' }, + ].filter((choice) => isMac || !choice.value.startsWith('ios-')); + const task = await chooseValue(options.mobileTask, mobileTaskChoices, 'Select mobile action'); + + if (task.startsWith('ios-') && !isMac) { + throw new Error('iOS mobile actions require macOS and Xcode.'); + } + + const mobileCwd = path.join(repoRoot, 'packages/mobile'); + const mobileRun = (label, script) => step(label, () => run('bun', ['run', script], { cwd: mobileCwd })); + switch (task) { + case 'build': return mobileRun('Building mobile web assets', 'build'); + case 'sync': return mobileRun('Syncing native projects', 'sync'); + case 'android-devices': return mobileRun('Listing Android USB devices', 'android:devices'); + case 'android-deploy-usb': + mobileRun('Building Android debug APK', 'build:android:debug'); + return mobileRun('Installing and launching Android app on USB device', 'android:run'); + case 'android-run': return mobileRun('Installing and launching Android app on USB device', 'android:run'); + case 'android-logcat': return mobileRun('Streaming Android app logs', 'android:logcat'); + case 'ios-sim-build': return mobileRun('Building iOS Simulator app', 'build:ios:simulator'); + case 'ios-sim-run': return mobileRun('Installing and launching iOS Simulator app', 'sim:run'); + case 'ios-sim-serve': return mobileRun('Starting iOS Simulator browser preview', 'sim:serve'); + case 'ios-sim-kill': return mobileRun('Stopping iOS Simulator browser preview', 'sim:kill'); + case 'ios-device-sync-debug': { + mobileRun('Syncing iOS native project', 'sync'); + const deviceName = process.env.IOS_DEVICE_NAME || config.ios?.deviceName || 'iPhone Bohdan'; + const xcodeAppName = process.env.XCODE_APP_NAME || config.ios?.xcodeAppName || (config.ios?.useXcodeBeta ? 'Xcode-beta' : 'Xcode'); + log.info(`Target physical device: ${deviceName}`); + log.warn('CLI can sync/build/install parts of iOS, but attaching Apple\'s debugger to a physical iPhone is still Xcode\'s job. Select the device in Xcode and press Run.'); + if (process.platform !== 'darwin') throw new Error('Opening Xcode requires macOS.'); + return step(`Opening iOS workspace in ${xcodeAppName}`, () => run('open', ['-a', xcodeAppName, path.join(mobileCwd, 'ios/App/App.xcworkspace')])); + } + default: + throw new Error(`Unknown mobile task: ${task}`); + } +} + +function startElectronApp() { + run('bun', ['run', 'electron:dev']); +} + +function buildElectronApp() { + run('bun', ['run', 'electron:build'], { env: { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } }); + const distDir = path.join(repoRoot, 'packages/electron/dist'); + if (!existsSync(distDir) || !isMac) return; + const artifact = latestFileByExtensions(distDir, ['.dmg', '-mac.zip']); + if (artifact) run('open', [artifact]); +} + +function startVsCodeExtension() { + const vscodeDir = path.join(repoRoot, 'packages/vscode'); + removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Building VS Code extension', () => run('bun', ['run', 'vscode:build'])); + run('code', ['--extensionDevelopmentPath', vscodeDir]); +} + +async function installVsCodeExtensionLocal(options) { + const cleanup = await chooseValue(options.vsixCleanup, [ + { value: 'delete', label: 'Delete VSIX after install' }, + { value: 'keep', label: 'Keep VSIX after install' }, + ], 'Select VSIX cleanup mode'); + const vscodeDir = path.join(repoRoot, 'packages/vscode'); + step('Building VS Code extension', () => run('bun', ['run', '--cwd', 'packages/vscode', 'build'])); + removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Packaging VSIX', () => run('bunx', ['vsce', 'package', '--no-dependencies'], { cwd: vscodeDir })); + run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true }); + const vsix = latestFileByExtensions(vscodeDir, ['.vsix']); + if (!vsix) throw new Error('VSIX package was not created.'); + step('Installing VSIX locally', () => run('code', ['--install-extension', vsix])); + if (cleanup === 'delete') removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); +} + +async function createRelease(options) { + if (!options.config?.features?.releaseTools) { + throw new Error(`Release tools are disabled. Set features.releaseTools=true in ${configPath} to enable this maintainer task.`); + } + + let version = options.version; + if (!version) { + ensurePromptable(); + version = await text({ message: 'Enter release version', placeholder: '1.4.7' }); + if (isCancel(version)) { + cancel('Operation cancelled.'); + process.exit(130); + } + } + if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1'); + step('Validating codebase', () => run('bun', ['run', 'release:prepare'])); + step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version])); + log.success(`Release v${version} prepared locally`); +} + +async function chooseAction(config) { + const options = [ + { value: 'build-deploy-web', label: 'Build/Deploy web' }, + { value: 'start-web-dev', label: 'Start web dev' }, + { value: 'start-mobile-dev', label: 'Start mobile dev' }, + { value: 'mobile-tools', label: 'Mobile tools' }, + { value: 'start-electron-app', label: 'Start Electron app' }, + { value: 'build-electron-app', label: 'Build Electron app' }, + { value: 'start-vscode-extension', label: 'Start VS Code extension' }, + { value: 'install-vscode-extension-local', label: 'Install VS Code extension locally' }, + ]; + + if (config.features?.releaseTools) { + options.push({ value: 'create-release', label: 'Create Release' }); + } + if (config.remoteDeployments.length > 0) { + options.splice(1, 0, { value: 'remote-deploy-web', label: 'Deploy configured remote web' }); + } + const action = await chooseValue('', options, 'Select OpenChamber dev action'); + return action; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const config = loadConfig(); + const interactive = !options.action; + if (interactive) intro('OpenChamber dev'); + let action = normalizeAction(options.action || await chooseAction(config)); + + switch (action) { + case 'build-deploy-web': + await deployWeb(options, config); + break; + case 'remote-deploy-web': + await deployRemoteWeb(options, config); + break; + case 'start-web-dev': + await startWebDev(options); + break; + case 'start-mobile-dev': + await startMobileDev(options); + break; + case 'mobile-tools': + await mobileTools(options, config); + break; + case 'start-electron-app': + startElectronApp(); + break; + case 'build-electron-app': + buildElectronApp(); + break; + case 'start-vscode-extension': + startVsCodeExtension(); + break; + case 'install-vscode-extension-local': + await installVsCodeExtensionLocal(options); + break; + case 'create-release': + options.config = config; + await createRelease(options); + break; + default: + throw new Error(`Unknown action: ${action}`); + } + if (interactive) outro('Done'); +} + +main().catch((error) => { + log.error(error.message); + process.exit(1); +}); From dcbec91019d69a567f17dd8e03febac69ca5db4c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 17:21:19 +0300 Subject: [PATCH 37/88] fix: recover chat state after idle reconnects Resyncs active sessions after hidden upstream stream reconnects Recovers orphaned streaming parts with active-session snapshots Adds coverage for event-stream reconnect behavior --- bun.lock | 22 +++-- package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/sync/sync-context.tsx | 82 +++++++++++++------ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- .../lib/event-stream/global-ws-bridge.js | 11 +++ .../server/lib/event-stream/runtime.test.js | 4 +- 8 files changed, 86 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 52f0ff1d4c..2a05722edc 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -100,7 +100,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -136,7 +136,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -173,7 +173,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -242,10 +242,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.4", + "version": "1.13.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -265,14 +265,14 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.4", + "version": "1.13.8", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", @@ -1007,7 +1007,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-N8kazWO0ZLCHWYFuZQt1UJM+bWxY6g1auSG6SvD1+K3+W+nw2qIhDAUGNCD0KVW3bY2LCwvfWvpG2ZbVGCHC0Q=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], @@ -3387,7 +3387,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -3739,8 +3739,6 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - "serve-sim/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 6e9de3d392..7013f47cef 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index 509eb7435c..e44a52e33b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -46,7 +46,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index da34f3a47a..423ecf77b3 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -874,15 +874,20 @@ const childStoreHasSessionState = ( || Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID) } -const childStoreHasMessagePartState = ( - childStores: ChildStoreManager, - directory: string, - messageID: string, -): boolean => { +const childStoreHasMessagePartState = ( + childStores: ChildStoreManager, + directory: string, + messageID: string, +): boolean => { const store = childStores.getChild(directory) if (!store) return false - return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) -} + return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) +} + +const getActiveDirectoryFallback = (childStores: ChildStoreManager): string | null => { + if (!_activeDirectory || !_activeSession) return null + return childStores.getChild(_activeDirectory) ? _activeDirectory : null +} const resolveDirectoryFromRoutingIndex = ( routingIndex: EventRoutingIndex, @@ -927,12 +932,21 @@ const resolveDirectoryFromRoutingIndex = ( } // Scan child stores for a store that has parts for this message - for (const [dir, store] of childStores.children) { - if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { - return dir - } - } - } + for (const [dir, store] of childStores.children) { + if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) { + return dir + } + } + + // Some reconnect/idle gaps can deliver part events before the matching + // message.updated event and without a sessionID. If the user is actively + // viewing a session, route the orphaned part event there so the reducer can + // trigger HTTP materialization instead of dropping it as a global event. + const activeDirectory = getActiveDirectoryFallback(childStores) + if (activeDirectory) { + return activeDirectory + } + } // Single-store fallback: if there's only one directory, use it if ( @@ -946,8 +960,25 @@ const resolveDirectoryFromRoutingIndex = ( } } - return normalizedDirectory -} + return normalizedDirectory +} + +const resolveMaterializationSessionID = ( + materializationSessionID: string | undefined, + messageID: string | undefined, + resolvedDirectory: string, + routingIndex: EventRoutingIndex, +): string | undefined => { + if (materializationSessionID) return materializationSessionID + if (messageID) { + const indexedSessionID = routingIndex.messageSessionById.get(messageID) + if (indexedSessionID) return indexedSessionID + } + if (resolvedDirectory && resolvedDirectory === _activeDirectory && _activeSession) { + return _activeSession + } + return undefined +} const updateRoutingIndexFromEvent = ( routingIndex: EventRoutingIndex, @@ -1559,14 +1590,19 @@ function handleEvent( } - // Snapshot materialization is driven by typed reducer outcomes, not by - // inferring meaning from a generic false/no-change result. - if (materializationResult) { - const materializationSessionID = materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined - if (materializationSessionID) { - enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) - } - } + // Snapshot materialization is driven by typed reducer outcomes, not by + // inferring meaning from a generic false/no-change result. + if (materializationResult) { + const materializationSessionID = resolveMaterializationSessionID( + materializationResult.sessionID ?? getSessionIdFromPayload(payload) ?? undefined, + materializationResult.messageID ?? getMessageIdFromPayload(payload) ?? undefined, + resolvedDirectory, + routingIndex, + ) + if (materializationSessionID) { + enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) + } + } updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload) } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 9b52033833..20f556a5fa 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -244,7 +244,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 3ac7bdb4ab..1fd0c48b9b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.9", + "@opencode-ai/sdk": "^1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/packages/web/server/lib/event-stream/global-ws-bridge.js b/packages/web/server/lib/event-stream/global-ws-bridge.js index 9ab65c8413..d26f7ca0cb 100644 --- a/packages/web/server/lib/event-stream/global-ws-bridge.js +++ b/packages/web/server/lib/event-stream/global-ws-bridge.js @@ -120,6 +120,17 @@ export function createGlobalMessageStreamWsBridge({ for (const socket of Array.from(clients)) { if (!readyClients.has(socket)) { markReady(socket, clientLastEventIds.get(socket) ?? ''); + continue; + } + + if (status.wasReady) { + const sent = sendMessageStreamWsFrame(socket, { + type: 'ready', + scope: 'global', + }); + if (!sent) { + removeClient(socket); + } } } return; diff --git a/packages/web/server/lib/event-stream/runtime.test.js b/packages/web/server/lib/event-stream/runtime.test.js index 19065a9250..c86351126b 100644 --- a/packages/web/server/lib/event-stream/runtime.test.js +++ b/packages/web/server/lib/event-stream/runtime.test.js @@ -435,7 +435,7 @@ describe('message stream websocket runtime', () => { return createSseResponse({ signal: options.signal, - holdOpen: false, + holdOpen: true, blocks: [ 'id: evt-2\ndata: {"type":"server.connected","properties":{}}\n\n', ], @@ -451,7 +451,7 @@ describe('message stream websocket runtime', () => { const readyFrames = socket.sent.filter((frame) => frame.type === 'ready'); const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected'); - expect(readyFrames).toHaveLength(1); + expect(readyFrames.length).toBeGreaterThanOrEqual(2); expect(eventFrames.length).toBeGreaterThanOrEqual(2); expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']); expect(triggerHealthCheckCalls).toBe(0); From d351f2078f22bcccd82b877640d22ec95329dcc9 Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Wed, 1 Jul 2026 11:01:02 -0400 Subject: [PATCH 38/88] feat(pr-review): add risk score to review comment output (#1943) --- .opencode/agent/pr-review.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.opencode/agent/pr-review.md b/.opencode/agent/pr-review.md index 07913c1339..c7c1c9244f 100644 --- a/.opencode/agent/pr-review.md +++ b/.opencode/agent/pr-review.md @@ -136,6 +136,13 @@ Merge signal in plain English: safe to merge, safe after a small fix, or not saf Explain the reason in a short paragraph. If there are findings, name the files that need attention. +

Risk Score: X/5

+ +1 is low risk (isolated, reversible, well-contained change), 5 is high risk (touches security, data persistence, shared state, build/release, or broad cross-runtime contracts). + +Explain the score in a short paragraph: which risk dimensions apply (correctness, data loss, security/supply-chain, performance, cross-runtime parity) and what makes the change more or less risky. +
+

Findings

If there are findings, list them like this: From fee944d7fd2526d69cfa90170e742504fa3da3cd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 18:32:16 +0300 Subject: [PATCH 39/88] fix: recover mobile and sync state after resume Reconnect sync stream when native mobile app resumes Materialize incomplete sessions with explicit recovery reasons Add low-noise debug breadcrumb for scoped recovery --- packages/ui/src/apps/MobileApp.tsx | 8 + .../src/sync/__tests__/event-reducer.test.ts | 8 +- packages/ui/src/sync/debug.ts | 8 +- packages/ui/src/sync/event-reducer.ts | 21 ++- packages/ui/src/sync/sync-context.tsx | 142 +++++++++++------- .../server/lib/event-stream/DOCUMENTATION.md | 1 + 6 files changed, 123 insertions(+), 65 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 756e564cc1..aeeb2a49c4 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -282,6 +282,8 @@ const mobileInputKeyboardProps = { spellCheck: false, } as const; +const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -1946,9 +1948,15 @@ export function MobileApp({ apis }: MobileAppProps) { // exhausted the attempt (then the connect screen shows). const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); + const lastNativeResumeSyncEventAtRef = React.useRef(0); const handleNativeResume = React.useCallback(() => { if (!getRuntimeApiBaseUrl()) return; + const now = Date.now(); + if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { + lastNativeResumeSyncEventAtRef.current = now; + window.dispatchEvent(new Event('openchamber:system-resume')); + } void initializeApp(); void refreshGitHubAuthStatus(apis.github, { force: true }); if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); diff --git a/packages/ui/src/sync/__tests__/event-reducer.test.ts b/packages/ui/src/sync/__tests__/event-reducer.test.ts index 0c6245633b..bfeb843861 100644 --- a/packages/ui/src/sync/__tests__/event-reducer.test.ts +++ b/packages/ui/src/sync/__tests__/event-reducer.test.ts @@ -61,7 +61,7 @@ describe("applyDirectoryEvent", () => { expect(result).toEqual({ changed: false, - materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" }, + materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", messageID: "msg_1", partID: "prt_1" }, }) }) @@ -73,7 +73,7 @@ describe("applyDirectoryEvent", () => { expect(result).toEqual({ changed: false, - materialization: { type: "incomplete-session-snapshot", messageID: "msg_1", partID: "prt_1" }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", messageID: "msg_1", partID: "prt_1" }, }) }) @@ -86,6 +86,7 @@ describe("applyDirectoryEvent", () => { changed: true, materialization: { type: "incomplete-session-snapshot", + reason: "missing-owning-message", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1", @@ -102,6 +103,7 @@ describe("applyDirectoryEvent", () => { changed: true, materialization: { type: "incomplete-session-snapshot", + reason: "missing-owning-message", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1", @@ -123,7 +125,7 @@ describe("applyDirectoryEvent", () => { expect(result).toEqual({ changed: false, - materialization: { type: "incomplete-session-snapshot", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1" }, + materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: "ses_1", messageID: "msg_1", partID: "prt_1" }, }) }) diff --git a/packages/ui/src/sync/debug.ts b/packages/ui/src/sync/debug.ts index b792b4454e..d6b1aad15f 100644 --- a/packages/ui/src/sync/debug.ts +++ b/packages/ui/src/sync/debug.ts @@ -23,7 +23,7 @@ function isSyncDebugEnabled(): boolean { } return _enabled } -type SyncDebugCategory = "pipeline" | "reducer" | "dispatch" +type SyncDebugCategory = "pipeline" | "reducer" | "dispatch" | "recovery" function log(cat: SyncDebugCategory, ...args: unknown[]): void { if (!isSyncDebugEnabled()) return @@ -74,4 +74,10 @@ export const syncDebug = { eventApplied: (eventType: string, sessionID?: string, messageID?: string) => log("dispatch", "event → applied", { eventType, sessionID, messageID }), }, + + recovery: { + /** A scoped session snapshot fetch is starting because live state looked incomplete. */ + materializing: (details: { reason: string; directory: string; sessionID: string; messageID?: string; partID?: string }) => + log("recovery", "materializing session", details), + }, } as const diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index c925b3d066..b9120b03ff 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -151,10 +151,23 @@ export type GlobalEventResult = { project: Project } | null +export type SessionMaterializationReason = + | "missing-owning-message" + | "orphan-delta" + | "missing-delta-part" + | "empty-assistant-message" + | "child-session-idle" + | "child-session-discovered" + | "ensure-session-messages" + | "stream-reconnect" + | "transport-switch" + | "stale-status-resync" + export type DirectoryEventResult = boolean | { changed: boolean materialization: { type: "incomplete-session-snapshot" + reason: SessionMaterializationReason sessionID?: string messageID: string partID?: string @@ -356,7 +369,7 @@ export function applyDirectoryEvent( return missingOwningMessage ? { changed: true, - materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id }, } : true } @@ -390,7 +403,7 @@ export function applyDirectoryEvent( return missingOwningMessage ? { changed: true, - materialization: { type: "incomplete-session-snapshot", sessionID, messageID, partID: part.id }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-owning-message", sessionID, messageID, partID: part.id }, } : true } @@ -426,7 +439,7 @@ export function applyDirectoryEvent( syncDebug.reducer.partDeltaNoParts(props.messageID, props.partID) return { changed: false, - materialization: { type: "incomplete-session-snapshot", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, + materialization: { type: "incomplete-session-snapshot", reason: "orphan-delta", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, } } const result = Binary.search(parts, props.partID, (p) => p.id) @@ -434,7 +447,7 @@ export function applyDirectoryEvent( syncDebug.reducer.partDeltaNotFound(props.messageID, props.partID) return { changed: false, - materialization: { type: "incomplete-session-snapshot", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, + materialization: { type: "incomplete-session-snapshot", reason: "missing-delta-part", sessionID: props.sessionID, messageID: props.messageID, partID: props.partID }, } } const existing = parts[result.index] as Record diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 423ecf77b3..4eab76501c 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -9,7 +9,7 @@ import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" import { isCapacitorApp } from "@/lib/platform" -import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" +import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent, type SessionMaterializationReason } from "./event-reducer" import { useGlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" import { @@ -194,24 +194,36 @@ function haveEquivalentSyncSnapshots(left: unknown, right: unknown): boolean { // Tracked per-directory, deduplicated, and auto-expiring. // --------------------------------------------------------------------------- -type PendingSessionMaterialization = { - sessionID: string - directory: string - enqueuedAt: number -} +type PendingSessionMaterialization = { + sessionID: string + directory: string + enqueuedAt: number + request: SessionMaterializationRequest +} + +type SessionMaterializationRequest = { + reason: SessionMaterializationReason + messageID?: string + partID?: string +} const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000 const pendingSessionMaterializations = new Map() // key: directory:sessionID const materializationKey = (directory: string, sessionID: string) => `${directory}:${sessionID}` -function enqueueSessionMaterialization(directory: string, sessionID: string, childStores: ChildStoreManager) { - if (!directory || directory === "global" || !sessionID) return - const k = materializationKey(directory, sessionID) - const existing = pendingSessionMaterializations.get(k) - if (existing && Date.now() - existing.enqueuedAt < SESSION_MATERIALIZATION_COOLDOWN_MS) return - - pendingSessionMaterializations.set(k, { sessionID, directory, enqueuedAt: Date.now() }) +function enqueueSessionMaterialization( + directory: string, + sessionID: string, + childStores: ChildStoreManager, + request: SessionMaterializationRequest, +) { + if (!directory || directory === "global" || !sessionID) return + const k = materializationKey(directory, sessionID) + const existing = pendingSessionMaterializations.get(k) + if (existing && Date.now() - existing.enqueuedAt < SESSION_MATERIALIZATION_COOLDOWN_MS) return + + pendingSessionMaterializations.set(k, { sessionID, directory, enqueuedAt: Date.now(), request }) // Defer to next microtask so we don't hold up the current event batch void Promise.resolve().then(async () => { @@ -220,8 +232,8 @@ function enqueueSessionMaterialization(directory: string, sessionID: string, chi pendingSessionMaterializations.delete(k) return } - try { - await materializeSessionFromServer(directory, sessionID, store) + try { + await materializeSessionFromServer(directory, sessionID, store, request) } catch { // Transient failure — next SSE event or reconnect will catch up. } finally { @@ -230,13 +242,20 @@ function enqueueSessionMaterialization(directory: string, sessionID: string, chi }) } -async function materializeSessionFromServer( - directory: string, - sessionID: string, - store: StoreApi, - options?: { isStale?: () => boolean }, -) { - const scopedClient = opencodeClient.getScopedSdkClient(directory) +async function materializeSessionFromServer( + directory: string, + sessionID: string, + store: StoreApi, + options?: SessionMaterializationRequest & { isStale?: () => boolean }, +) { + syncDebug.recovery.materializing({ + reason: options?.reason ?? "ensure-session-messages", + directory, + sessionID, + messageID: options?.messageID, + partID: options?.partID, + }) + const scopedClient = opencodeClient.getScopedSdkClient(directory) const result = await retry(async () => { const response = await scopedClient.session.messages({ sessionID, limit: SESSION_MATERIALIZATION_MESSAGE_LIMIT }) assertSdkSuccess(response, "session.messages") @@ -1216,29 +1235,31 @@ export async function resyncBlockingRequestsForDirectory( } } -async function resyncDirectoryAfterReconnect( - directory: string, - store: StoreApi, - routingIndex: EventRoutingIndex, -) { +async function resyncDirectoryAfterReconnect( + directory: string, + store: StoreApi, + routingIndex: EventRoutingIndex, + reason: SessionMaterializationReason, +) { const current = store.getState() const candidateSessionIds = getActiveSessionCandidateIds(directory, current) if (candidateSessionIds.length === 0) return await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "authoritative") - const scopedClient = opencodeClient.getScopedSdkClient(directory) - await Promise.all(candidateSessionIds.map(async (sessionId) => { - const [sessionResponse, messageResponse] = await Promise.all([ - retry(async () => { - const response = await scopedClient.session.get({ sessionID: sessionId }) + const scopedClient = opencodeClient.getScopedSdkClient(directory) + await Promise.all(candidateSessionIds.map(async (sessionId) => { + syncDebug.recovery.materializing({ reason, directory, sessionID: sessionId }) + const [sessionResponse, messageResponse] = await Promise.all([ + retry(async () => { + const response = await scopedClient.session.get({ sessionID: sessionId }) assertSdkSuccess(response, "session.get") return response - }).catch(() => null), - retry(async () => { - const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }) - assertSdkSuccess(response, "session.messages") - return response + }).catch(() => null), + retry(async () => { + const response = await scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }) + assertSdkSuccess(response, "session.messages") + return response }).catch(() => null), ]) const session = sessionResponse?.data @@ -1497,9 +1518,9 @@ function handleEvent( const parentID = idleSession ? (idleSession as Session & { parentID?: string | null }).parentID : null - if (parentID) { - enqueueSessionMaterialization(resolvedDirectory, parentID, childStores) - } + if (parentID) { + enqueueSessionMaterialization(resolvedDirectory, parentID, childStores, { reason: "child-session-idle" }) + } } } @@ -1578,10 +1599,13 @@ function handleEvent( // never arrived. Recover the session so the UI doesn't render a blank bubble. if (sessionID && messageID && payload.type === "message.updated") { const after = store.getState() - const info = (payload.properties as { info: Message }).info - if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) { - enqueueSessionMaterialization(resolvedDirectory, sessionID, childStores) - } + const info = (payload.properties as { info: Message }).info + if (info.role === "assistant" && (!after.part[messageID] || after.part[messageID].length === 0)) { + enqueueSessionMaterialization(resolvedDirectory, sessionID, childStores, { + reason: "empty-assistant-message", + messageID, + }) + } } } else { const sessionID = getSessionIdFromPayload(payload) ?? undefined @@ -1600,7 +1624,11 @@ function handleEvent( routingIndex, ) if (materializationSessionID) { - enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores) + enqueueSessionMaterialization(resolvedDirectory, materializationSessionID, childStores, { + reason: materializationResult.reason, + messageID: materializationResult.messageID, + partID: materializationResult.partID, + }) } } @@ -1650,7 +1678,7 @@ export function SyncProvider(props: { [childStores, props.sdk, props.directory], ) - const triggerDirectoryResync = useCallback((directory: string) => { + const triggerDirectoryResync = useCallback((directory: string, reason: SessionMaterializationReason) => { const store = childStores.children.get(directory) if (!store) return const resyncing = resyncingDirectoriesRef.current @@ -1658,7 +1686,7 @@ export function SyncProvider(props: { lastFullResyncAtByDirectoryRef.current.set(directory, Date.now()) resyncing.add(directory) - void resyncDirectoryAfterReconnect(directory, store, routingIndex) + void resyncDirectoryAfterReconnect(directory, store, routingIndex, reason) .catch(() => { // Transient failure — the watchdog, next SSE event, or reconnect will catch up. }) @@ -1845,7 +1873,7 @@ export function SyncProvider(props: { return } for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir) + triggerDirectoryResync(dir, "stream-reconnect") } }, onDisconnect: (reason) => { @@ -1865,7 +1893,7 @@ export function SyncProvider(props: { connectionPhase: "connected", }) for (const dir of childStores.children.keys()) { - triggerDirectoryResync(dir) + triggerDirectoryResync(dir, "transport-switch") } }, }) @@ -1919,11 +1947,11 @@ export function SyncProvider(props: { ) return { session: sessions, limit: Math.max(sessions.length, 50) } }) - // Trigger parent session materialization so the task tool part - // state (metadata, sessionId, output) is refreshed. - for (const pid of parentIdsForMaterialization) { - enqueueSessionMaterialization(directory, pid, childStores) - } + // Trigger parent session materialization so the task tool part + // state (metadata, sessionId, output) is refreshed. + for (const pid of parentIdsForMaterialization) { + enqueueSessionMaterialization(directory, pid, childStores, { reason: "child-session-discovered" }) + } } catch { // Best-effort — next tick will retry. } @@ -1945,7 +1973,7 @@ export function SyncProvider(props: { needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId]) )) if (needsSnapshot) { - triggerDirectoryResync(directory) + triggerDirectoryResync(directory, "stale-status-resync") } } finally { polling.delete(directory) @@ -1977,7 +2005,7 @@ export function SyncProvider(props: { const lastFullResyncAt = lastFullResyncAtByDirectoryRef.current.get(directory) ?? 0 if (shouldTriggerStaleResync(lastStreamActivityAtRef.current, lastFullResyncAt, now)) { pipelineReconnectRef.current?.("active_stream_stale") - triggerDirectoryResync(directory) + triggerDirectoryResync(directory, "stale-status-resync") } // Discover child sessions created by other OpenCode instances @@ -2676,7 +2704,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string) void (async () => { try { - await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale }) + await materializeSessionFromServer(resolvedDirectory, sessionID, store, { reason: "ensure-session-messages", isStale }) } catch { // Transient failure — next navigation or reconnect will retry } finally { diff --git a/packages/web/server/lib/event-stream/DOCUMENTATION.md b/packages/web/server/lib/event-stream/DOCUMENTATION.md index f69938c680..2acc7263dd 100644 --- a/packages/web/server/lib/event-stream/DOCUMENTATION.md +++ b/packages/web/server/lib/event-stream/DOCUMENTATION.md @@ -42,6 +42,7 @@ This module contains the OpenChamber message-stream WebSocket protocol and runti - The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`. - Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped. - If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast. +- When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close. - Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream. - Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached. - Global UI broadcasts are fan-out capable across both SSE and WS clients. From da155e214010387bcc6e5525838f3ca607c1ee57 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 19:07:20 +0300 Subject: [PATCH 40/88] feat(desktop): add keep-awake setting --- packages/electron/main.mjs | 45 ++++++++++- .../openchamber/DesktopNetworkSettings.tsx | 79 +++++++++++++++++++ packages/ui/src/lib/desktop.ts | 42 +++++++++- .../ui/src/lib/i18n/messages/en.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 5 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 5 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 5 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 5 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 5 ++ packages/ui/src/lib/settings/search.ts | 8 ++ .../server/lib/opencode/settings-helpers.js | 3 + .../lib/opencode/settings-helpers.test.js | 11 +++ 16 files changed, 236 insertions(+), 2 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 91ec2f780d..826c4dcb3b 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, protocol, screen, session, shell, webContents } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -193,6 +193,32 @@ const state = { sshLogs: new Map(), trayController: null, lastFocusedWindowId: null, + keepAwakeBlockerId: null, +}; + +const setDesktopKeepAwakeActive = (enabled) => { + const currentId = state.keepAwakeBlockerId; + const isActive = Number.isInteger(currentId) && powerSaveBlocker.isStarted(currentId); + + if (enabled) { + if (!isActive) { + state.keepAwakeBlockerId = powerSaveBlocker.start('prevent-app-suspension'); + } + return Number.isInteger(state.keepAwakeBlockerId) && powerSaveBlocker.isStarted(state.keepAwakeBlockerId); + } + + if (isActive) { + powerSaveBlocker.stop(currentId); + } + state.keepAwakeBlockerId = null; + return false; +}; + +const readDesktopKeepAwakeStatus = () => { + const enabled = readSettingsRoot().desktopKeepAwakeEnabled === true; + const currentId = state.keepAwakeBlockerId; + const active = Number.isInteger(currentId) && powerSaveBlocker.isStarted(currentId); + return { supported: true, enabled, active }; }; const quitRisk = { @@ -228,6 +254,7 @@ const quitConfirmationMessage = () => { const shutdownBackgroundServices = () => { if (state.backgroundShutdownComplete) return; state.backgroundShutdownComplete = true; + setDesktopKeepAwakeActive(false); if (state.installingUpdate) return; killSidecar(); setImmediate(() => { @@ -271,6 +298,8 @@ const prepareForQuit = ({ installingUpdate = false } = {}) => { } } + setDesktopKeepAwakeActive(false); + if (installingUpdate) { state.backgroundShutdownComplete = true; return; @@ -1141,6 +1170,7 @@ const spawnLocalServer = async () => { // so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear // warning and persists the flag via /api/config/settings. const lanAccessEnabled = settings.desktopLanAccessEnabled === true; + setDesktopKeepAwakeActive(settings.desktopKeepAwakeEnabled === true); const desktopUiPassword = typeof settings.desktopUiPassword === 'string' ? settings.desktopUiPassword.trim() : ''; const lanAccessBlockedByMissingPassword = lanAccessEnabled && !desktopUiPassword; const effectiveLanAccessEnabled = lanAccessEnabled && !lanAccessBlockedByMissingPassword; @@ -3166,6 +3196,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return { supported: true, enabled: settings.openAtLogin === true }; } + case 'desktop_get_keep_awake': { + return readDesktopKeepAwakeStatus(); + } + + case 'desktop_set_keep_awake': { + const enabled = args.enabled === true; + await mutateSettingsRoot((root) => { + root.desktopKeepAwakeEnabled = enabled; + }); + const active = setDesktopKeepAwakeActive(enabled); + return { supported: true, enabled, active }; + } + case 'desktop_browser_capture_page': { const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null; if (wcId === null || wcId < 0) throw new Error('webContentsId is required'); diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx index a2861c3c9c..661d492f65 100644 --- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -5,10 +5,12 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { getDesktopLanAddress, + getDesktopKeepAwake, getDesktopLaunchAtLogin, isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, + setDesktopKeepAwake, setDesktopLaunchAtLogin, } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; @@ -29,6 +31,9 @@ export const DesktopNetworkSettings: React.FC = () => { const [launchAtLoginSupported, setLaunchAtLoginSupported] = React.useState(false); const [launchAtLoginEnabled, setLaunchAtLoginEnabled] = React.useState(false); const [isSavingLaunchAtLogin, setIsSavingLaunchAtLogin] = React.useState(false); + const [keepAwakeSupported, setKeepAwakeSupported] = React.useState(false); + const [keepAwakeEnabled, setKeepAwakeEnabled] = React.useState(false); + const [isSavingKeepAwake, setIsSavingKeepAwake] = React.useState(false); const [error, setError] = React.useState(null); const [lanAddress, setLanAddress] = React.useState(null); @@ -107,6 +112,27 @@ export const DesktopNetworkSettings: React.FC = () => { }; }, [isLocalDesktop]); + React.useEffect(() => { + if (!isLocalDesktop) { + setKeepAwakeSupported(false); + return; + } + + let cancelled = false; + void (async () => { + const status = await getDesktopKeepAwake(); + if (cancelled) { + return; + } + setKeepAwakeSupported(status?.supported === true); + setKeepAwakeEnabled(status?.enabled === true); + })(); + + return () => { + cancelled = true; + }; + }, [isLocalDesktop]); + React.useEffect(() => { if (!isLocalDesktop || !draftValue) { setLanAddress(null); @@ -183,6 +209,30 @@ export const DesktopNetworkSettings: React.FC = () => { } }, [isSavingLaunchAtLogin, launchAtLoginEnabled, launchAtLoginSupported, t]); + const handleKeepAwakeToggle = React.useCallback(async () => { + if (!keepAwakeSupported || isSavingKeepAwake) { + return; + } + + const nextValue = !keepAwakeEnabled; + setKeepAwakeEnabled(nextValue); + setIsSavingKeepAwake(true); + setError(null); + + try { + const status = await setDesktopKeepAwake(nextValue); + if (!status?.supported) { + throw new Error(t('settings.openchamber.desktopNetwork.error.keepAwakeUnsupported')); + } + setKeepAwakeEnabled(status.enabled); + } catch (cause) { + setKeepAwakeEnabled(!nextValue); + setError(cause instanceof Error ? cause.message : t('settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed')); + } finally { + setIsSavingKeepAwake(false); + } + }, [isSavingKeepAwake, keepAwakeEnabled, keepAwakeSupported, t]); + const handleSaveAndRestart = React.useCallback(async () => { if (!isDirty) { return; @@ -261,6 +311,35 @@ export const DesktopNetworkSettings: React.FC = () => {
) : null} + {keepAwakeSupported ? ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleKeepAwakeToggle(); + } + }} + > + +
+
{t('settings.openchamber.desktopNetwork.field.keepAwake')}
+
+ {t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')} +
+
+
+ ) : null} +
diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index d0006eb29f..579dcda168 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -107,7 +107,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
- +
@@ -125,7 +125,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
- +
diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index eb415ebc41..60ca2686af 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -10,6 +10,7 @@ export const iconSpriteData = { "alert": ``, "align-justify": ``, "apple": ``, + "apps-2-ai": ``, "archive": ``, "archive-stack": ``, "arrow-down": ``, @@ -157,7 +158,6 @@ export const iconSpriteData = { "macbook": ``, "menu-2": ``, "menu-fold-2": ``, - "menu": ``, "menu-search": ``, "mic": ``, "mic-off": ``, diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 11724eff2e..8c2a3d298d 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -248,6 +248,10 @@ :root.mobile-pointer:not(.desktop-runtime) .header-safe-area { padding-top: var(--oc-safe-area-top); } + + :root.oc-capacitor-app [data-sonner-toaster][data-y-position='top'] { + top: calc(env(safe-area-inset-top, 0px) + 16px) !important; + } } /* Phase 1: iOS PWA safe area handling - Enhanced positioning approach */ From 4b987c5b56a17fd6ee7d2fdfaaf7644606ae3917 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 23:44:10 +0300 Subject: [PATCH 44/88] fix: build VS Code extension correctly from oc-dev Run oc-dev with Node to avoid Bun setting NODE_ENV=development Keep local VSIX install flow aligned with the working shell script --- package.json | 2 +- scripts/oc-dev.mjs | 30 +++++++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 7013f47cef..8604a6cd02 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "license": "MIT", "scripts": { "dev": "node ./scripts/dev-web-hmr.mjs", - "oc-dev": "bun scripts/oc-dev.mjs", + "oc-dev": "node scripts/oc-dev.mjs", "build": "bun run --filter '*' build", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index b6c46dba21..94170fd244 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -44,7 +44,7 @@ const isMac = process.platform === 'darwin'; function printHelp() { console.log(`Usage: bun run oc-dev [action] [options] - bun scripts/oc-dev.mjs [action] [options] + node scripts/oc-dev.mjs [action] [options] Actions: build-deploy-web Build web package and deploy @@ -493,19 +493,27 @@ function startVsCodeExtension() { } async function installVsCodeExtensionLocal(options) { - const cleanup = await chooseValue(options.vsixCleanup, [ - { value: 'delete', label: 'Delete VSIX after install' }, - { value: 'keep', label: 'Keep VSIX after install' }, - ], 'Select VSIX cleanup mode'); + let cleanup = options.vsixCleanup; + if (!cleanup && isTty) { + cleanup = await chooseValue('', [ + { value: 'delete', label: 'Delete VSIX after install' }, + { value: 'keep', label: 'Keep VSIX after install' }, + ], 'Select VSIX cleanup mode'); + } + cleanup ||= 'delete'; + if (!['delete', 'keep'].includes(cleanup)) throw new Error('Invalid --vsix-cleanup. Use delete or keep.'); + const vscodeDir = path.join(repoRoot, 'packages/vscode'); step('Building VS Code extension', () => run('bun', ['run', '--cwd', 'packages/vscode', 'build'])); - removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Removing found VSIX package(s) before install flow', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix')); step('Packaging VSIX', () => run('bunx', ['vsce', 'package', '--no-dependencies'], { cwd: vscodeDir })); - run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true }); - const vsix = latestFileByExtensions(vscodeDir, ['.vsix']); - if (!vsix) throw new Error('VSIX package was not created.'); - step('Installing VSIX locally', () => run('code', ['--install-extension', vsix])); - if (cleanup === 'delete') removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix'); + step('Installing VSIX locally', () => { + run('code', ['--uninstall-extension', 'fedaykindev.openchamber'], { label: 'uninstall old extension', allowFail: true }); + run('code --install-extension packages/vscode/openchamber-*.vsix', [], { shell: true, label: 'install VSIX' }); + }); + if (cleanup === 'delete') { + step('Removing local VSIX package(s) after install', () => removeFilesByPrefixSuffix(vscodeDir, 'openchamber-', '.vsix')); + } } async function createRelease(options) { From cf36b55e6798ed5cecaae0647973ba2924743550 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 1 Jul 2026 23:44:36 +0300 Subject: [PATCH 45/88] fix: clear stale busy state after session recovery Reconcile session status after materializing recovered messages Return composer from stop to send when the server reports idle --- packages/ui/src/sync/sync-context.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 4eab76501c..3e6142108c 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -248,6 +248,7 @@ async function materializeSessionFromServer( store: StoreApi, options?: SessionMaterializationRequest & { isStale?: () => boolean }, ) { + const statusBeforeMaterialization = store.getState().session_status?.[sessionID] syncDebug.recovery.materializing({ reason: options?.reason ?? "ensure-session-messages", directory, @@ -282,11 +283,15 @@ async function materializeSessionFromServer( info: stripMessageDiffSnapshots(record.info), parts: record.parts ?? [], })), - { skipPartTypes: RECONNECT_SKIP_PARTS }, - ) - return { message: materialized.message, part: materialized.part } - }) -} + { skipPartTypes: RECONNECT_SKIP_PARTS }, + ) + return { message: materialized.message, part: materialized.part } + }) + + if (statusBeforeMaterialization && statusBeforeMaterialization.type !== "idle" && !options?.isStale?.()) { + await resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative") + } +} // Module-level refs for notification viewed check. // Used to determine if user is currently viewing the session when a notification arrives. From b099ff4edf4199c6d810060f493d9ac41a4ace16 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 00:45:59 +0300 Subject: [PATCH 46/88] chore: remove bundled IBM Plex fonts --- bun.lock | 17 ---------------- package.json | 3 --- packages/electron/main.mjs | 2 +- packages/ui/package.json | 3 --- .../components/chat/MarkdownRendererImpl.tsx | 2 +- packages/ui/src/index.css | 12 +++++------ packages/ui/src/lib/fontOptions.ts | 20 ++++--------------- .../ui/src/lib/theme/themes/amoled-dark.json | 6 +++--- .../ui/src/lib/theme/themes/amoled-light.json | 6 +++--- .../ui/src/lib/theme/themes/aura-dark.json | 6 +++--- .../ui/src/lib/theme/themes/aura-light.json | 6 +++--- .../ui/src/lib/theme/themes/ayu-dark.json | 6 +++--- .../ui/src/lib/theme/themes/ayu-light.json | 6 +++--- .../src/lib/theme/themes/carbonfox-dark.json | 6 +++--- .../src/lib/theme/themes/carbonfox-light.json | 6 +++--- .../src/lib/theme/themes/catppuccin-dark.json | 6 +++--- .../lib/theme/themes/catppuccin-light.json | 6 +++--- .../ui/src/lib/theme/themes/cursor-dark.json | 6 +++--- .../ui/src/lib/theme/themes/cursor-light.json | 6 +++--- .../ui/src/lib/theme/themes/dracula-dark.json | 6 +++--- .../src/lib/theme/themes/dracula-light.json | 6 +++--- .../themes/fields-of-the-shire-dark.json | 6 +++--- .../themes/fields-of-the-shire-light.json | 6 +++--- .../ui/src/lib/theme/themes/flexoki-dark.json | 6 +++--- .../src/lib/theme/themes/flexoki-light.json | 6 +++--- .../ui/src/lib/theme/themes/github-dark.json | 6 +++--- .../ui/src/lib/theme/themes/github-light.json | 6 +++--- .../ui/src/lib/theme/themes/gruvbox-dark.json | 6 +++--- .../src/lib/theme/themes/gruvbox-light.json | 6 +++--- .../src/lib/theme/themes/jetbrains-dark.json | 6 +++--- .../src/lib/theme/themes/jetbrains-light.json | 6 +++--- .../src/lib/theme/themes/kanagawa-dark.json | 6 +++--- .../src/lib/theme/themes/kanagawa-light.json | 6 +++--- .../lib/theme/themes/lucent-orng-dark.json | 6 +++--- .../lib/theme/themes/lucent-orng-light.json | 6 +++--- .../ui/src/lib/theme/themes/mono-dark.json | 6 +++--- .../ui/src/lib/theme/themes/mono-light.json | 6 +++--- .../src/lib/theme/themes/mono-plus-dark.json | 6 +++--- .../src/lib/theme/themes/mono-plus-light.json | 6 +++--- .../ui/src/lib/theme/themes/monokai-dark.json | 6 +++--- .../src/lib/theme/themes/monokai-light.json | 6 +++--- .../src/lib/theme/themes/nightowl-dark.json | 6 +++--- .../src/lib/theme/themes/nightowl-light.json | 6 +++--- .../ui/src/lib/theme/themes/nord-dark.json | 6 +++--- .../ui/src/lib/theme/themes/nord-light.json | 6 +++--- .../ui/src/lib/theme/themes/oc-2-dark.json | 6 +++--- .../ui/src/lib/theme/themes/oc-2-light.json | 6 +++--- .../src/lib/theme/themes/onedarkpro-dark.json | 6 +++--- .../lib/theme/themes/onedarkpro-light.json | 6 +++--- .../ui/src/lib/theme/themes/orng-dark.json | 6 +++--- .../ui/src/lib/theme/themes/orng-light.json | 6 +++--- .../src/lib/theme/themes/rosepine-dark.json | 6 +++--- .../src/lib/theme/themes/rosepine-light.json | 6 +++--- .../lib/theme/themes/shadesofpurple-dark.json | 6 +++--- .../theme/themes/shadesofpurple-light.json | 6 +++--- .../src/lib/theme/themes/solarized-dark.json | 6 +++--- .../src/lib/theme/themes/solarized-light.json | 6 +++--- .../src/lib/theme/themes/tokyonight-dark.json | 6 +++--- .../lib/theme/themes/tokyonight-light.json | 6 +++--- .../ui/src/lib/theme/themes/vercel-dark.json | 6 +++--- .../ui/src/lib/theme/themes/vercel-light.json | 6 +++--- .../ui/src/lib/theme/themes/vesper-dark.json | 6 +++--- .../ui/src/lib/theme/themes/vesper-light.json | 6 +++--- .../lib/theme/themes/vitesse-dark-dark.json | 6 +++--- .../lib/theme/themes/vitesse-light-light.json | 6 +++--- .../ui/src/lib/theme/themes/zenburn-dark.json | 6 +++--- .../src/lib/theme/themes/zenburn-light.json | 6 +++--- packages/ui/src/styles/design-system.css | 4 ++-- packages/ui/src/styles/fonts.ts | 10 +--------- packages/web/package.json | 3 --- scripts/changelog-card/generate.mjs | 12 ++++------- scripts/port-opencode-theme.ts | 6 +++--- 72 files changed, 202 insertions(+), 252 deletions(-) diff --git a/bun.lock b/bun.lock index 2a05722edc..f462636350 100644 --- a/bun.lock +++ b/bun.lock @@ -25,12 +25,9 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "6.39.13", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.17.12", @@ -169,9 +166,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", @@ -298,9 +292,6 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-go": "^6.0.1", "@eslint/js": "^9.33.0", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -773,10 +764,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], - - "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="], - "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], @@ -817,10 +804,6 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], - - "@ibm/telemetry-js": ["@ibm/telemetry-js@1.11.0", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-RO/9j+URJnSfseWg9ZkEX9p+a3Ousd33DBU7rOafoZB08RqdzxFVYJ2/iM50dkBuD0o7WX7GYt1sLbNgCoE+pA=="], - "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], diff --git a/package.json b/package.json index 8604a6cd02..bd58e5e735 100644 --- a/package.json +++ b/package.json @@ -102,12 +102,9 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.5.4", "@codemirror/view": "6.39.13", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", "@heroui/scroll-shadow": "^2.3.18", "@heroui/system": "^2.4.23", "@heroui/theme": "^2.4.23", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", "@opencode-ai/sdk": "^1.17.12", diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 826c4dcb3b..ca188e7ecb 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1434,7 +1434,7 @@ const buildStartupSplashHtml = () => { } body { margin: 0; - font-family: "IBM Plex Sans", sans-serif; + font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; display: grid; place-items: center; height: 100vh; diff --git a/packages/ui/package.json b/packages/ui/package.json index e44a52e33b..daef0b3326 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -42,9 +42,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "^1.17.12", "@pierre/diffs": "1.3.0-beta.6", diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 5b5252f223..db97312ce4 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -965,7 +965,7 @@ const mermaidColorsFromTheme = (theme: Theme) => ({ surface: theme.colors.surface.muted, border: theme.colors.interactive.border, transparent: true, - font: 'IBM Plex Sans, sans-serif', + font: 'system-ui, sans-serif', }); const useDecorateContext = ( diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 92da1e46bc..0cbae94dd5 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -708,11 +708,11 @@ html:not(.dark) .chat-scroll { /* Pierre diff viewer styling */ .pierre-diff-wrapper { - --diffs-font-family: var(--font-mono, 'IBM Plex Mono', monospace); + --diffs-font-family: var(--font-mono, ui-monospace, monospace); --diffs-font-size: var(--text-code); --diffs-line-height: 24px; --diffs-tab-size: 2; - --diffs-header-font-family: var(--font-sans, 'IBM Plex Sans', sans-serif); + --diffs-header-font-family: var(--font-sans, system-ui, sans-serif); --diffs-min-number-column-width: 4ch; --diffs-gap-inline: 0; --diffs-gap-block: 0; @@ -923,7 +923,7 @@ html:not(.dark) .chat-scroll { } } -/* Text font: IBM Plex Sans */ +/* Text font */ .markdown-content { font-family: var(--font-sans); font-size: var(--text-markdown); @@ -1057,7 +1057,7 @@ html:not(.dark) .chat-scroll { margin-bottom: 0.25em; } -/* Code font: IBM Plex Mono */ +/* Code font */ .markdown-content code, .markdown-content pre { font-family: var(--font-mono); @@ -1331,7 +1331,7 @@ html:not(.dark) .chat-scroll { white-space: pre; width: max-content; min-width: 100%; - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--surface-foreground); background: transparent; } @@ -1388,7 +1388,7 @@ html:not(.dark) .chat-scroll { width: max-content; min-width: 100%; min-height: 100%; - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: var(--surface-foreground); background: transparent; } diff --git a/packages/ui/src/lib/fontOptions.ts b/packages/ui/src/lib/fontOptions.ts index 42bb76516b..6387698a3a 100644 --- a/packages/ui/src/lib/fontOptions.ts +++ b/packages/ui/src/lib/fontOptions.ts @@ -1,6 +1,6 @@ -export type UiFontOption = 'ibm-plex-sans' | 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system'; +export type UiFontOption = 'inter' | 'geist-sans' | 'atkinson-hyperlegible' | 'source-sans-3' | 'roboto' | 'noto-sans' | 'dm-sans' | 'manrope' | 'system'; -export type MonoFontOption = 'ibm-plex-mono' | 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono'; +export type MonoFontOption = 'jetbrains-mono' | 'fira-code' | 'geist-mono' | 'commit-mono' | 'source-code-pro' | 'cascadia-code' | 'roboto-mono' | 'iosevka' | 'system-mono'; export interface FontFaceSource { family: string; @@ -19,12 +19,6 @@ export interface FontOptionDefinition { } export const UI_FONT_OPTIONS: FontOptionDefinition[] = [ - { - id: 'ibm-plex-sans', - label: 'IBM Plex Sans', - description: 'Humanist sans-serif for optimal readability in the interface.', - stack: '"IBM Plex Sans", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' - }, { id: 'inter', label: 'Inter', @@ -90,12 +84,6 @@ export const UI_FONT_OPTIONS: FontOptionDefinition[] = [ ]; export const CODE_FONT_OPTIONS: FontOptionDefinition[] = [ - { - id: 'ibm-plex-mono', - label: 'IBM Plex Mono', - description: 'Balanced monospace for code blocks and technical content.', - stack: '"IBM Plex Mono", "SFMono-Regular", "Menlo", monospace' - }, { id: 'jetbrains-mono', label: 'JetBrains Mono', @@ -166,8 +154,8 @@ const buildFontMap = (options: FontOptionDefinition[]) => export const UI_FONT_OPTION_MAP = buildFontMap(UI_FONT_OPTIONS); export const CODE_FONT_OPTION_MAP = buildFontMap(CODE_FONT_OPTIONS); -export const DEFAULT_UI_FONT: UiFontOption = 'ibm-plex-sans'; -export const DEFAULT_MONO_FONT: MonoFontOption = 'ibm-plex-mono'; +export const DEFAULT_UI_FONT: UiFontOption = 'system'; +export const DEFAULT_MONO_FONT: MonoFontOption = 'system-mono'; export const isUiFontOption = (value: unknown): value is UiFontOption => typeof value === 'string' && value in UI_FONT_OPTION_MAP; diff --git a/packages/ui/src/lib/theme/themes/amoled-dark.json b/packages/ui/src/lib/theme/themes/amoled-dark.json index 98822de5fb..43dc52ad1a 100644 --- a/packages/ui/src/lib/theme/themes/amoled-dark.json +++ b/packages/ui/src/lib/theme/themes/amoled-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/amoled-light.json b/packages/ui/src/lib/theme/themes/amoled-light.json index 91c338518f..57a8b164be 100644 --- a/packages/ui/src/lib/theme/themes/amoled-light.json +++ b/packages/ui/src/lib/theme/themes/amoled-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/aura-dark.json b/packages/ui/src/lib/theme/themes/aura-dark.json index aed60c01e2..2a311cfc4d 100644 --- a/packages/ui/src/lib/theme/themes/aura-dark.json +++ b/packages/ui/src/lib/theme/themes/aura-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/aura-light.json b/packages/ui/src/lib/theme/themes/aura-light.json index 7269ab91d6..4fec273eca 100644 --- a/packages/ui/src/lib/theme/themes/aura-light.json +++ b/packages/ui/src/lib/theme/themes/aura-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/ayu-dark.json b/packages/ui/src/lib/theme/themes/ayu-dark.json index 84241e510f..3b7c9e62a4 100644 --- a/packages/ui/src/lib/theme/themes/ayu-dark.json +++ b/packages/ui/src/lib/theme/themes/ayu-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/ayu-light.json b/packages/ui/src/lib/theme/themes/ayu-light.json index 33a448ae8e..fcdbf7eceb 100644 --- a/packages/ui/src/lib/theme/themes/ayu-light.json +++ b/packages/ui/src/lib/theme/themes/ayu-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/carbonfox-dark.json b/packages/ui/src/lib/theme/themes/carbonfox-dark.json index 6567344e0a..3eb08847b2 100644 --- a/packages/ui/src/lib/theme/themes/carbonfox-dark.json +++ b/packages/ui/src/lib/theme/themes/carbonfox-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/carbonfox-light.json b/packages/ui/src/lib/theme/themes/carbonfox-light.json index 4d90d8d716..78f5c2bb43 100644 --- a/packages/ui/src/lib/theme/themes/carbonfox-light.json +++ b/packages/ui/src/lib/theme/themes/carbonfox-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/catppuccin-dark.json b/packages/ui/src/lib/theme/themes/catppuccin-dark.json index 062304eed3..74699dec7d 100644 --- a/packages/ui/src/lib/theme/themes/catppuccin-dark.json +++ b/packages/ui/src/lib/theme/themes/catppuccin-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/catppuccin-light.json b/packages/ui/src/lib/theme/themes/catppuccin-light.json index 4a1b04ea75..d234758140 100644 --- a/packages/ui/src/lib/theme/themes/catppuccin-light.json +++ b/packages/ui/src/lib/theme/themes/catppuccin-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/cursor-dark.json b/packages/ui/src/lib/theme/themes/cursor-dark.json index c1f2a5418c..5243be7492 100644 --- a/packages/ui/src/lib/theme/themes/cursor-dark.json +++ b/packages/ui/src/lib/theme/themes/cursor-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/cursor-light.json b/packages/ui/src/lib/theme/themes/cursor-light.json index 87a8ebea0f..9729bc058b 100644 --- a/packages/ui/src/lib/theme/themes/cursor-light.json +++ b/packages/ui/src/lib/theme/themes/cursor-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/dracula-dark.json b/packages/ui/src/lib/theme/themes/dracula-dark.json index e318229c0e..1a936c2084 100644 --- a/packages/ui/src/lib/theme/themes/dracula-dark.json +++ b/packages/ui/src/lib/theme/themes/dracula-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/dracula-light.json b/packages/ui/src/lib/theme/themes/dracula-light.json index 33fd135788..b732244bcb 100644 --- a/packages/ui/src/lib/theme/themes/dracula-light.json +++ b/packages/ui/src/lib/theme/themes/dracula-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json b/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json index e22a01d030..e3d8bfb691 100644 --- a/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json +++ b/packages/ui/src/lib/theme/themes/fields-of-the-shire-dark.json @@ -168,9 +168,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json b/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json index c40c55b3c5..e3dbf3f809 100644 --- a/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json +++ b/packages/ui/src/lib/theme/themes/fields-of-the-shire-light.json @@ -168,9 +168,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/flexoki-dark.json b/packages/ui/src/lib/theme/themes/flexoki-dark.json index b0d6e43c33..65ccfe92d4 100644 --- a/packages/ui/src/lib/theme/themes/flexoki-dark.json +++ b/packages/ui/src/lib/theme/themes/flexoki-dark.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/flexoki-light.json b/packages/ui/src/lib/theme/themes/flexoki-light.json index b8b2018818..5880bf743c 100644 --- a/packages/ui/src/lib/theme/themes/flexoki-light.json +++ b/packages/ui/src/lib/theme/themes/flexoki-light.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/github-dark.json b/packages/ui/src/lib/theme/themes/github-dark.json index 20ccb2df89..084189759a 100644 --- a/packages/ui/src/lib/theme/themes/github-dark.json +++ b/packages/ui/src/lib/theme/themes/github-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/github-light.json b/packages/ui/src/lib/theme/themes/github-light.json index 1afb06601c..765704f361 100644 --- a/packages/ui/src/lib/theme/themes/github-light.json +++ b/packages/ui/src/lib/theme/themes/github-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/gruvbox-dark.json b/packages/ui/src/lib/theme/themes/gruvbox-dark.json index 83fea9592d..d4ab488306 100644 --- a/packages/ui/src/lib/theme/themes/gruvbox-dark.json +++ b/packages/ui/src/lib/theme/themes/gruvbox-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/gruvbox-light.json b/packages/ui/src/lib/theme/themes/gruvbox-light.json index d90042728f..ec019e9e34 100644 --- a/packages/ui/src/lib/theme/themes/gruvbox-light.json +++ b/packages/ui/src/lib/theme/themes/gruvbox-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/jetbrains-dark.json b/packages/ui/src/lib/theme/themes/jetbrains-dark.json index 6bca8d0b4e..6aa036adfc 100644 --- a/packages/ui/src/lib/theme/themes/jetbrains-dark.json +++ b/packages/ui/src/lib/theme/themes/jetbrains-dark.json @@ -169,9 +169,9 @@ }, "config": { "fonts": { - "sans": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "mono": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "heading": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace" + "sans": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/jetbrains-light.json b/packages/ui/src/lib/theme/themes/jetbrains-light.json index 3c7d3f9744..91e7cbd95c 100644 --- a/packages/ui/src/lib/theme/themes/jetbrains-light.json +++ b/packages/ui/src/lib/theme/themes/jetbrains-light.json @@ -169,9 +169,9 @@ }, "config": { "fonts": { - "sans": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "mono": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace", - "heading": "\"JetBrains Mono\", \"IBM Plex Mono\", monospace" + "sans": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "\"JetBrains Mono\", ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/kanagawa-dark.json b/packages/ui/src/lib/theme/themes/kanagawa-dark.json index 668ff4f7b1..020a8e9384 100644 --- a/packages/ui/src/lib/theme/themes/kanagawa-dark.json +++ b/packages/ui/src/lib/theme/themes/kanagawa-dark.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/kanagawa-light.json b/packages/ui/src/lib/theme/themes/kanagawa-light.json index d28e08a71a..6e29428888 100644 --- a/packages/ui/src/lib/theme/themes/kanagawa-light.json +++ b/packages/ui/src/lib/theme/themes/kanagawa-light.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/lucent-orng-dark.json b/packages/ui/src/lib/theme/themes/lucent-orng-dark.json index a9b287be3c..a33f2f1556 100644 --- a/packages/ui/src/lib/theme/themes/lucent-orng-dark.json +++ b/packages/ui/src/lib/theme/themes/lucent-orng-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/lucent-orng-light.json b/packages/ui/src/lib/theme/themes/lucent-orng-light.json index 098a10ad55..78b6b31a9d 100644 --- a/packages/ui/src/lib/theme/themes/lucent-orng-light.json +++ b/packages/ui/src/lib/theme/themes/lucent-orng-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/mono-dark.json b/packages/ui/src/lib/theme/themes/mono-dark.json index c0b9a8e3e7..72eb6946b4 100644 --- a/packages/ui/src/lib/theme/themes/mono-dark.json +++ b/packages/ui/src/lib/theme/themes/mono-dark.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/mono-light.json b/packages/ui/src/lib/theme/themes/mono-light.json index f8e028e7f5..e1d2f03d43 100644 --- a/packages/ui/src/lib/theme/themes/mono-light.json +++ b/packages/ui/src/lib/theme/themes/mono-light.json @@ -167,9 +167,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/mono-plus-dark.json b/packages/ui/src/lib/theme/themes/mono-plus-dark.json index f9220071ff..7aea459603 100644 --- a/packages/ui/src/lib/theme/themes/mono-plus-dark.json +++ b/packages/ui/src/lib/theme/themes/mono-plus-dark.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/mono-plus-light.json b/packages/ui/src/lib/theme/themes/mono-plus-light.json index 7b59e50f07..9506822370 100644 --- a/packages/ui/src/lib/theme/themes/mono-plus-light.json +++ b/packages/ui/src/lib/theme/themes/mono-plus-light.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/monokai-dark.json b/packages/ui/src/lib/theme/themes/monokai-dark.json index 620280f2f3..ee378f7439 100644 --- a/packages/ui/src/lib/theme/themes/monokai-dark.json +++ b/packages/ui/src/lib/theme/themes/monokai-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/monokai-light.json b/packages/ui/src/lib/theme/themes/monokai-light.json index bae4525981..b8542307f3 100644 --- a/packages/ui/src/lib/theme/themes/monokai-light.json +++ b/packages/ui/src/lib/theme/themes/monokai-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nightowl-dark.json b/packages/ui/src/lib/theme/themes/nightowl-dark.json index dc2c94c884..a18136bb79 100644 --- a/packages/ui/src/lib/theme/themes/nightowl-dark.json +++ b/packages/ui/src/lib/theme/themes/nightowl-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nightowl-light.json b/packages/ui/src/lib/theme/themes/nightowl-light.json index 13d5a40150..1929b47706 100644 --- a/packages/ui/src/lib/theme/themes/nightowl-light.json +++ b/packages/ui/src/lib/theme/themes/nightowl-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nord-dark.json b/packages/ui/src/lib/theme/themes/nord-dark.json index 453e50cdb1..47844869b0 100644 --- a/packages/ui/src/lib/theme/themes/nord-dark.json +++ b/packages/ui/src/lib/theme/themes/nord-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/nord-light.json b/packages/ui/src/lib/theme/themes/nord-light.json index ae4afc28fc..2e5895e1e2 100644 --- a/packages/ui/src/lib/theme/themes/nord-light.json +++ b/packages/ui/src/lib/theme/themes/nord-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/oc-2-dark.json b/packages/ui/src/lib/theme/themes/oc-2-dark.json index 8342a69cff..2d11712d47 100644 --- a/packages/ui/src/lib/theme/themes/oc-2-dark.json +++ b/packages/ui/src/lib/theme/themes/oc-2-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/oc-2-light.json b/packages/ui/src/lib/theme/themes/oc-2-light.json index 860341d058..b43fbf212d 100644 --- a/packages/ui/src/lib/theme/themes/oc-2-light.json +++ b/packages/ui/src/lib/theme/themes/oc-2-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/onedarkpro-dark.json b/packages/ui/src/lib/theme/themes/onedarkpro-dark.json index 6f8c26c45a..a101eaae1c 100644 --- a/packages/ui/src/lib/theme/themes/onedarkpro-dark.json +++ b/packages/ui/src/lib/theme/themes/onedarkpro-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/onedarkpro-light.json b/packages/ui/src/lib/theme/themes/onedarkpro-light.json index ed67d61c23..b3e09dee49 100644 --- a/packages/ui/src/lib/theme/themes/onedarkpro-light.json +++ b/packages/ui/src/lib/theme/themes/onedarkpro-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/orng-dark.json b/packages/ui/src/lib/theme/themes/orng-dark.json index f2fc50f075..2e4f64c9ed 100644 --- a/packages/ui/src/lib/theme/themes/orng-dark.json +++ b/packages/ui/src/lib/theme/themes/orng-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/orng-light.json b/packages/ui/src/lib/theme/themes/orng-light.json index 3b00b86d3b..f9cd8c53bf 100644 --- a/packages/ui/src/lib/theme/themes/orng-light.json +++ b/packages/ui/src/lib/theme/themes/orng-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/rosepine-dark.json b/packages/ui/src/lib/theme/themes/rosepine-dark.json index 4d4e852eeb..d88b3906e2 100644 --- a/packages/ui/src/lib/theme/themes/rosepine-dark.json +++ b/packages/ui/src/lib/theme/themes/rosepine-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/rosepine-light.json b/packages/ui/src/lib/theme/themes/rosepine-light.json index a9e35083ed..643d5b8642 100644 --- a/packages/ui/src/lib/theme/themes/rosepine-light.json +++ b/packages/ui/src/lib/theme/themes/rosepine-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json b/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json index f33bbbee89..405ffdd813 100644 --- a/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json +++ b/packages/ui/src/lib/theme/themes/shadesofpurple-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/shadesofpurple-light.json b/packages/ui/src/lib/theme/themes/shadesofpurple-light.json index 579f459ab7..266876abab 100644 --- a/packages/ui/src/lib/theme/themes/shadesofpurple-light.json +++ b/packages/ui/src/lib/theme/themes/shadesofpurple-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/solarized-dark.json b/packages/ui/src/lib/theme/themes/solarized-dark.json index 0bfd9240e6..7a5729934d 100644 --- a/packages/ui/src/lib/theme/themes/solarized-dark.json +++ b/packages/ui/src/lib/theme/themes/solarized-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/solarized-light.json b/packages/ui/src/lib/theme/themes/solarized-light.json index 7f12f1e624..bbf1e33b73 100644 --- a/packages/ui/src/lib/theme/themes/solarized-light.json +++ b/packages/ui/src/lib/theme/themes/solarized-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/tokyonight-dark.json b/packages/ui/src/lib/theme/themes/tokyonight-dark.json index 5dfab8d2ab..e639c7bdb8 100644 --- a/packages/ui/src/lib/theme/themes/tokyonight-dark.json +++ b/packages/ui/src/lib/theme/themes/tokyonight-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/tokyonight-light.json b/packages/ui/src/lib/theme/themes/tokyonight-light.json index d3efeaeaad..8c50a90ffb 100644 --- a/packages/ui/src/lib/theme/themes/tokyonight-light.json +++ b/packages/ui/src/lib/theme/themes/tokyonight-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vercel-dark.json b/packages/ui/src/lib/theme/themes/vercel-dark.json index 284920f39e..cf961f8749 100644 --- a/packages/ui/src/lib/theme/themes/vercel-dark.json +++ b/packages/ui/src/lib/theme/themes/vercel-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/vercel-light.json b/packages/ui/src/lib/theme/themes/vercel-light.json index 4bd61939a9..dc960e6ed5 100644 --- a/packages/ui/src/lib/theme/themes/vercel-light.json +++ b/packages/ui/src/lib/theme/themes/vercel-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/vesper-dark.json b/packages/ui/src/lib/theme/themes/vesper-dark.json index 36d8225915..b038339721 100644 --- a/packages/ui/src/lib/theme/themes/vesper-dark.json +++ b/packages/ui/src/lib/theme/themes/vesper-dark.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vesper-light.json b/packages/ui/src/lib/theme/themes/vesper-light.json index 62a7ac979d..b3f9882c68 100644 --- a/packages/ui/src/lib/theme/themes/vesper-light.json +++ b/packages/ui/src/lib/theme/themes/vesper-light.json @@ -165,9 +165,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json b/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json index 140ca10759..0c481b9fc1 100644 --- a/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json +++ b/packages/ui/src/lib/theme/themes/vitesse-dark-dark.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/vitesse-light-light.json b/packages/ui/src/lib/theme/themes/vitesse-light-light.json index 3340627200..2efa19cd44 100644 --- a/packages/ui/src/lib/theme/themes/vitesse-light-light.json +++ b/packages/ui/src/lib/theme/themes/vitesse-light-light.json @@ -145,9 +145,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "transitions": { "fast": "150ms ease", diff --git a/packages/ui/src/lib/theme/themes/zenburn-dark.json b/packages/ui/src/lib/theme/themes/zenburn-dark.json index 5d38357fad..76872d9be3 100644 --- a/packages/ui/src/lib/theme/themes/zenburn-dark.json +++ b/packages/ui/src/lib/theme/themes/zenburn-dark.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/lib/theme/themes/zenburn-light.json b/packages/ui/src/lib/theme/themes/zenburn-light.json index b7cda39281..b86cbd0a3c 100644 --- a/packages/ui/src/lib/theme/themes/zenburn-light.json +++ b/packages/ui/src/lib/theme/themes/zenburn-light.json @@ -407,9 +407,9 @@ }, "config": { "fonts": { - "sans": "\"IBM Plex Mono\", monospace", - "mono": "\"IBM Plex Mono\", monospace", - "heading": "\"IBM Plex Mono\", monospace" + "sans": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "mono": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace", + "heading": "ui-monospace, \"SFMono-Regular\", \"Menlo\", \"Cascadia Mono\", \"Segoe UI Mono\", monospace" }, "spacing": { "xs": "0.25rem", diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index 438eaa43db..63a63bfb5d 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -343,9 +343,9 @@ --markdown-heading4-size: var(--text-markdown); --markdown-heading5-size: var(--text-markdown); --markdown-heading6-size: var(--text-markdown); - --font-sans: "IBM Plex Mono", "JetBrains Mono", "Fira Code", "SFMono-Regular", "Menlo", monospace; + --font-sans: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --font-heading: var(--font-sans); - --font-mono: "IBM Plex Mono", "JetBrains Mono", "Fira Code", "SFMono-Regular", "Menlo", monospace; + --font-mono: ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace; --font-family-sans: var(--font-sans); --font-family-mono: var(--font-mono); } diff --git a/packages/ui/src/styles/fonts.ts b/packages/ui/src/styles/fonts.ts index 2334b048a0..12f3cab8f4 100644 --- a/packages/ui/src/styles/fonts.ts +++ b/packages/ui/src/styles/fonts.ts @@ -1,9 +1 @@ - - -import '@fontsource/ibm-plex-sans/latin-400.css'; -import '@fontsource/ibm-plex-sans/latin-500.css'; -import '@fontsource/ibm-plex-sans/latin-600.css'; - -import '@fontsource/ibm-plex-mono/latin-400.css'; -import '@fontsource/ibm-plex-mono/latin-500.css'; -import '@fontsource/ibm-plex-mono/latin-600.css'; +// Default fonts use system stacks. Optional user-selected fonts are loaded on demand. diff --git a/packages/web/package.json b/packages/web/package.json index 1fd0c48b9b..19911bf9c2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -51,9 +51,6 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-go": "^6.0.1", "@eslint/js": "^9.33.0", - "@fontsource/ibm-plex-mono": "^5.2.7", - "@fontsource/ibm-plex-sans": "^5.1.1", - "@ibm/plex": "^6.4.1", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/scripts/changelog-card/generate.mjs b/scripts/changelog-card/generate.mjs index 4d6029ac0e..28479a6d27 100644 --- a/scripts/changelog-card/generate.mjs +++ b/scripts/changelog-card/generate.mjs @@ -18,8 +18,8 @@ // The sentence wraps automatically; the version + sentence block is // bottom-anchored over a readability scrim so the glowing plate stays visible. // -// Fonts (IBM Plex Sans, Instrument Serif) are fetched once from Fontsource -// into ./.fonts (gitignored) and wired into fontconfig for Pango. +// Accent fonts are fetched once from Fontsource into ./.fonts (gitignored) +// and wired into fontconfig for Pango. Main text uses the system sans stack. import { mkdir, writeFile, readFile, access } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; @@ -30,10 +30,6 @@ const repoRoot = path.resolve(toolDir, '..', '..'); const fontsDir = path.join(toolDir, '.fonts'); const FONTS = [ - { - file: 'IBMPlexSans-SemiBold.ttf', - url: 'https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-sans@latest/latin-600-normal.ttf', - }, { file: 'InstrumentSerif-Italic.ttf', url: 'https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.ttf', @@ -142,7 +138,7 @@ async function main() { const sentenceBuf = await sharp({ text: { text: toPangoMarkup(sentence), - font: 'IBM Plex Sans 92', + font: 'system-ui 92', rgba: true, width: wrapWidth, wrap: 'word', @@ -160,7 +156,7 @@ async function main() { text: `${escapeMarkup( title )}`, - font: 'IBM Plex Sans 64', + font: 'system-ui 64', rgba: true, align: 'left', }, diff --git a/scripts/port-opencode-theme.ts b/scripts/port-opencode-theme.ts index 78b6ccc15b..ff25db95e5 100644 --- a/scripts/port-opencode-theme.ts +++ b/scripts/port-opencode-theme.ts @@ -120,9 +120,9 @@ const DEFAULT_OUT_DIR = path.join(REPO_ROOT, 'packages', 'ui', 'src', 'lib', 'th const DEFAULT_CONFIG = { fonts: { - sans: '"IBM Plex Mono", monospace', - mono: '"IBM Plex Mono", monospace', - heading: '"IBM Plex Mono", monospace', + sans: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + mono: 'ui-monospace, "SFMono-Regular", "Menlo", "Cascadia Mono", "Segoe UI Mono", monospace', + heading: '"SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', }, radius: { none: '0', From 453fe778b55cfff0673dcd02a0a7373d6d697b04 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 00:48:09 +0300 Subject: [PATCH 47/88] fix: reject OpenCode desktop app as CLI --- packages/vscode/src/opencode.ts | 45 +++++++++----- .../web/server/lib/opencode/env-runtime.js | 29 +++++++-- .../server/lib/opencode/env-runtime.test.js | 59 +++++++++++++++++++ 3 files changed, 113 insertions(+), 20 deletions(-) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 540a9abab4..c7e5cd856a 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -196,10 +196,30 @@ function isMacOpenCodeAppBundlePath(candidate: string): boolean { return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate); } +function isWindowsOpenCodeDesktopAppPath(candidate: string): boolean { + if (process.platform !== 'win32' || typeof candidate !== 'string') { + return false; + } + const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() + ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() + : ''; + if (!localAppData) { + return false; + } + const normalized = path.resolve(candidate).toLowerCase(); + return normalized.startsWith(`${localAppData}${path.sep}`) + && normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); +} + +function isKnownOpenCodeDesktopAppPath(candidate: string): boolean { + return isMacOpenCodeAppBundlePath(candidate) || isWindowsOpenCodeDesktopAppPath(candidate); +} + function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error { const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; - if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) { - return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`); + if (isKnownOpenCodeDesktopAppPath(raw) || isKnownOpenCodeDesktopAppPath(normalized)) { + const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; + return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${normalized}. ${messageSuffix}`); } try { @@ -254,7 +274,7 @@ function validateConfiguredOpencodeBinaryForManagedStart(): string | null { return null; } - if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) { + if (isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { return normalized; } @@ -271,7 +291,7 @@ function resolveOpencodeCliPath(): string | null { } })(); - if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) { + if (configured && isExecutable(configured) && !isKnownOpenCodeDesktopAppPath(configured)) { return configured; } @@ -288,7 +308,7 @@ function resolveOpencodeCliPath(): string | null { } })(); - if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) { + if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isKnownOpenCodeDesktopAppPath(sharedFromOpenChamber)) { return sharedFromOpenChamber; } @@ -302,13 +322,13 @@ function resolveOpencodeCliPath(): string | null { .filter(Boolean); for (const candidate of explicit) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) { return candidate; } } if (cachedDetectedOpencodeCliPath) { - if (isExecutable(cachedDetectedOpencodeCliPath)) { + if (isExecutable(cachedDetectedOpencodeCliPath) && !isKnownOpenCodeDesktopAppPath(cachedDetectedOpencodeCliPath)) { return cachedDetectedOpencodeCliPath; } cachedDetectedOpencodeCliPath = undefined; @@ -327,7 +347,6 @@ function resolveOpencodeCliPath(): string | null { const winFallbacks = (() => { const userProfile = process.env.USERPROFILE || home; const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming'); - const localAppData = process.env.LOCALAPPDATA || ''; const programData = process.env.ProgramData || 'C:\\ProgramData'; const npmDir = path.join(appData, 'npm'); @@ -344,14 +363,12 @@ function resolveOpencodeCliPath(): string | null { // Bun global install path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - // Some installers use LocalAppData - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', ].filter(Boolean); })(); if (process.platform !== 'win32') { const fromPath = findExecutableInPath('opencode'); - if (fromPath) { + if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) { cachedDetectedOpencodeCliPath = fromPath; return fromPath; } @@ -359,7 +376,7 @@ function resolveOpencodeCliPath(): string | null { const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks; for (const candidate of fallbacks) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isKnownOpenCodeDesktopAppPath(candidate)) { cachedDetectedOpencodeCliPath = candidate; return candidate; } @@ -367,7 +384,7 @@ function resolveOpencodeCliPath(): string | null { if (process.platform === 'win32') { const fromPath = findExecutableInPath('opencode'); - if (fromPath) { + if (fromPath && !isKnownOpenCodeDesktopAppPath(fromPath)) { cachedDetectedOpencodeCliPath = fromPath; return fromPath; } @@ -382,7 +399,7 @@ function resolveOpencodeCliPath(): string | null { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); + const found = lines.find((line) => isExecutable(line) && !isKnownOpenCodeDesktopAppPath(line)); if (found) { cachedDetectedOpencodeCliPath = found; return found; diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 998128d315..bee62da192 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -260,6 +260,20 @@ export const createOpenCodeEnvRuntime = (deps) => { return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed); }; + const isWindowsOpenCodeDesktopAppPath = (candidate) => { + if (process.platform !== 'win32' || typeof candidate !== 'string') { + return false; + } + const normalized = path.resolve(candidate).toLowerCase(); + const localAppData = typeof process.env.LOCALAPPDATA === 'string' && process.env.LOCALAPPDATA.trim() + ? path.resolve(process.env.LOCALAPPDATA).toLowerCase() + : ''; + if (!localAppData || !normalized.startsWith(`${localAppData}${path.sep}`)) { + return false; + } + return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); + }; + const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; @@ -278,7 +292,7 @@ export const createOpenCodeEnvRuntime = (deps) => { .filter(Boolean); for (const candidate of explicit) { - if (isExecutable(candidate)) { + if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'env'; return candidate; @@ -319,7 +333,6 @@ export const createOpenCodeEnvRuntime = (deps) => { path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'), path.join(userProfile, '.bun', 'bin', 'opencode.exe'), path.join(userProfile, '.bun', 'bin', 'opencode.cmd'), - localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '', ].filter(Boolean); })(); @@ -344,7 +357,7 @@ export const createOpenCodeEnvRuntime = (deps) => { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); - const found = lines.find((line) => isExecutable(line)); + const found = lines.find((line) => isExecutable(line) && !isWindowsOpenCodeDesktopAppPath(line)); if (found) { clearWslOpencodeResolution(); state.resolvedOpencodeBinarySource = 'where'; @@ -824,13 +837,17 @@ export const createOpenCodeEnvRuntime = (deps) => { return /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate); }; + const isKnownOpenCodeDesktopAppPath = (candidate) => isMacOpenCodeAppBundlePath(candidate) + || isWindowsOpenCodeDesktopAppPath(candidate); + const createConfiguredOpencodeBinaryError = (raw, normalized) => { const configured = typeof raw === 'string' ? raw.trim() : ''; const candidate = typeof normalized === 'string' && normalized.trim().length > 0 ? normalized.trim() : configured; const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set settings.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.'; const error = (() => { - if (isMacOpenCodeAppBundlePath(candidate) || isMacOpenCodeAppBundlePath(configured)) { - return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${candidate}. ${messageSuffix}`); + if (isKnownOpenCodeDesktopAppPath(candidate) || isKnownOpenCodeDesktopAppPath(configured)) { + const platformName = process.platform === 'win32' ? 'Windows desktop app install' : 'macOS desktop app bundle'; + return new Error(`Configured OpenCode binary points at the ${platformName}, not the CLI: ${candidate}. ${messageSuffix}`); } try { @@ -927,7 +944,7 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - if (normalized && isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) { + if (normalized && isExecutable(normalized) && !isKnownOpenCodeDesktopAppPath(normalized)) { clearWslOpencodeResolution(); process.env.OPENCODE_BINARY = normalized; prependToPath(path.dirname(normalized)); diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index c0ad1e7fab..0e432a1109 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -7,6 +7,7 @@ import { createOpenCodeEnvRuntime } from './env-runtime.js'; const originalOpencodeBinary = process.env.OPENCODE_BINARY; const originalComSpec = process.env.ComSpec; const originalPath = process.env.PATH; +const originalLocalAppData = process.env.LOCALAPPDATA; const originalSystemRoot = process.env.SystemRoot; const originalWslBinary = process.env.WSL_BINARY; const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY; @@ -59,6 +60,12 @@ afterEach(() => { delete process.env.SystemRoot; } + if (typeof originalLocalAppData === 'string') { + process.env.LOCALAPPDATA = originalLocalAppData; + } else { + delete process.env.LOCALAPPDATA; + } + if (typeof originalWslBinary === 'string') { process.env.WSL_BINARY = originalWslBinary; } else { @@ -138,6 +145,58 @@ describe('OpenCode env runtime', () => { }); }); + it('rejects known Windows OpenCode desktop app install paths', async () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + process.env.LOCALAPPDATA = localAppData; + const { runtime } = createRuntime({ opencodeBinary: desktopBinary }); + + await expect(runtime.applyOpencodeBinaryFromSettings({ strict: true })).rejects.toMatchObject({ + code: 'OPENCODE_BINARY_INVALID', + message: expect.stringContaining('Windows desktop app install'), + }); + }); + + it('does not auto-detect the Windows OpenCode desktop app as a CLI', () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-'); + delete process.env.OPENCODE_BINARY; + const { runtime } = createRuntime({}, { + spawnSync: () => ({ status: 1, stdout: '', stderr: '' }), + }); + + expect(runtime.resolveOpencodeCliPath()).toBeNull(); + }); + + it('skips Windows OpenCode desktop app entries returned by where.exe', () => { + setPlatform('win32'); + const localAppData = createTempDir('openchamber-localappdata-'); + const desktopBinary = path.join(localAppData, 'Programs', 'OpenCode', 'OpenCode.exe'); + const cliBinary = path.join(createTempDir('openchamber-cli-'), 'opencode.exe'); + fs.mkdirSync(path.dirname(desktopBinary), { recursive: true }); + fs.writeFileSync(desktopBinary, ''); + fs.writeFileSync(cliBinary, ''); + process.env.LOCALAPPDATA = localAppData; + process.env.PATH = createTempDir('openchamber-empty-path-'); + process.env.SystemRoot = createTempDir('openchamber-empty-systemroot-'); + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}, { + spawnSync: () => ({ status: 0, stdout: `${desktopBinary}\r\n${cliBinary}\r\n`, stderr: '' }), + }); + + expect(runtime.resolveOpencodeCliPath()).toBe(cliBinary); + expect(state.resolvedOpencodeBinarySource).toBe('where'); + }); + it('rejects WSL settings in strict mode', async () => { setPlatform('win32'); const dir = createTempDir('openchamber-no-wsl-'); From d7759bd1e5627cc12d3f80949f7d9910a67dbd84 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 17:43:33 +0300 Subject: [PATCH 48/88] feat(desktop): bundle pinned OpenCode CLI Bundle the official OpenCode CLI into Electron desktop builds instead of relying on whichever opencode executable happens to be first on PATH. Pin @opencode-ai/sdk to an exact version and use that version as the source of truth for the downloaded CLI artifact. Add an Electron prepare script that maps the current platform/arch to the official OpenCode release artifact, downloads it from GitHub releases, caches the archive under packages/electron/.cache, stages the binary under resources/opencode-cli, verifies opencode --version, and skips work when the staged binary already matches. Prefer explicit OpenCode binary overrides first, then the bundled Electron CLI, then PATH/system installs. Keep rejecting the Windows OpenCode desktop app executable as a CLI candidate and add resolver tests for bundled priority, explicit override priority, resourcesPath lookup, and desktop-app rejection. Suppress OpenCode CLI update prompts when the active CLI source is bundled. The server now reports upgrade-status as unavailable for bundled CLI while still returning the current OpenCode version for About, and rejects direct upgrade attempts with a 409 instead of trying to mutate the bundled binary. Update desktop release, smoke, and manual macOS DMG workflows to prepare and verify the bundled CLI before packaging, verify the packaged app contains the expected CLI, cache downloads by OS/arch/OpenCode version, and align the Windows smoke runner with production windows-2022. Document desktop bundling behavior, ignore generated CLI/cache files, add oc-dev helpers, and keep Web/VS Code behavior dependent on installed OpenCode CLI rather than desktop bundled resources. --- .github/workflows/build-macos-arm64-dmg.yml | 17 ++ .github/workflows/release-desktop-smoke.yml | 48 ++++- .github/workflows/release.yml | 42 +++- README.md | 2 +- bun.lock | 8 +- package.json | 2 +- packages/electron/.gitignore | 3 + packages/electron/README.md | 21 +- packages/electron/package.json | 9 +- .../electron/resources/opencode-cli/.gitkeep | 0 .../electron/scripts/prepare-opencode-cli.mjs | 181 ++++++++++++++++++ .../electron/scripts/verify-opencode-cli.mjs | 107 +++++++++++ packages/ui/package.json | 2 +- packages/ui/src/sync/sync-context.tsx | 71 +++++-- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- .../web/server/lib/opencode/env-runtime.js | 34 ++++ .../server/lib/opencode/env-runtime.test.js | 74 +++++++ packages/web/server/lib/opencode/routes.js | 36 ++++ scripts/oc-dev.mjs | 13 ++ 20 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 packages/electron/resources/opencode-cli/.gitkeep create mode 100644 packages/electron/scripts/prepare-opencode-cli.mjs create mode 100644 packages/electron/scripts/verify-opencode-cli.mjs diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index c819b9c9b7..194795388c 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -37,6 +37,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-arm64-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-arm64- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -68,9 +82,12 @@ jobs: ELECTRON_BUILDER_ARCH: arm64 run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main bun run rebuild:native ./node_modules/.bin/electron-builder --mac --arm64 --publish=never + bun run verify:opencode-cli:packaged - name: Prepare DMG artifact run: | diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 022292a654..7686eb7cce 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -70,6 +70,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -101,12 +115,15 @@ jobs: ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own. Rebuild against the target # Electron ABI before packaging, matching the release workflow. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Verify signature + entitlements + notarization run: | @@ -165,7 +182,10 @@ jobs: build-windows-electron: if: ${{ inputs.build_windows }} name: Build Windows Electron (x64) - runs-on: windows-latest + # Match the production release workflow. windows-latest currently resolves + # to a runner with Visual Studio 18, which this Electron/node-gyp stack does + # not detect correctly. + runs-on: windows-2022 strategy: fail-fast: false matrix: @@ -191,10 +211,32 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + shell: bash + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Build web assets working-directory: packages/electron run: bun run build:web-assets + - name: Prepare bundled OpenCode CLI + working-directory: packages/electron + shell: bash + run: | + bun run prepare:opencode-cli + bun run verify:opencode-cli + - name: Bundle main process working-directory: packages/electron run: bun run bundle:main @@ -210,7 +252,9 @@ jobs: - name: Build Windows app working-directory: packages/electron shell: bash - run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + run: | + node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Upload Windows installable artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 717be36780..ef66d35527 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,6 +146,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Install Apple Certificate env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} @@ -179,6 +193,8 @@ jobs: ELECTRON_BUILDER_ARCH: ${{ matrix.arch }} run: | bun run build:web-assets + bun run prepare:opencode-cli + bun run verify:opencode-cli bun run bundle:main # npmRebuild=false in package.json, so electron-builder won't # recompile native deps on its own — we must rebuild against the @@ -186,6 +202,7 @@ jobs: # node-pty/bun-pty crash on require inside the packaged app. bun run rebuild:native bunx electron-builder --mac --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Verify signature + entitlements + notarization run: | @@ -275,10 +292,31 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Get bundled OpenCode CLI version + id: opencode_cli_version + run: | + VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Cache bundled OpenCode CLI artifact + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: packages/electron/.cache/opencode-cli + key: opencode-cli-${{ runner.os }}-${{ matrix.arch }}-${{ steps.opencode_cli_version.outputs.version }} + restore-keys: | + opencode-cli-${{ runner.os }}-${{ matrix.arch }}- + - name: Build web assets working-directory: packages/electron run: bun run build:web-assets + - name: Prepare bundled OpenCode CLI + working-directory: packages/electron + shell: bash + run: | + bun run prepare:opencode-cli + bun run verify:opencode-cli + - name: Bundle main process working-directory: packages/electron run: bun run bundle:main @@ -294,7 +332,9 @@ jobs: - name: Build Windows app working-directory: packages/electron shell: bash - run: node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + run: | + node ./scripts/package.mjs --win --${{ matrix.arch }} --publish=never + bun run verify:opencode-cli:packaged - name: Upload installer to release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 diff --git a/README.md b/README.md index 08ada71238..f01b25bea1 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ ## Quick Start -> **Prerequisite:** [OpenCode CLI](https://opencode.ai) installed. +> **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). ### **Desktop (macOS + Windows)** Download from [Releases](https://github.com/btriapitsyn/openchamber/releases). diff --git a/bun.lock b/bun.lock index f462636350..e49df4360b 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -167,7 +167,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -239,7 +239,7 @@ "version": "1.13.8", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -266,7 +266,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/package.json b/package.json index bd58e5e735..91851ef8a3 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/electron/.gitignore b/packages/electron/.gitignore index 2f8dd8d380..9827bb507f 100644 --- a/packages/electron/.gitignore +++ b/packages/electron/.gitignore @@ -8,6 +8,9 @@ dist-bundle/ # Generated packaging resources resources/web-dist/ resources/sidecar/ +resources/opencode-cli/* +!resources/opencode-cli/.gitkeep +.cache/ # OS-specific .DS_Store diff --git a/packages/electron/README.md b/packages/electron/README.md index d722c87805..5939a916e7 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -21,6 +21,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | | `scripts/build-web-assets.mjs` | Builds `packages/web` and stages UI assets into `resources/web-dist` | +| `scripts/prepare-opencode-cli.mjs` | Downloads and stages the pinned OpenCode CLI into `resources/opencode-cli` | | `scripts/bundle-main.mjs` | Bundles Electron main code into `dist-bundle/main.mjs` for packaging | | `scripts/rebuild-native.mjs` | Rebuilds native modules against the Electron runtime | | `scripts/package.mjs` | Runs `electron-builder`, with unsigned Windows builds when signing env is missing | @@ -58,9 +59,10 @@ bun run electron:build That runs, in order: 1. `build:web-assets` to build the web UI and copy it into `packages/electron/resources/web-dist`. -2. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. -3. `rebuild:native` to rebuild native modules for Electron. -4. `package.mjs` to run `electron-builder`. +2. `prepare:opencode-cli` to download/cache the pinned OpenCode CLI and copy it into `packages/electron/resources/opencode-cli`. +3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`. +4. `rebuild:native` to rebuild native modules for Electron. +5. `package.mjs` to run `electron-builder`. Build output goes to `packages/electron/dist`. @@ -74,6 +76,18 @@ Windows packaging needs NSIS support through `electron-builder`. If no Windows s The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows. +## Bundled OpenCode CLI + +Packaged Desktop builds include the official OpenCode CLI that matches the pinned `@opencode-ai/sdk` version in the root `package.json`. `prepare:opencode-cli` downloads the platform-specific release artifact, caches it under `packages/electron/.cache/opencode-cli`, stages `opencode` or `opencode.exe` into `resources/opencode-cli`, and verifies `opencode --version` before packaging. Re-running the step is fast when the staged binary already matches the pinned version. + +Managed local Desktop startup prefers OpenCode binaries in this order: + +1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`. +2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`. +3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations. + +Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable. + ## Common Env Vars | Variable | Use | @@ -83,6 +97,7 @@ The package supports macOS and Windows desktop features. Some native discovery h | `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` | | `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` | | `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server | +| `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version | | `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server | | `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead | | `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally | diff --git a/packages/electron/package.json b/packages/electron/package.json index b7208b2871..a1d9514e94 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -27,10 +27,13 @@ "dev": "node ./scripts/electron-dev.mjs", "build:web-assets": "node ./scripts/build-web-assets.mjs", "build": "bun -e \"process.exit(0)\"", + "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", + "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", + "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", "bundle:main": "bun ./scripts/bundle-main.mjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "rebuild:native": "node ./scripts/rebuild-native.mjs", - "package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", + "package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs", "finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs", "type-check": "node --check ./main.mjs && node --check ./preload.mjs", "lint": "node -e \"process.exit(0)\"" @@ -54,6 +57,10 @@ { "from": "resources/icons/tray", "to": "icons/tray" + }, + { + "from": "resources/opencode-cli", + "to": "opencode-cli" } ], "afterPack": "scripts/after-pack.cjs", diff --git a/packages/electron/resources/opencode-cli/.gitkeep b/packages/electron/resources/opencode-cli/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/electron/scripts/prepare-opencode-cli.mjs b/packages/electron/scripts/prepare-opencode-cli.mjs new file mode 100644 index 0000000000..d7f5ede5c8 --- /dev/null +++ b/packages/electron/scripts/prepare-opencode-cli.mjs @@ -0,0 +1,181 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); +const outputDir = path.join(electronRoot, 'resources', 'opencode-cli'); +const cacheRoot = path.join(electronRoot, '.cache', 'opencode-cli'); +const rootPackagePath = path.join(workspaceRoot, 'package.json'); + +const run = (command, args, options = {}) => { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: options.stdio || 'pipe', + windowsHide: true, + ...options, + }); + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr.trim()}` : ''; + const stdout = result.stdout ? `\n${result.stdout.trim()}` : ''; + throw new Error(`Command failed: ${command} ${args.join(' ')}${stderr}${stdout}`); + } + return result; +}; + +const readPinnedSdkVersion = () => { + const pkg = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8')); + const version = pkg.dependencies?.['@opencode-ai/sdk']; + if (typeof version !== 'string' || !version.trim()) { + throw new Error('Missing @opencode-ai/sdk dependency in root package.json'); + } + const trimmed = version.trim(); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed)) { + throw new Error(`@opencode-ai/sdk must be pinned to an exact version for desktop CLI bundling, got: ${trimmed}`); + } + return trimmed; +}; + +const artifactForCurrentPlatform = () => { + const { platform, arch } = process; + if (platform === 'darwin') { + if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' }; + if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' }; + } + if (platform === 'win32') { + if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' }; + if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' }; + } + if (platform === 'linux') { + if (arch === 'arm64') return { name: 'opencode-linux-arm64.tar.gz', binary: 'opencode' }; + if (arch === 'x64') return { name: 'opencode-linux-x64-baseline.tar.gz', binary: 'opencode' }; + } + throw new Error(`No OpenCode CLI artifact mapping for ${platform}/${arch}`); +}; + +const outputBinaryPath = (binaryName) => path.join(outputDir, binaryName); + +const readBinaryVersion = (binaryPath) => { + if (!fs.existsSync(binaryPath)) return null; + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + windowsHide: true, + }); + if (result.status !== 0) return null; + return (result.stdout || '').trim().split(/\s+/)[0] || null; +}; + +const ensureExecutable = (filePath) => { + if (process.platform !== 'win32') { + fs.chmodSync(filePath, 0o755); + } +}; + +const download = async (url, destination) => { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`); + } + const temp = `${destination}.tmp`; + fs.writeFileSync(temp, Buffer.from(await response.arrayBuffer())); + fs.renameSync(temp, destination); +}; + +const extractArchive = (archivePath, destination) => { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(destination, { recursive: true }); + if (archivePath.endsWith('.zip')) { + if (process.platform === 'win32') { + run('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`, + ]); + return; + } + run('unzip', ['-q', archivePath, '-d', destination]); + return; + } + if (archivePath.endsWith('.tar.gz')) { + run('tar', ['-xzf', archivePath, '-C', destination]); + return; + } + throw new Error(`Unsupported OpenCode CLI archive: ${archivePath}`); +}; + +const findBinary = (root, binaryName) => { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(root, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === binaryName.toLowerCase()) { + return fullPath; + } + if (entry.isDirectory()) { + const found = findBinary(fullPath, binaryName); + if (found) return found; + } + } + return null; +}; + +const main = async () => { + const version = process.env.OPENCHAMBER_OPENCODE_CLI_VERSION || readPinnedSdkVersion(); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid OpenCode CLI version: ${version}`); + } + + const artifact = artifactForCurrentPlatform(); + const outputBinary = outputBinaryPath(artifact.binary); + const existingVersion = readBinaryVersion(outputBinary); + if (existingVersion === version) { + console.log(`[electron] bundled OpenCode CLI already prepared: ${outputBinary} (${version})`); + return; + } + + const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`); + const archivePath = path.join(cacheDir, artifact.name); + const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`; + if (!fs.existsSync(archivePath)) { + console.log(`[electron] downloading OpenCode CLI ${version}: ${artifact.name}`); + await download(url, archivePath); + } else { + console.log(`[electron] using cached OpenCode CLI archive: ${archivePath}`); + } + + const extractDir = path.join(cacheDir, 'extract'); + extractArchive(archivePath, extractDir); + const extractedBinary = findBinary(extractDir, artifact.binary); + if (!extractedBinary) { + throw new Error(`Archive ${archivePath} did not contain ${artifact.binary}`); + } + + fs.mkdirSync(outputDir, { recursive: true }); + for (const entry of fs.readdirSync(outputDir)) { + if (entry === '.gitkeep') continue; + fs.rmSync(path.join(outputDir, entry), { recursive: true, force: true }); + } + fs.copyFileSync(extractedBinary, outputBinary); + ensureExecutable(outputBinary); + + const preparedVersion = readBinaryVersion(outputBinary); + if (preparedVersion !== version) { + throw new Error(`Prepared OpenCode CLI version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`); + } + + console.log(`[electron] prepared OpenCode CLI ${version}: ${outputBinary}`); +}; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/packages/electron/scripts/verify-opencode-cli.mjs b/packages/electron/scripts/verify-opencode-cli.mjs new file mode 100644 index 0000000000..5d9da4f1ac --- /dev/null +++ b/packages/electron/scripts/verify-opencode-cli.mjs @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(electronRoot, '../..'); + +const readExpectedVersion = () => { + const pkg = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')); + const version = pkg.dependencies?.['@opencode-ai/sdk']; + if (typeof version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Expected root @opencode-ai/sdk to be pinned to an exact version, got: ${version || '(missing)'}`); + } + return version; +}; + +const binaryName = () => process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + +const runVersion = (binaryPath) => { + const result = spawnSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15000, + windowsHide: true, + }); + if (result.status !== 0) { + const stderr = result.stderr ? `\n${result.stderr.trim()}` : ''; + const stdout = result.stdout ? `\n${result.stdout.trim()}` : ''; + throw new Error(`Failed to run bundled OpenCode CLI: ${binaryPath}${stderr}${stdout}`); + } + return (result.stdout || '').trim().split(/\s+/)[0] || ''; +}; + +const assertBinary = (binaryPath, expectedVersion) => { + if (!fs.existsSync(binaryPath)) { + throw new Error(`Bundled OpenCode CLI not found: ${binaryPath}`); + } + const stat = fs.statSync(binaryPath); + if (!stat.isFile()) { + throw new Error(`Bundled OpenCode CLI is not a file: ${binaryPath}`); + } + if (process.platform !== 'win32' && (stat.mode & 0o111) === 0) { + throw new Error(`Bundled OpenCode CLI is not executable: ${binaryPath}`); + } + const actualVersion = runVersion(binaryPath); + if (actualVersion !== expectedVersion) { + throw new Error(`Bundled OpenCode CLI version mismatch at ${binaryPath}: expected ${expectedVersion}, got ${actualVersion || '(empty)'}`); + } + console.log(`[electron] verified bundled OpenCode CLI ${actualVersion}: ${binaryPath}`); +}; + +const findPackagedBinaries = () => { + const distDir = path.join(electronRoot, 'dist'); + if (!fs.existsSync(distDir)) return []; + + const candidates = []; + const targetBinary = binaryName().toLowerCase(); + const visit = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + continue; + } + if (!entry.isFile() || entry.name.toLowerCase() !== targetBinary) continue; + const parent = path.basename(path.dirname(fullPath)).toLowerCase(); + if (parent === 'opencode-cli') { + candidates.push(fullPath); + } + } + }; + visit(distDir); + return candidates; +}; + +const usage = () => { + console.error('Usage: node scripts/verify-opencode-cli.mjs --staged|--packaged'); + process.exit(2); +}; + +const main = () => { + const mode = process.argv[2]; + if (mode !== '--staged' && mode !== '--packaged') usage(); + + const expectedVersion = readExpectedVersion(); + if (mode === '--staged') { + assertBinary(path.join(electronRoot, 'resources', 'opencode-cli', binaryName()), expectedVersion); + return; + } + + const packagedBinaries = findPackagedBinaries(); + if (packagedBinaries.length === 0) { + throw new Error('No packaged OpenCode CLI found under packages/electron/dist'); + } + for (const packagedBinary of packagedBinaries) { + assertBinary(packagedBinary, expectedVersion); + } +}; + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index daef0b3326..c68903e7da 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,7 +43,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 3e6142108c..89ace18f61 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -43,13 +43,14 @@ import * as sessionActions from "./session-actions" import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization" import { openSessionFromToast } from "./session-navigation" import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-memory" -import { getRuntimeKey } from "@/lib/runtime-switch" -import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" -import { setSessionPrefetch } from "./session-prefetch-cache" -import { listGlobalSessionPages } from "@/stores/globalSessions" -import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" -import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" -import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" +import { getRuntimeKey } from "@/lib/runtime-switch" +import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" +import { setSessionPrefetch } from "./session-prefetch-cache" +import { listGlobalSessionPages } from "@/stores/globalSessions" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" +import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" +import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" +import { runtimeFetch } from "@/lib/runtime-fetch" // --------------------------------------------------------------------------- // Context @@ -1644,10 +1645,44 @@ function handleEvent( // Provider // --------------------------------------------------------------------------- -const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => { - if (typeof window === "undefined") return - window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload })) -} +const dispatchOpenCodeUpdateAvailable = (payload: { version: string }) => { + if (typeof window === "undefined") return + window.dispatchEvent(new CustomEvent("openchamber:opencode-update-available", { detail: payload })) +} + +let bundledOpenCodeRuntimeCache: { runtimeKey: string; promise: Promise } | null = null + +const isBundledOpenCodeRuntime = async () => { + const runtimeKey = getRuntimeKey() + if (!bundledOpenCodeRuntimeCache || bundledOpenCodeRuntimeCache.runtimeKey !== runtimeKey) { + bundledOpenCodeRuntimeCache = { + runtimeKey, + promise: runtimeFetch("/api/config/opencode-resolution", { signal: AbortSignal.timeout(4000) }) + .then(async (response) => { + if (response.ok) { + const resolution = await response.json() as { source?: unknown; detectedSourceNow?: unknown } + return resolution.source === "bundled" || resolution.detectedSourceNow === "bundled" + } + + const healthResponse = await runtimeFetch("/health", { signal: AbortSignal.timeout(4000) }) + if (!healthResponse.ok) return false + const health = await healthResponse.json() as { opencodeBinarySource?: unknown } + return health.opencodeBinarySource === "bundled" + }) + .catch(() => false), + } + } + return bundledOpenCodeRuntimeCache.promise +} + +const dispatchOpenCodeUpdateAvailableUnlessBundled = (payload: { version: string }) => { + if (typeof window === "undefined") return + void isBundledOpenCodeRuntime().then((isBundled) => { + if (!isBundled) { + dispatchOpenCodeUpdateAvailable(payload) + } + }) +} export function SyncProvider(props: { sdk: OpencodeClient @@ -1859,13 +1894,13 @@ export function SyncProvider(props: { lastStreamActivityAtRef.current = Date.now() dispatchVSCodeRuntimeNotificationEvent(directory, payload) if (payload.type === "installation.update-available") { - const version = typeof (payload.properties as { version?: unknown })?.version === "string" - ? (payload.properties as { version: string }).version - : "" - if (version) { - dispatchOpenCodeUpdateAvailable({ version }) - } - } + const version = typeof (payload.properties as { version?: unknown })?.version === "string" + ? (payload.properties as { version: string }).version + : "" + if (version) { + dispatchOpenCodeUpdateAvailableUnlessBundled({ version }) + } + } handleEvent(directory, payload, childStores, routingIndex) }, onReconnect: () => { diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 20f556a5fa..53165781f9 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -244,7 +244,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 19911bf9c2..65b4438ddd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.17.12", + "@opencode-ai/sdk": "1.17.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "better-sqlite3": "^12.10.0", diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index bee62da192..fabfa0a49c 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -274,6 +274,33 @@ export const createOpenCodeEnvRuntime = (deps) => { return normalized.endsWith(`${path.sep}programs${path.sep}opencode${path.sep}opencode.exe`); }; + const bundledOpenCodeCliCandidates = () => { + const names = process.platform === 'win32' ? ['opencode.exe'] : ['opencode']; + const roots = [ + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR, + typeof process.resourcesPath === 'string' ? path.join(process.resourcesPath, 'opencode-cli') : null, + ] + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter(Boolean); + + const candidates = []; + for (const root of roots) { + for (const name of names) { + candidates.push(path.join(root, name)); + } + } + return candidates; + }; + + const resolveBundledOpenCodeCliPath = () => { + for (const candidate of bundledOpenCodeCliCandidates()) { + if (isExecutable(candidate) && !isWindowsOpenCodeDesktopAppPath(candidate)) { + return candidate; + } + } + return null; + }; + const clearWslOpencodeResolution = () => { state.useWslForOpencode = false; state.resolvedWslBinary = null; @@ -299,6 +326,13 @@ export const createOpenCodeEnvRuntime = (deps) => { } } + const bundled = resolveBundledOpenCodeCliPath(); + if (bundled) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'bundled'; + return bundled; + } + const resolvedFromPath = searchPathFor('opencode'); if (resolvedFromPath) { clearWslOpencodeResolution(); diff --git a/packages/web/server/lib/opencode/env-runtime.test.js b/packages/web/server/lib/opencode/env-runtime.test.js index 0e432a1109..f5c5c7c550 100644 --- a/packages/web/server/lib/opencode/env-runtime.test.js +++ b/packages/web/server/lib/opencode/env-runtime.test.js @@ -9,6 +9,8 @@ const originalComSpec = process.env.ComSpec; const originalPath = process.env.PATH; const originalLocalAppData = process.env.LOCALAPPDATA; const originalSystemRoot = process.env.SystemRoot; +const originalBundledOpencodeCliDir = process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; +const originalResourcesPath = process.resourcesPath; const originalWslBinary = process.env.WSL_BINARY; const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY; const originalPlatform = process.platform; @@ -66,6 +68,17 @@ afterEach(() => { delete process.env.LOCALAPPDATA; } + if (typeof originalBundledOpencodeCliDir === 'string') { + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = originalBundledOpencodeCliDir; + } else { + delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; + } + + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: originalResourcesPath, + }); + if (typeof originalWslBinary === 'string') { process.env.WSL_BINARY = originalWslBinary; } else { @@ -136,6 +149,67 @@ describe('OpenCode env runtime', () => { expect(state.resolvedOpencodeBinarySource).toBe('settings'); }); + it('resolves bundled OpenCode CLI before PATH lookup', () => { + const bundledDir = createTempDir('openchamber-bundled-opencode-'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + const pathDir = createTempDir('openchamber-path-opencode-'); + const pathBinary = path.join(pathDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + fs.writeFileSync(pathBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') { + fs.chmodSync(bundledBinary, 0o755); + fs.chmodSync(pathBinary, 0o755); + } + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir; + process.env.PATH = pathDir; + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}); + + expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); + expect(state.resolvedOpencodeBinarySource).toBe('bundled'); + }); + + it('keeps explicit OpenCode binary ahead of bundled CLI', () => { + const bundledDir = createTempDir('openchamber-bundled-opencode-'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + const explicitDir = createTempDir('openchamber-explicit-opencode-'); + const explicitBinary = path.join(explicitDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + fs.writeFileSync(explicitBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') { + fs.chmodSync(bundledBinary, 0o755); + fs.chmodSync(explicitBinary, 0o755); + } + process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR = bundledDir; + process.env.OPENCODE_BINARY = explicitBinary; + const { runtime, state } = createRuntime({}); + + expect(runtime.resolveOpencodeCliPath()).toBe(explicitBinary); + expect(state.resolvedOpencodeBinarySource).toBe('env'); + }); + + it('resolves bundled OpenCode CLI from Electron resourcesPath', () => { + const resourcesPath = createTempDir('openchamber-resources-'); + const bundledDir = path.join(resourcesPath, 'opencode-cli'); + const bundledBinary = path.join(bundledDir, process.platform === 'win32' ? 'opencode.exe' : 'opencode'); + fs.mkdirSync(bundledDir, { recursive: true }); + fs.writeFileSync(bundledBinary, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') { + fs.chmodSync(bundledBinary, 0o755); + } + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: resourcesPath, + }); + process.env.PATH = createTempDir('openchamber-empty-path-'); + delete process.env.OPENCHAMBER_BUNDLED_OPENCODE_CLI_DIR; + delete process.env.OPENCODE_BINARY; + const { runtime, state } = createRuntime({}); + + expect(runtime.resolveOpencodeCliPath()).toBe(bundledBinary); + expect(state.resolvedOpencodeBinarySource).toBe('bundled'); + }); + itIf(process.platform === 'darwin')('rejects known macOS OpenCode app bundle executable paths', async () => { const { runtime } = createRuntime({ opencodeBinary: '/Applications/OpenCode.app/Contents/MacOS/OpenCode' }); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index f7c54db53e..08cdb0acf8 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -41,6 +41,25 @@ export const registerOpenCodeRoutes = (app, dependencies) => { return trimmed || null; }; + const isBundledOpenCodeBinaryActive = async () => { + const settings = await readSettingsFromDiskMigrated(); + const resolution = await getOpenCodeResolutionSnapshot(settings); + return resolution?.source === 'bundled' || resolution?.detectedSourceNow === 'bundled'; + }; + + const readOpenCodeCurrentVersion = async () => { + const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + const health = await healthResponse.json().catch(() => null); + if (!healthResponse.ok) { + return { ok: false, status: healthResponse.status, error: health?.error || healthResponse.statusText }; + } + const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null; + return { ok: true, currentVersion }; + }; + const parseVersionForComparison = (value) => { const normalized = String(value || '').replace(/^v/, '').split('+')[0]; const prereleaseIndex = normalized.indexOf('-'); @@ -136,6 +155,13 @@ export const registerOpenCodeRoutes = (app, dependencies) => { app.post('/api/opencode/upgrade', async (req, res) => { try { + if (await isBundledOpenCodeBinaryActive()) { + return res.status(409).json({ + success: false, + error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.', + }); + } + const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0 ? req.body.target.trim() : undefined; @@ -180,6 +206,16 @@ export const registerOpenCodeRoutes = (app, dependencies) => { app.get('/api/opencode/upgrade-status', async (_req, res) => { try { + if (await isBundledOpenCodeBinaryActive()) { + const current = await readOpenCodeCurrentVersion().catch(() => ({ ok: false, currentVersion: null })); + return res.json({ + available: false, + currentVersion: current.ok ? current.currentVersion : null, + latestVersion: null, + source: 'bundled', + }); + } + const [healthResponse, latestVersion] = await Promise.all([ fetch(buildOpenCodeUrl('/global/health', ''), { method: 'GET', diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 94170fd244..ade8433ee6 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -53,6 +53,7 @@ Actions: start-mobile-dev Start mobile app with dev server live reload mobile-tools Mobile build/sync/deploy helper menu start-electron-app Start Electron app in dev mode + prepare-opencode-cli Download/cache bundled OpenCode CLI for Electron build-electron-app Build Electron app artifacts start-vscode-extension Build + launch VS Code extension host install-vscode-extension-local Build, package, and install local VSIX @@ -180,6 +181,8 @@ function normalizeAction(action = '') { 'mobile-menu': 'mobile-tools', 'remote-deploy-web': 'remote-deploy-web', 'electron-dev': 'start-electron-app', + 'opencode-cli': 'prepare-opencode-cli', + 'electron-opencode-cli': 'prepare-opencode-cli', 'electron-build': 'build-electron-app', 'vscode-dev': 'start-vscode-extension', 'vscode-install-local': 'install-vscode-extension-local', @@ -474,10 +477,16 @@ async function mobileTools(options, config) { } function startElectronApp() { + prepareOpenCodeCli(); run('bun', ['run', 'electron:dev']); } +function prepareOpenCodeCli() { + step('Preparing bundled OpenCode CLI', () => run('bun', ['--filter', '@openchamber/electron', 'prepare:opencode-cli'])); +} + function buildElectronApp() { + prepareOpenCodeCli(); run('bun', ['run', 'electron:build'], { env: { CSC_IDENTITY_AUTO_DISCOVERY: 'false' } }); const distDir = path.join(repoRoot, 'packages/electron/dist'); if (!existsSync(distDir) || !isMac) return; @@ -543,6 +552,7 @@ async function chooseAction(config) { { value: 'start-mobile-dev', label: 'Start mobile dev' }, { value: 'mobile-tools', label: 'Mobile tools' }, { value: 'start-electron-app', label: 'Start Electron app' }, + { value: 'prepare-opencode-cli', label: 'Prepare bundled OpenCode CLI' }, { value: 'build-electron-app', label: 'Build Electron app' }, { value: 'start-vscode-extension', label: 'Start VS Code extension' }, { value: 'install-vscode-extension-local', label: 'Install VS Code extension locally' }, @@ -589,6 +599,9 @@ async function main() { case 'start-electron-app': startElectronApp(); break; + case 'prepare-opencode-cli': + prepareOpenCodeCli(); + break; case 'build-electron-app': buildElectronApp(); break; From 94aa7ac32fe20c655995f4f01b3fe71f5a2c6f6e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 19:00:58 +0300 Subject: [PATCH 49/88] release v1.13.9 --- CHANGELOG.md | 14 ++++++++++++-- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 8 ++++++++ packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b75e4b90..bf19462e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- Desktop: remote instances can now save additional request headers for proxy-auth setups such as Cloudflare Access, including for live updates and terminal streams. -- Desktop: SSH remote instances with a saved UI password no longer ask for that UI password again after the tunnel connects. +## [1.13.9] - 2026-07-02 + +- Mobile: added the native iOS and Android app projects ahead of the mobile app release, with continued polish for saved connections, password unlock, QR-code connection scanning, push notifications, iOS widgets, app resume, and native layout details. +- Desktop: the app can now use a bundled OpenCode CLI, or you can choose your own CLI path in settings. +- Desktop: added a Keep awake setting for the upcoming desktop app release to prevent the computer from sleeping while the app is running. +- Desktop: you can now specify optional custom headers when adding a remote OpenChamber instance to the desktop app, including for Cloudflare Access-style setups; settings and environment variables can still override them, and the bundled CLI can be replaced by setting a direct OpenCode CLI path. +- Desktop: SSH remote instances with a saved UI password now open directly after the tunnel connects instead of showing the unlock screen again. +- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling. +- Chat: embedded JSON examples in messages no longer render as generated-result cards. +- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state. +- VSCode: clearing optional agent fields now removes them from agent config instead of saving `null` values. +- VSCode: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI. ## [1.13.8] - 2026-06-29 diff --git a/package.json b/package.json index 91851ef8a3..c18f83a421 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.13.8", + "version": "1.13.9", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index a1d9514e94..96f490b042 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.13.8", + "version": "1.13.9", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index c68903e7da..f8ae5ec11e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.13.8", + "version": "1.13.9", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index ee61572613..b3a5631f0b 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.13.9] - 2026-07-02 + +- Agents: clearing optional agent fields now removes them from agent config instead of saving `null` values. +- Startup: the extension no longer picks OpenCode desktop app installs when looking for the standalone OpenCode CLI. +- Chat: fixed edge cases where late-loading tool content, subagent content, or streaming Thinking blocks could pull the conversation away from the latest message or fight manual scrolling. +- Chat: embedded JSON examples in messages no longer render as generated-result cards. +- Sync: chat state now recovers after idle reconnects instead of leaving sessions stuck in a stale busy state. + ## [1.13.8] - 2026-06-29 - Chat: a new Follow-up behavior setting controls what happens when you press Enter on a message while the agent is still responding — Steer inserts it into the agent's current turn, or Queue holds it until the turn finishes. Replaces the previous queue-mode toggle (thanks to @bashrusakh). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 53165781f9..0a9a8ea124 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.13.8", + "version": "1.13.9", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 65b4438ddd..83bb0dfbfd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.13.8", + "version": "1.13.9", "private": false, "type": "module", "main": "./server/index.js", From 0ee55a12fe66ed89deaf6071365cd0a7616d38f5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 19:23:42 +0300 Subject: [PATCH 50/88] fix(ci): run OpenCode CLI version step in bash The release workflow reads the pinned @opencode-ai/sdk version with bash command-substitution syntax before caching the bundled OpenCode CLI artifact. On Windows jobs GitHub Actions defaults run steps to PowerShell, which treated VERSION= as a command and failed before the cache/build steps ran. Set shell: bash on the release workflow version-discovery steps so macOS and Windows use the same syntax. The smoke workflow already used bash for the Windows version step, which is why the smoke artifact could pass while the production Windows release job failed. --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef66d35527..68390b3fe3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,6 +148,7 @@ jobs: - name: Get bundled OpenCode CLI version id: opencode_cli_version + shell: bash run: | VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -294,6 +295,7 @@ jobs: - name: Get bundled OpenCode CLI version id: opencode_cli_version + shell: bash run: | VERSION=$(node -p "require('./package.json').dependencies['@opencode-ai/sdk']") echo "version=$VERSION" >> "$GITHUB_OUTPUT" From ffcbe2c1785ac4030a8a799770fe8e3cbdc27a4e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 2 Jul 2026 22:44:00 +0300 Subject: [PATCH 51/88] fix: prevent mobile session resync flicker Avoid unnecessary resync on clean initial stream connect Skip no-op message snapshot writes during recovery Only trigger mobile resume sync after real app resume --- packages/ui/src/apps/MobileApp.tsx | 15 ++++++- packages/ui/src/sync/session-actions.ts | 5 +++ packages/ui/src/sync/sync-context.tsx | 55 +++++++++++++++---------- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 7e7c1ea93e..825b523d5d 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -202,19 +202,30 @@ const useNativeMobileChrome = (): void => { }; const useNativeMobileLifecycle = (onResume: () => void): void => { + const wasInactiveRef = React.useRef(false); + React.useEffect(() => { if (!isCapacitorMobileApp()) return; let disposed = false; const cleanup: Array<() => void> = []; + const resumeAfterInactive = () => { + if (!wasInactiveRef.current) return; + wasInactiveRef.current = false; + onResume(); + }; void import('@capacitor/app').then(async ({ App }) => { if (disposed) return; const state = await App.addListener('appStateChange', ({ isActive }) => { document.documentElement.classList.toggle('oc-native-app-active', isActive); - if (isActive) onResume(); + if (!isActive) { + wasInactiveRef.current = true; + return; + } + resumeAfterInactive(); }); - const resume = await App.addListener('resume', onResume); + const resume = await App.addListener('resume', resumeAfterInactive); if (disposed) { void state.remove(); void resume.remove(); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 8d4fe79676..e0dc66c584 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1169,6 +1169,10 @@ export async function fetchMessagesForSession(sessionID: string, directory?: str // can't repopulate (and un-evict) a session already navigated away from. if (useSessionUIStore.getState().currentSessionId !== sessionID) return + const latestState = store.getState() + const latestStatus = getSessionMaterializationStatus(latestState, sessionID) + if (latestStatus.renderable && (latestState.message[sessionID]?.length ?? 0) >= records.length) return + store.setState((state) => { const materialized = materializeSessionSnapshots( state, @@ -1179,6 +1183,7 @@ export async function fetchMessagesForSession(sessionID: string, directory?: str })), { skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS }, ) + if (!materialized.messagesChanged && !materialized.partsChanged) return state return { message: materialized.message, part: materialized.part } }) } catch { diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 89ace18f61..6d94b74bfd 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -277,15 +277,18 @@ async function materializeSessionFromServer( if (options?.isStale?.()) return store.setState((state: DirectoryStore) => { - const materialized = materializeSessionSnapshots( - state, - sessionID, + const materialized = materializeSessionSnapshots( + state, + sessionID, records.map((record: { info: Message; parts?: Part[] }) => ({ info: stripMessageDiffSnapshots(record.info), parts: record.parts ?? [], - })), + })), { skipPartTypes: RECONNECT_SKIP_PARTS }, ) + if (!materialized.messagesChanged && !materialized.partsChanged) { + return state + } return { message: materialized.message, part: materialized.part } }) @@ -1705,9 +1708,11 @@ export function SyncProvider(props: { const lastStatusPollAtByDirectoryRef = useRef(new Map()) const lastFullResyncAtByDirectoryRef = useRef(new Map()) const lastChildDiscoveryAtByDirectoryRef = useRef(new Map()) - const resyncingDirectoriesRef = useRef(new Set()) - const statusPollingDirectoriesRef = useRef(new Set()) - const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) + const resyncingDirectoriesRef = useRef(new Set()) + const statusPollingDirectoriesRef = useRef(new Set()) + const pipelineReconnectRef = useRef<((reason?: string) => void) | null>(null) + const pipelineHasConnectedRef = useRef(false) + const pipelineDisconnectedBeforeFirstConnectRef = useRef(false) const system = useMemo( () => ({ @@ -1903,23 +1908,31 @@ export function SyncProvider(props: { } handleEvent(directory, payload, childStores, routingIndex) }, - onReconnect: () => { - useConfigStore.setState({ - isConnected: true, - hasEverConnected: true, - connectionPhase: "connected", - }) - if (isRecentBoot()) { - return - } + onReconnect: () => { + useConfigStore.setState({ + isConnected: true, + hasEverConnected: true, + connectionPhase: "connected", + }) + const isFirstConnect = !pipelineHasConnectedRef.current + pipelineHasConnectedRef.current = true + if (isFirstConnect && !pipelineDisconnectedBeforeFirstConnectRef.current) { + return + } + if (isRecentBoot()) { + return + } for (const dir of childStores.children.keys()) { triggerDirectoryResync(dir, "stream-reconnect") } - }, - onDisconnect: (reason) => { - const { hasEverConnected } = useConfigStore.getState() - useConfigStore.setState({ - isConnected: false, + }, + onDisconnect: (reason) => { + if (!pipelineHasConnectedRef.current) { + pipelineDisconnectedBeforeFirstConnectRef.current = true + } + const { hasEverConnected } = useConfigStore.getState() + useConfigStore.setState({ + isConnected: false, connectionPhase: hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: reason, }) From 4f3a7fd4338ba949eb6a7e78732df51cd75ba729 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 01:34:04 +0300 Subject: [PATCH 52/88] fix: stabilize chat history prepend scroll preservation on mobile and desktop - Mobile: defeat iOS momentum scroll when compensating history prepend (overflow toggle + short rAF watchdog); disable history virtualization and post-paint background prepends; preload Markdown renderer and use plain-text Suspense fallback to avoid first-frame geometry shifts - Desktop: stop double-compensating prepends on the virtualized list - virtua shift owns the adjustment; remove sticky-anchor heuristics that misfired as failed restores - Sync: skip no-op store writes when messages/parts are unchanged --- packages/ui/src/apps/renderMobileApp.tsx | 2 + .../src/components/chat/MarkdownRenderer.tsx | 29 ++- .../ui/src/components/chat/MessageList.tsx | 27 ++- .../hooks/useChatTimelineController.test.ts | 31 ++- .../chat/hooks/useChatTimelineController.ts | 204 ++++++++++++++++-- .../components/chat/markdownRendererLoader.ts | 13 ++ packages/ui/src/sync/use-sync.ts | 20 +- 7 files changed, 286 insertions(+), 40 deletions(-) create mode 100644 packages/ui/src/components/chat/markdownRendererLoader.ts diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx index 64f77954e3..3c2beeafc6 100644 --- a/packages/ui/src/apps/renderMobileApp.tsx +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -16,6 +16,7 @@ import { initializeLocale, I18nProvider } from '@/lib/i18n'; import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startTypographyWatcher } from '@/lib/typographyWatcher'; +import { preloadMarkdownRenderer } from '@/components/chat/markdownRendererLoader'; import { MobileApp } from './MobileApp'; const initializeSharedPreferences = () => { @@ -42,6 +43,7 @@ const initializeSharedPreferences = () => { }; export function renderMobileApp(apis: RuntimeAPIs) { + preloadMarkdownRenderer(); initializeSharedPreferences(); // Expose the widget snapshot builder so the native shell can read the session overview diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 4d56736bf6..c294bd6f6f 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,5 +1,8 @@ import React from 'react'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; +import { cn } from '@/lib/utils'; +import { loadMarkdownRendererModule } from './markdownRendererLoader'; // Thin lazy wrapper around the MarkdownRenderer implementation. // The full implementation (marked + Shiki highlighting + KaTeX + morphdom @@ -7,23 +10,41 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // initial bundle lean. const MarkdownRendererLazy = lazyWithChunkRecovery(() => - import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) + loadMarkdownRendererModule().then((m) => ({ default: m.MarkdownRenderer })) ); const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() => - import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer })) + loadMarkdownRendererModule().then((m) => ({ default: m.SimpleMarkdownRenderer })) ); const fallback =
; +const fallbackContentClassName = (variant: unknown): string => { + if (variant === 'tool') return 'markdown-content markdown-tool'; + if (variant === 'reasoning') return 'markdown-content markdown-reasoning'; + return 'markdown-content leading-relaxed'; +}; + +const MobileMarkdownFallback = (props: { content?: unknown; className?: unknown; variant?: unknown }) => { + if (!isMobileSurfaceRuntime() || typeof props.content !== 'string' || props.content.length === 0) { + return fallback; + } + + return ( +
+ {props.content} +
+ ); +}; + export const MarkdownRenderer: React.FC> = (props) => ( - + }> ); export const SimpleMarkdownRenderer: React.FC> = (props) => ( - + }> ); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 653f370d32..73bcea09e6 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -155,6 +155,21 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => { return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null; }; +const isInsideStuckSticky = (node: HTMLElement, container: HTMLElement, containerTop: number): boolean => { + if (typeof window === 'undefined') return false; + + let current: HTMLElement | null = node; + while (current && current !== container) { + const computed = window.getComputedStyle(current); + if (computed.position === 'sticky' && current.getBoundingClientRect().top <= containerTop + 1) { + return true; + } + current = current.parentElement; + } + + return false; +}; + const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => { if (!message) return false; if (resolveMessageRole(message) !== 'user') return false; @@ -373,6 +388,7 @@ export interface MessageListHandle { scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; + isHistoryVirtualized: () => boolean; scrollToBottom: () => void; } @@ -1262,7 +1278,10 @@ const MessageList = React.forwardRef(({ } const historyEntries = staticRenderEntries; - const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; + // Virtua hides unmeasured items until ResizeObserver reports their height. + // Mobile momentum scrolling can outrun that measurement and expose blank + // reserved rows, so keep the constrained mobile history mounted normally. + const shouldVirtualizeHistory = !isMobileSurfaceRuntime() && historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]); const virtualCache = React.useMemo( () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined), @@ -1468,6 +1487,8 @@ const MessageList = React.forwardRef(({ ); }, + isHistoryVirtualized: () => shouldVirtualizeHistory, + captureViewportAnchor: () => { const container = resolveScrollContainer(); if (!container) { @@ -1486,9 +1507,7 @@ const MessageList = React.forwardRef(({ return true; } - const computed = window.getComputedStyle(node); - const isStuckSticky = computed.position === 'sticky' && rect.top <= containerRect.top + 1; - return !isStuckSticky; + return !isInsideStuckSticky(node, container, containerRect.top); }) ?? nodes.find((node) => node.getBoundingClientRect().bottom > containerRect.top + 1); if (!firstVisible) { return null; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts index 7c45cd4fc1..4eb0a72ec7 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { shouldAutoLoadEarlierForUnderfilledPinnedViewport } from './useChatTimelineController'; +import { + isOlderHistoryPrependCommit, + shouldAutoLoadEarlierForUnderfilledPinnedViewport, +} from './useChatTimelineController'; const baseInput = { sessionId: 'ses_1', @@ -39,3 +42,29 @@ describe('shouldAutoLoadEarlierForUnderfilledPinnedViewport', () => { })).toBe(false); }); }); + +describe('isOlderHistoryPrependCommit', () => { + test('detects older messages inserted above the existing timeline', () => { + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_1', + currentNewestId: 'msg_4', + })).toBe(true); + }); + + test('does not treat appends or replacements as prepends', () => { + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_2', + currentNewestId: 'msg_5', + })).toBe(false); + expect(isOlderHistoryPrependCommit({ + previousOldestId: 'msg_2', + previousNewestId: 'msg_4', + currentOldestId: 'msg_1', + currentNewestId: 'msg_5', + })).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 13a0793cc4..528eafa42d 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -126,6 +126,75 @@ export const shouldAutoLoadEarlierForUnderfilledPinnedViewport = (input: { return input.scrollHeight <= input.clientHeight + 1; }; +export const isOlderHistoryPrependCommit = (input: { + previousOldestId: string | null; + previousNewestId: string | null; + currentOldestId: string | null; + currentNewestId: string | null; +}): boolean => Boolean( + input.previousOldestId + && input.currentOldestId + && input.currentOldestId !== input.previousOldestId + && input.previousNewestId + && input.currentNewestId + && input.currentNewestId === input.previousNewestId, +); + +// iOS WKWebView ignores programmatic scrollTop writes while a touch drag or +// momentum (fling) scroll is active: the native scroll animation keeps running +// and overwrites the value on the next frame. The mobile history threshold is +// large enough that the prepend commit almost always lands mid-fling, so a +// plain `container.scrollTop = target` never sticks. Toggling overflow kills +// the native scroll synchronously (pre-paint, invisible inside a layout +// effect); a short post-paint watchdog re-asserts the target if residual +// momentum still drags the viewport upward. +const MOMENTUM_WATCHDOG_FRAMES = 20; +const MOMENTUM_WATCHDOG_TOLERANCE_PX = 4; + +const setScrollTopDefeatingMomentum = (container: HTMLElement, target: number) => { + const previousOverflow = container.style.overflow; + container.style.overflow = 'hidden'; + container.scrollTop = target; + void container.scrollHeight; + container.style.overflow = previousOverflow; + container.scrollTop = target; + + if (typeof window === 'undefined') return; + let cancelled = false; + let frames = 0; + const cancelOnUserTouch = () => { + cancelled = true; + }; + container.addEventListener('touchstart', cancelOnUserTouch, { passive: true, once: true }); + const watch = () => { + if (cancelled) return; + // Only correct upward drift (residual momentum). Downward movement or + // content growth above the viewport must not be fought here. + if (container.scrollTop < target - MOMENTUM_WATCHDOG_TOLERANCE_PX) { + container.scrollTop = target; + } + frames += 1; + if (frames < MOMENTUM_WATCHDOG_FRAMES) { + window.requestAnimationFrame(watch); + } else { + container.removeEventListener('touchstart', cancelOnUserTouch); + } + }; + window.requestAnimationFrame(watch); +}; + +const hasInsertedBeforeKnownOldest = ( + previousOldestId: string | null, + currentOldestId: string | null, + messages: ChatMessageEntry[], +): boolean => { + if (!previousOldestId || !currentOldestId || currentOldestId === previousOldestId) { + return false; + } + + return messages.some((message) => message.info.id === previousOldestId); +}; + export const useChatTimelineController = ({ sessionId, messages, @@ -339,9 +408,12 @@ export const useChatTimelineController = ({ // before triggering the state change. useLayoutEffect consumes it // after React commits new DOM — before the browser paints. const prePrependScrollRef = React.useRef<{ + sessionId: string | null; height: number; top: number; anchor: ViewportAnchor | null; + oldestId: string | null; + newestId: string | null; } | null>(null); const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => { @@ -364,26 +436,47 @@ export const useChatTimelineController = ({ scrollHeight: number; } | null>(null); + React.useLayoutEffect(() => { + prePrependScrollRef.current = null; + prependTrackingRef.current = null; + }, [sessionId]); + React.useLayoutEffect(() => { const container = scrollRef.current; if (!container) return; - const snap = prePrependScrollRef.current; + let snap = prePrependScrollRef.current; const prev = prependTrackingRef.current; const currentOldestId = renderedMessages[0]?.info?.id ?? null; const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null; - // A prepend = content inserted ABOVE the viewport: the oldest message id - // changed while the newest stayed the same. This distinguishes a history - // load from a bottom append, a streaming part growing, or a session switch. - const isPrepend = Boolean( - prev - && prev.oldestId - && currentOldestId - && currentOldestId !== prev.oldestId - && prev.newestId - && currentNewestId - && currentNewestId === prev.newestId, - ); + // A prepend = content inserted ABOVE the viewport: either the newest + // stayed fixed, or the old first message still exists below a new first + // message. The latter keeps preservation alive if a tail append lands in + // the same commit as the history page. + const isPrepend = prev + ? isOlderHistoryPrependCommit({ + previousOldestId: prev.oldestId, + previousNewestId: prev.newestId, + currentOldestId, + currentNewestId, + }) || hasInsertedBeforeKnownOldest(prev.oldestId, currentOldestId, renderedMessages) + : false; + + if (snap && snap.sessionId !== sessionIdRef.current) { + prePrependScrollRef.current = null; + snap = null; + } + + const isSnapshotPrepend = snap + ? isOlderHistoryPrependCommit({ + previousOldestId: snap.oldestId, + previousNewestId: snap.newestId, + currentOldestId, + currentNewestId, + }) || hasInsertedBeforeKnownOldest(snap.oldestId, currentOldestId, renderedMessages) + : false; + const didPrepend = isPrepend || isSnapshotPrepend; + const shouldConsumeSnapshot = Boolean(snap && (isPrepend || isSnapshotPrepend)); const updateTracking = () => { prependTrackingRef.current = { @@ -393,6 +486,22 @@ export const useChatTimelineController = ({ }; }; + const refreshPendingSnapshot = () => { + const pending = prePrependScrollRef.current; + if (!pending) { + return; + } + + prePrependScrollRef.current = { + ...pending, + height: container.scrollHeight, + top: container.scrollTop, + anchor: captureViewportAnchor(), + oldestId: currentOldestId, + newestId: currentNewestId, + }; + }; + if (isPinnedRef.current) { // Bottom-pinned. Only content inserted ABOVE (a prepend / history load) // needs an explicit re-pin: with overflow-anchor:none the browser leaves @@ -407,38 +516,74 @@ export const useChatTimelineController = ({ // best, and the source of the old up/down jiggle on send / from the // queue / while streaming. So for an append we do nothing and let // auto-follow own it. - if (snap || isPrepend) { + if (didPrepend) { prePrependScrollRef.current = null; goToBottom('instant'); + } else if (snap) { + refreshPendingSnapshot(); } updateTracking(); return; } - if (snap) { + // When the history list is virtualized, virtua runs with `shift` during + // history loads and compensates the prepend internally. Manual + // height-delta compensation on top of that applies the same delta twice + // and throws the viewport far downward. Anchor restore stays allowed — + // it corrects to an absolute element position, so it cannot double up. + const historyVirtualized = messageListRef.current?.isHistoryVirtualized() ?? false; + + if (snap && shouldConsumeSnapshot) { prePrependScrollRef.current = null; + const heightDelta = container.scrollHeight - snap.height; + const applyHeightDelta = (): boolean => { + if (historyVirtualized || heightDelta <= 0) { + return false; + } + container.scrollTop = snap.top + heightDelta; + return true; + }; + + if (isMobileSurfaceRuntime() && heightDelta > 0) { + setScrollTopDefeatingMomentum(container, snap.top + heightDelta); + updateTracking(); + return; + } + // When a viewport anchor is available, delegate to MessageList // restoreViewportAnchor which falls back to virtualizer-aware // scrollHistoryIndexIntoView when the element is not in the DOM. + // Note: an unchanged scrollTop after restore is NOT a failure here — + // the virtualized desktop list runs with virtua `shift`, which + // compensates the prepend internally, so staying near snap.top is + // the correct outcome. if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) { // Fallback: height-delta compensation - const delta = container.scrollHeight - snap.height; - if (delta > 0) { - container.scrollTop = snap.top + delta; - } + applyHeightDelta(); } - } else if (isPrepend && prev) { + } else if (isPrepend && prev && !historyVirtualized) { // Released viewport: preserve the read position by compensating for the // exact height the prepend added above, with no intermediate frame for - // auto-follow to fight. + // auto-follow to fight. Virtualized lists skip this — virtua `shift` + // already compensated the prepend. const delta = container.scrollHeight - prev.scrollHeight; if (delta > 0) { - container.scrollTop = container.scrollTop + delta; + const target = container.scrollTop + delta; + if (isMobileSurfaceRuntime()) { + setScrollTopDefeatingMomentum(container, target); + } else { + container.scrollTop = target; + } } + } else if (snap) { + // setIsLoadingOlder/historyMeta can commit before the server page + // arrives. Keep the snapshot armed, but refresh it so later fallback + // compensation only accounts for rows actually prepended above. + refreshPendingSnapshot(); } updateTracking(); - }, [renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); + }, [captureViewportAnchor, messageListRef, renderedMessages, scrollRef, restoreViewportAnchor, goToBottom]); const revealBufferedTurns = React.useCallback(async (): Promise => false, []); @@ -462,9 +607,12 @@ export const useChatTimelineController = ({ // compensate synchronously when React commits the new messages. if (input.preserveViewport && container) { prePrependScrollRef.current = { + sessionId: sessionIdRef.current, height: container.scrollHeight, top: container.scrollTop, anchor: captureViewportAnchor(), + oldestId: beforeOldestMessageId, + newestId: beforeMessages[beforeMessages.length - 1]?.info?.id ?? null, }; } @@ -474,6 +622,7 @@ export const useChatTimelineController = ({ try { const targetSessionId = sessionIdRef.current; if (!targetSessionId) { + prePrependScrollRef.current = null; return false; } @@ -485,6 +634,7 @@ export const useChatTimelineController = ({ while (true) { await loadMoreMessages(targetSessionId, 'up'); if (sessionIdRef.current !== targetSessionId) { + prePrependScrollRef.current = null; return false; } @@ -506,6 +656,7 @@ export const useChatTimelineController = ({ return true; } if (!messageGrowth) { + prePrependScrollRef.current = null; return false; } if (!historySignalsRef.current.hasMoreAboveTurns) { @@ -516,6 +667,9 @@ export const useChatTimelineController = ({ loadedOldestMessageId = afterOldestMessageId; loadedLimit = afterLimit; } + } catch (error) { + prePrependScrollRef.current = null; + throw error; } finally { setIsLoadingOlder(false); settleHistoryInteraction(); @@ -547,6 +701,10 @@ export const useChatTimelineController = ({ }, [loadEarlier, scrollRef]); const loadEarlierIfPinnedViewportUnderfilled = React.useCallback(() => { + // On mobile the initial page is intentionally smaller. Auto-prepending + // older rows after first paint shifts the narrow timeline; let explicit + // upward scroll request history instead. + if (isMobileSurfaceRuntime()) return; if (historyInteractionRef.current) return; const container = scrollRef.current; if (!container) return; diff --git a/packages/ui/src/components/chat/markdownRendererLoader.ts b/packages/ui/src/components/chat/markdownRendererLoader.ts new file mode 100644 index 0000000000..986fbedcc4 --- /dev/null +++ b/packages/ui/src/components/chat/markdownRendererLoader.ts @@ -0,0 +1,13 @@ +let markdownRendererModulePromise: Promise | null = null; + +export const loadMarkdownRendererModule = () => { + markdownRendererModulePromise ??= import('./MarkdownRendererImpl').catch((error) => { + markdownRendererModulePromise = null; + throw error; + }); + return markdownRendererModulePromise; +}; + +export const preloadMarkdownRenderer = () => { + void loadMarkdownRendererModule().catch(() => undefined); +}; diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index b0d646d6c8..6271ffde1a 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -399,7 +399,12 @@ export function useSync() { complete: merged.complete, loading: false, }) - store.setState({ message: materialized.message, part: materialized.part }) + if (materialized.messagesChanged || materialized.partsChanged) { + store.setState({ + ...(materialized.messagesChanged ? { message: materialized.message } : {}), + ...(materialized.partsChanged ? { part: materialized.part } : {}), + }) + } setSessionPrefetch({ directory, sessionID, @@ -498,13 +503,12 @@ export function useSync() { shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(), ]) - // Progressive mount: after the initial page resolves, if the session - // isn't stale and the server indicated more messages, dispatch a - // second fetch to prepend older history. The user sees the first page - // immediately; the rest arrive shortly after. This gives the scroll - // container headroom above the viewport so the "load older on - // scroll-up" trigger fires before the user hits the absolute top. - if (!isStale()) { + // Progressive mount on desktop: after the initial page resolves, if the + // session isn't stale and the server indicated more messages, dispatch a + // second fetch to prepend older history. Mobile avoids this background + // prepend because adding rows after first paint on a narrow viewport can + // visibly shift the timeline; user scroll still loads older history. + if (!isStale() && !isMobileSurfaceRuntime()) { const currentMeta = getMetaFor(sessionID) if (currentMeta.cursor && !currentMeta.complete) { loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale }) From 862975ba1ec0381a464dfad06a4cc8044d90e1e1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 13:49:53 +0300 Subject: [PATCH 53/88] feat(chat): migrate history list to @tanstack/react-virtual with deterministic mobile history loading - Replace virtua with @tanstack/react-virtual for chat history on all surfaces: bottom anchoring (anchorTo: end), key-stable prepend preservation, and native iOS touch/momentum deferral live in the core - Patch virtual-core to clamp the render range to real scroll bounds during transient adjustments - Rows render in normal flow inside a translated wrapper so sticky user headers keep working; measurement snapshots cached per session - Pre-write container height in scrollToFn so the browser cannot clamp anchor corrections to the stale height; hold the prepend anchor for up to 180 frames on mobile while fresh rows settle (cancelled by user input; desktop relies on core anchoring alone) - Adaptive row-size estimate from per-session measured averages; disable reveal fade-in for virtualized history rows - Mobile loads older history only through an explicit localized top button: no scroll-position trigger and no post-mount background prepend, so every insert happens from a resting state; a quiet-window hold defers any stray prepend commit while a touch gesture is active - Desktop/VS Code keep the seamless scroll-up trigger and progressive background prepend --- bun.lock | 16 +- package.json | 3 + packages/ui/package.json | 1 + .../ui/src/components/chat/ChatContainer.tsx | 35 +- .../ui/src/components/chat/MessageList.tsx | 443 +++++++++++++----- .../chat/hooks/useChatTimelineController.ts | 45 +- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/sync/use-sync.ts | 14 +- patches/@tanstack%2Fvirtual-core@3.17.3.patch | 36 ++ 18 files changed, 458 insertions(+), 145 deletions(-) create mode 100644 patches/@tanstack%2Fvirtual-core@3.17.3.patch diff --git a/bun.lock b/bun.lock index e49df4360b..37335213a5 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.13.8", + "version": "1.13.9", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -133,7 +133,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.13.8", + "version": "1.13.9", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -171,6 +171,7 @@ "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", + "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", @@ -236,7 +237,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.13.8", + "version": "1.13.9", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.17.12", @@ -259,7 +260,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.13.8", + "version": "1.13.9", "bin": { "openchamber": "./bin/cli.js", }, @@ -345,6 +346,9 @@ }, }, }, + "patchedDependencies": { + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + }, "overrides": { "@codemirror/language": "6.12.2", "@codemirror/view": "6.39.13", @@ -1296,6 +1300,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + "@textlint/ast-node-types": ["@textlint/ast-node-types@15.5.2", "", {}, "sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg=="], "@textlint/linter-formatter": ["@textlint/linter-formatter@15.5.2", "", { "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", "@textlint/module-interop": "15.5.2", "@textlint/resolver": "15.5.2", "@textlint/types": "15.5.2", "chalk": "^4.1.2", "debug": "^4.4.3", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" } }, "sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg=="], diff --git a/package.json b/package.json index c18f83a421..8f83f714e8 100644 --- a/package.json +++ b/package.json @@ -174,5 +174,8 @@ "typescript": "~5.9.0", "typescript-eslint": "^8.39.1", "vite": "^7.1.2" + }, + "patchedDependencies": { + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index f8ae5ec11e..862ffbb19d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,6 +47,7 @@ "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", + "@tanstack/react-virtual": "3.14.5", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.0", "beautiful-mermaid": "^1.1.3", diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index b73c52ee35..0a25eb422d 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -46,6 +46,7 @@ import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-pre import { getSessionMaterializationStatus } from '@/sync/materialization'; import { usePlanDetection } from '@/hooks/usePlanDetection'; import { useI18n } from '@/lib/i18n'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isVSCodeRuntime } from '@/lib/desktop'; const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; @@ -157,6 +158,8 @@ type ChatViewportProps = { sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; + showLoadOlderButton: boolean; + onLoadOlder: () => void; }; const ChatViewport = React.memo(({ @@ -181,7 +184,10 @@ const ChatViewport = React.memo(({ sessionQuestions, sessionPermissions, isProgrammaticFollowActive, + showLoadOlderButton, + onLoadOlder, }: ChatViewportProps) => { + const { t } = useI18n(); const focusScrollContainer = React.useCallback((event: React.MouseEvent) => { if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) { return; @@ -218,6 +224,21 @@ const ChatViewport = React.memo(({ data-scrollbar="chat" >
+ {showLoadOlderButton && ( +
+ +
+ )} = ({ autoOpenDraft = tr const resumeToLatestInstant = React.useCallback(() => { goToBottom('instant'); }, [goToBottom]); + // Mobile loads older history via an explicit top button instead of a + // scroll-position trigger (see handleHistoryScroll in the controller). + const showLoadOlderButton = isMobileSurfaceRuntime() + && timelineController.historySignals.canLoadEarlier; + const timelineLoadEarlier = timelineController.loadEarlier; + const handleLoadOlderClick = React.useCallback(() => { + void timelineLoadEarlier({ userInitiated: true }); + }, [timelineLoadEarlier]); React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; @@ -918,6 +949,8 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr sessionQuestions={sessionQuestions} sessionPermissions={sessionPermissions} isProgrammaticFollowActive={isFollowingProgrammatically} + showLoadOlderButton={showLoadOlderButton} + onLoadOlder={handleLoadOlderClick} />
(); -const MESSAGE_LIST_BUFFER_SIZE = 900; -// Touch surfaces fling-scroll natively and dispatch scroll events less often -// than the virtualizer can repaint, so a desktop-sized buffer leaves blank gaps -// during momentum that only fill once measurement catches up. A larger overscan -// keeps more rows mounted around the viewport so fast flings stay populated. -const MOBILE_MESSAGE_LIST_BUFFER_SIZE = 2400; -const resolveMessageListBufferSize = (): number => ( - isMobileSurfaceRuntime() ? MOBILE_MESSAGE_LIST_BUFFER_SIZE : MESSAGE_LIST_BUFFER_SIZE -); const TIMELINE_CACHE_LIMIT = 16; const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean => { @@ -42,28 +33,81 @@ const sameKeys = (a: readonly string[] | undefined, b: readonly string[] | undef return a.every((key, index) => key === b[index]); }; -const timelineCache = new Map(); +// --- History virtualization (@tanstack/react-virtual) ---------------------- +// The history list virtualizes with @tanstack/react-virtual on all surfaces: +// its core has bottom anchoring (anchorTo: 'end'), key-stable prepend +// preservation, and native iOS touch/momentum deferral for scroll +// adjustments — the failure modes that historically forced virtua off on +// mobile and required manual prepend compensation on desktop. +type TanstackVirtualizerInstance = ReactVirtualizer; +type HistoryEngine = 'none' | 'tanstack'; + +const TANSTACK_ESTIMATED_ENTRY_SIZE = 320; +const TANSTACK_OVERSCAN = 8; +// Touch flings cover more distance between paints than desktop wheels; a +// larger window keeps fast mobile scrolling over mounted rows. +const TANSTACK_MOBILE_OVERSCAN = 16; +const resolveTanstackOverscan = (): number => ( + isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN +); +// Post-prepend anchor hold (upstream parity): measurements of freshly +// prepended rows settle over multiple frames, so a single restore can be +// invalidated by the next measurement pass. Re-assert the anchor until it +// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES. +const ANCHOR_HOLD_STABLE_FRAMES = 30; +const ANCHOR_HOLD_MAX_FRAMES = 180; +// Adaptive estimate bounds: only trust the session average once a few rows +// are measured, and keep it inside sane turn-height bounds. +const TANSTACK_ESTIMATE_MIN_SAMPLES = 5; +const TANSTACK_ESTIMATE_MIN = 120; +const TANSTACK_ESTIMATE_MAX = 1200; + +// Quiet-window prepend on mobile: while a touch drag or momentum scroll is +// active, iOS owns the scroll position and ANY geometry change above the +// viewport races against the native animation — a race that compensation +// logic can only lose sometimes. So freshly loaded older history is held +// (data already fetched, store already updated) and inserted into the +// rendered list only once the gesture goes quiet. Safety valves: flush when +// the user gets close to the top (a blank top is worse than a small hop) or +// after MAX_HOLD_MS. +const HISTORY_PREPEND_QUIET_MS = 160; +const HISTORY_PREPEND_MAX_HOLD_MS = 1500; +const HISTORY_PREPEND_NEAR_TOP_VIEWPORTS = 1.5; +const HISTORY_PREPEND_MONITOR_INTERVAL_MS = 90; + +// A commit is a deferable prepend when older entries were inserted strictly +// above the known content: the previous first key still exists deeper in the +// list and the tail is unchanged. Anything else renders immediately. +const isPrependAboveCommit = (previous: RenderEntry[], next: RenderEntry[]): boolean => { + if (previous.length === 0 || next.length <= previous.length) return false; + if (previous[previous.length - 1]?.key !== next[next.length - 1]?.key) return false; + const previousFirstKey = previous[0]?.key; + const insertedIndex = next.findIndex((entry) => entry.key === previousFirstKey); + return insertedIndex > 0; +}; + +const tanstackTimelineCache = new Map(); -const readTimelineCache = (sessionKey: string, keys: readonly string[]): CacheSnapshot | undefined => { - const entry = timelineCache.get(sessionKey); +const readTanstackTimelineCache = (sessionKey: string, keys: readonly string[]): VirtualItem[] | undefined => { + const entry = tanstackTimelineCache.get(sessionKey); if (!entry) return undefined; - if (sameKeys(entry.keys, keys)) return entry.cache; - timelineCache.delete(sessionKey); + if (sameKeys(entry.keys, keys)) return entry.items; + tanstackTimelineCache.delete(sessionKey); return undefined; }; -const writeTimelineCache = ( +const writeTanstackTimelineCache = ( sessionKey: string, keys: readonly string[], - handle: VirtualizerHandle | null | undefined, + virtualizer: TanstackVirtualizerInstance | null | undefined, ): void => { - if (!handle || keys.length === 0) return; - timelineCache.delete(sessionKey); - timelineCache.set(sessionKey, { keys: keys.slice(), cache: handle.cache }); - while (timelineCache.size > TIMELINE_CACHE_LIMIT) { - const oldest = timelineCache.keys().next().value; + if (!virtualizer || keys.length === 0) return; + tanstackTimelineCache.delete(sessionKey); + tanstackTimelineCache.set(sessionKey, { keys: keys.slice(), items: virtualizer.takeSnapshot() }); + while (tanstackTimelineCache.size > TIMELINE_CACHE_LIMIT) { + const oldest = tanstackTimelineCache.keys().next().value; if (typeof oldest !== 'string') break; - timelineCache.delete(oldest); + tanstackTimelineCache.delete(oldest); } }; @@ -388,6 +432,7 @@ export interface MessageListHandle { scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => boolean; captureViewportAnchor: () => { messageId: string; offsetTop: number } | null; restoreViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => boolean; + holdViewportAnchor: (anchor: { messageId: string; offsetTop: number }) => void; isHistoryVirtualized: () => boolean; scrollToBottom: () => void; } @@ -930,13 +975,11 @@ MessageListEntry.displayName = 'MessageListEntry'; // Inner component that renders staged turn entries. type StaticHistoryListProps = { entries: RenderEntry[]; - shouldVirtualize: boolean; + engine: HistoryEngine; contentRef: React.RefObject; scrollRef?: React.RefObject; - virtualizerRef: React.Ref; + registerTanstackVirtualizer?: (virtualizer: TanstackVirtualizerInstance | null) => void; virtualizerKey: string; - virtualCache?: CacheSnapshot; - shift: boolean; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: () => void; @@ -950,7 +993,151 @@ type StaticHistoryListProps = { reviewTransferDirection?: ReviewTransferDirection | null; }; -const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => { +const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, registerTanstackVirtualizer, virtualizerKey, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => { + const isTanstack = engine === 'tanstack'; + + // --- Quiet-window prepend (mobile) -------------------------------------- + // Gesture tracking for the deferred-prepend decision. Refs only: reading + // them never re-renders, and the render-phase reconcile below needs them. + const touchActiveRef = React.useRef(false); + const lastScrollAtRef = React.useRef(0); + const holdSinceRef = React.useRef(null); + const deferPrepends = isTanstack && isMobileSurfaceRuntime(); + + React.useEffect(() => { + if (!deferPrepends) return; + const element = scrollRef?.current; + if (!element) return; + const onTouchStart = () => { touchActiveRef.current = true; }; + const onTouchEnd = () => { touchActiveRef.current = false; }; + const onScroll = () => { lastScrollAtRef.current = performance.now(); }; + element.addEventListener('touchstart', onTouchStart, { passive: true }); + element.addEventListener('touchend', onTouchEnd, { passive: true }); + element.addEventListener('touchcancel', onTouchEnd, { passive: true }); + element.addEventListener('scroll', onScroll, { passive: true }); + return () => { + element.removeEventListener('touchstart', onTouchStart); + element.removeEventListener('touchend', onTouchEnd); + element.removeEventListener('touchcancel', onTouchEnd); + element.removeEventListener('scroll', onScroll); + }; + }, [deferPrepends, scrollRef]); + + const isGestureActive = React.useCallback(() => ( + touchActiveRef.current + || performance.now() - lastScrollAtRef.current < HISTORY_PREPEND_QUIET_MS + ), []); + + const isNearTop = React.useCallback(() => { + const element = scrollRef?.current; + if (!element) return true; + return element.scrollTop < element.clientHeight * HISTORY_PREPEND_NEAR_TOP_VIEWPORTS; + }, [scrollRef]); + + const [displayEntries, setDisplayEntries] = React.useState(entries); + // Render-phase reconcile (official derived-state pattern): adopt the new + // entries immediately unless this commit is a pure prepend-above landing + // in the middle of an active touch gesture — those wait for quiet. + let renderEntries = displayEntries; + if (entries !== displayEntries) { + const shouldHold = deferPrepends + && isPrependAboveCommit(displayEntries, entries) + && isGestureActive() + && !isNearTop() + && (holdSinceRef.current === null + || performance.now() - holdSinceRef.current < HISTORY_PREPEND_MAX_HOLD_MS); + if (shouldHold) { + if (holdSinceRef.current === null) holdSinceRef.current = performance.now(); + } else { + holdSinceRef.current = null; + setDisplayEntries(entries); + renderEntries = entries; + } + } else if (holdSinceRef.current !== null) { + holdSinceRef.current = null; + } + + // While a prepend is held, poll for the quiet window (touch/momentum have + // no completion event we can await) and flush by re-rendering. + const [, forceFlushTick] = React.useReducer((tick: number) => tick + 1, 0); + React.useEffect(() => { + if (!deferPrepends) return; + const timer = window.setInterval(() => { + if (holdSinceRef.current === null) return; + const expired = performance.now() - holdSinceRef.current >= HISTORY_PREPEND_MAX_HOLD_MS; + if (!isGestureActive() || isNearTop() || expired) { + forceFlushTick(); + } + }, HISTORY_PREPEND_MONITOR_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [deferPrepends, isGestureActive, isNearTop]); + + const entriesRef = React.useRef(renderEntries); + entriesRef.current = renderEntries; + // Initial-only read: measurement cache restore is a mount-time concern; + // afterwards the live virtualizer owns measurements. + const [initialMeasurements] = React.useState(() => ( + isTanstack + ? readTanstackTimelineCache(virtualizerKey, entries.map((entry) => entry.key)) + : undefined + )); + + const sizeContainerRef = React.useRef(null); + // Adaptive estimate: rows this session has actually measured are a far + // better predictor for the still-unmeasured ones than a fixed constant. + // Smaller estimate error → smaller anchor corrections when prepended rows + // measure in → less visible drift. The ref keeps estimateSize's identity + // stable so updating the average never triggers a global remeasure. + const estimatedEntrySizeRef = React.useRef(TANSTACK_ESTIMATED_ENTRY_SIZE); + const tanstackVirtualizer = useTanstackVirtualizer({ + count: renderEntries.length, + enabled: isTanstack, + getScrollElement: () => scrollRef?.current ?? null, + estimateSize: () => estimatedEntrySizeRef.current, + overscan: resolveTanstackOverscan(), + scrollToFn: (offset, options, instance) => { + // Expose the new total height before core writes an anchor + // correction so the browser does not clamp the offset to the old + // height (upstream parity). + const sizeElement = sizeContainerRef.current; + if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`; + elementScroll(offset, options, instance); + }, + getItemKey: (index) => entriesRef.current[index]?.key ?? `index:${index}`, + // Bottom-anchored chat semantics: prepending older entries above the + // viewport must not move what the user is reading, and iOS-specific + // touch/momentum deferral for those adjustments lives in the core. + anchorTo: 'end', + initialOffset: () => Number.MAX_SAFE_INTEGER, + initialMeasurementsCache: initialMeasurements, + }); + + React.useEffect(() => { + if (!isTanstack) return; + const sizes = tanstackVirtualizer.itemSizeCache; + if (sizes.size >= TANSTACK_ESTIMATE_MIN_SAMPLES) { + let total = 0; + for (const size of sizes.values()) total += size; + estimatedEntrySizeRef.current = Math.min( + TANSTACK_ESTIMATE_MAX, + Math.max(TANSTACK_ESTIMATE_MIN, Math.round(total / sizes.size)), + ); + } + }); + + React.useEffect(() => { + if (!isTanstack) return; + registerTanstackVirtualizer?.(tanstackVirtualizer); + return () => { + writeTanstackTimelineCache( + virtualizerKey, + entriesRef.current.map((entry) => entry.key), + tanstackVirtualizer, + ); + registerTanstackVirtualizer?.(null); + }; + }, [isTanstack, registerTanstackVirtualizer, tanstackVirtualizer, virtualizerKey]); + const renderEntry = React.useCallback((entry: RenderEntry) => { return ( - {entries.map((entry) => ( + {renderEntries.map((entry) => (
- {(entry) => ( -
- {renderEntry(entry)} + if (engine === 'tanstack') { + const virtualItems = tanstackVirtualizer.getVirtualItems(); + const startOffset = virtualItems[0]?.start ?? 0; + // Rendered rows stay in normal flow inside a single translated wrapper + // (not per-row absolute positioning) so per-turn sticky user headers + // keep working against the scroll container. + return ( +
+
+ {virtualItems.map((item) => { + const entry = renderEntries[item.index]; + if (!entry) return null; + return ( +
+ {renderEntry(entry)} +
+ ); + })}
- )} - - ); +
+ ); + } + + return null; }); StaticHistoryList.displayName = 'StaticHistoryList'; @@ -1078,9 +1277,8 @@ const StreamingTailContent: React.FC<{ StreamingTailContent.displayName = 'StreamingTailContent'; -const MessageList = React.forwardRef(({ +const MessageList = React.forwardRef(({ sessionKey, - disableStaging = false, messages, sessionIsWorking = false, activeStreamingMessageId = null, @@ -1088,7 +1286,6 @@ const MessageList = React.forwardRef(({ retryOverlay = null, onMessageContentChange, getAnimationHandlers, - isLoadingOlder, scrollToBottom, scrollRef, directory, @@ -1176,7 +1373,6 @@ const MessageList = React.forwardRef(({ }), [messages]); const historyContentRef = React.useRef(null); - const historyVirtualizerRef = React.useRef(null); const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => { if (scrollRef?.current) { return scrollRef.current; @@ -1278,41 +1474,14 @@ const MessageList = React.forwardRef(({ } const historyEntries = staticRenderEntries; - // Virtua hides unmeasured items until ResizeObserver reports their height. - // Mobile momentum scrolling can outrun that measurement and expose blank - // reserved rows, so keep the constrained mobile history mounted normally. - const shouldVirtualizeHistory = !isMobileSurfaceRuntime() && historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; - const historyEntryKeys = React.useMemo(() => historyEntries.map((entry) => entry.key), [historyEntries]); - const virtualCache = React.useMemo( - () => (shouldVirtualizeHistory ? readTimelineCache(sessionKey, historyEntryKeys) : undefined), - [historyEntryKeys, sessionKey, shouldVirtualizeHistory], - ); - const virtualCacheSessionRef = React.useRef(sessionKey); - const virtualCacheKeysRef = React.useRef(historyEntryKeys); - const setHistoryVirtualizer = React.useCallback((handle: VirtualizerHandle | null) => { - if (!handle) { - writeTimelineCache( - virtualCacheSessionRef.current, - virtualCacheKeysRef.current, - historyVirtualizerRef.current, - ); - historyVirtualizerRef.current = null; - return; - } - - historyVirtualizerRef.current = handle; - }, []); - - React.useEffect(() => { - virtualCacheSessionRef.current = sessionKey; - virtualCacheKeysRef.current = historyEntryKeys; - }, [historyEntryKeys, sessionKey]); - - React.useEffect(() => { - const virtualizerForCleanup = historyVirtualizerRef.current; - return () => { - writeTimelineCache(virtualCacheSessionRef.current, virtualCacheKeysRef.current, virtualizerForCleanup); - }; + // All surfaces virtualize with @tanstack/react-virtual (see the engine + // note at the top of the file). An unvirtualized list is kept only for + // tiny histories where windowing overhead is not worth it. + const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD; + const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none'; + const tanstackVirtualizerRef = React.useRef(null); + const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => { + tanstackVirtualizerRef.current = virtualizer; }, []); const allEntries = React.useMemo(() => { @@ -1410,16 +1579,20 @@ const MessageList = React.forwardRef(({ }, [resolveScrollContainer]); const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => { - if (!shouldVirtualizeHistory || index < 0 || index >= historyEntries.length) { + if (index < 0 || index >= historyEntries.length) { + return false; + } + + if (!shouldVirtualizeHistory) { return false; } - const virtualizer = historyVirtualizerRef.current; + const virtualizer = tanstackVirtualizerRef.current; if (!virtualizer) { return false; } - virtualizer.scrollToIndex(index, { align: 'start', smooth: behavior === 'smooth' }); + virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' }); return true; }, [historyEntries.length, shouldVirtualizeHistory]); @@ -1487,6 +1660,47 @@ const MessageList = React.forwardRef(({ ); }, + holdViewportAnchor: (anchor) => { + const container = resolveScrollContainer(); + if (!container || typeof window === 'undefined') { + return; + } + + let frames = 0; + let stable = 0; + let cancelled = false; + const cancelOnUserInput = () => { + cancelled = true; + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + }; + container.addEventListener('touchstart', cancelOnUserInput, { passive: true }); + container.addEventListener('wheel', cancelOnUserInput, { passive: true }); + const step = () => { + if (cancelled) return; + const element = findMessageElement(anchor.messageId); + if (element) { + const delta = element.getBoundingClientRect().top + - container.getBoundingClientRect().top + - anchor.offsetTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + stable = 0; + } else { + stable += 1; + } + } + frames += 1; + if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) { + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + return; + } + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); + }, + isHistoryVirtualized: () => shouldVirtualizeHistory, captureViewportAnchor: () => { @@ -1559,8 +1773,8 @@ const MessageList = React.forwardRef(({ }, scrollToBottom: () => { - if (shouldVirtualizeHistory && historyEntries.length > 0) { - historyVirtualizerRef.current?.scrollToIndex(historyEntries.length - 1, { align: 'end' }); + if (shouldVirtualizeHistory && historyEntries.length > 0 && tanstackVirtualizerRef.current) { + tanstackVirtualizerRef.current.scrollToEnd(); return; } const container = resolveScrollContainer(); @@ -1589,27 +1803,32 @@ const MessageList = React.forwardRef(({
- + {/* Virtualized history rows unmount/remount during scroll; + re-running the reveal fade on every remount reads as + blinking. History content is never "new", so fade-in + is disabled there — the streaming tail keeps it. */} + + + {trailingStreamingEntry ? ( { - if (!isMobileSurfaceRuntime()) { - return HISTORY_SCROLL_THRESHOLD - } - return Math.max( - MOBILE_HISTORY_SCROLL_THRESHOLD_MIN, - clientHeight * MOBILE_HISTORY_SCROLL_VIEWPORT_FACTOR, - ) -} const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -544,7 +526,10 @@ export const useChatTimelineController = ({ return true; }; - if (isMobileSurfaceRuntime() && heightDelta > 0) { + // Non-virtualized mobile list only: fight iOS momentum manually. + // The virtualized mobile list (tanstack) defers prepend adjustments + // through touch/momentum in core, so manual writes would double up. + if (isMobileSurfaceRuntime() && !historyVirtualized && heightDelta > 0) { setScrollTopDefeatingMomentum(container, snap.top + heightDelta); updateTracking(); return; @@ -554,13 +539,21 @@ export const useChatTimelineController = ({ // restoreViewportAnchor which falls back to virtualizer-aware // scrollHistoryIndexIntoView when the element is not in the DOM. // Note: an unchanged scrollTop after restore is NOT a failure here — - // the virtualized desktop list runs with virtua `shift`, which - // compensates the prepend internally, so staying near snap.top is - // the correct outcome. + // the virtualized list compensates the prepend internally, so + // staying near snap.top is the correct outcome. if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) { // Fallback: height-delta compensation applyHeightDelta(); } + if (historyVirtualized && snap.anchor && isMobileSurfaceRuntime()) { + // Mobile only: freshly prepended rows keep re-measuring for a + // few frames and each pass can shift content, so hold the + // anchor until it settles. Desktop must NOT run this — wheel + // scrolling during the hold would fight the re-assertions and + // read as a frozen scroll; the virtualizer's own anchoring is + // enough there. + messageListRef.current?.holdViewportAnchor(snap.anchor); + } } else if (isPrepend && prev && !historyVirtualized) { // Released viewport: preserve the read position by compensating for the // exact height the prepend added above, with no intermediate frame for @@ -690,10 +683,16 @@ export const useChatTimelineController = ({ }, [beginHistoryInteraction, fetchOlderHistory, releaseAutoFollow, settleHistoryInteraction]); const handleHistoryScroll = React.useCallback(() => { + // Mobile never loads history from scroll position: any prepend racing + // an active touch gesture can be hijacked by the native scroll + // animation. The user scrolls to the natural top and taps an explicit + // "load older" button instead — the insert then happens from a resting + // state, which is fully deterministic. + if (isMobileSurfaceRuntime()) return; const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; + if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 18375b2bdf..65f299cc5a 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1279,6 +1279,7 @@ export const dict = { 'diffView.reviewDialog.actions.starting': 'Starting...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', + 'chat.history.loadOlder': 'Load older messages', 'chat.autoReview.title': 'Code review loop is running', 'chat.autoReview.status.waitingForReviewer': 'Waiting for reviewer', 'chat.autoReview.status.waitingForImplementer': 'Waiting for implementer', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index d1a9300516..31f6311a29 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Iniciando...', 'diffView.reviewDialog.toast.noSessionDirectory': 'El directorio de la sesión no está disponible', 'diffView.reviewDialog.toast.startFailed': 'No se pudo iniciar el flujo de revisión', + 'chat.history.loadOlder': 'Cargar mensajes anteriores', 'chat.autoReview.title': 'El ciclo de revisión de código está en curso', 'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor', 'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index c4eb7f12d7..ac7833a2cb 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1113,6 +1113,7 @@ export const dict = { 'diffView.reviewDialog.actions.starting': 'Démarrage...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible', 'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue', + 'chat.history.loadOlder': 'Charger les messages précédents', 'chat.autoReview.title': 'La boucle de revue de code est en cours', 'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer', 'chat.autoReview.status.waitingForImplementer': 'En attente de l’implémenteur', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 306ae0fe7e..e10957382e 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1284,6 +1284,7 @@ export const dict: Record = { 'diffView.hunk.discardTitle': 'ハンク{index}を破棄', 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', + 'chat.history.loadOlder': '以前のメッセージを読み込む', 'chat.autoReview.title': 'コードレビューループが実行中です', 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d329885d26..440c2b8446 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1282,6 +1282,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': '시작 중...', 'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다', 'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다', + 'chat.history.loadOlder': '이전 메시지 불러오기', 'chat.autoReview.title': '코드 리뷰 루프 실행 중', 'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중', 'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 30cd07a15b..e4c6cb2d8b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1481,6 +1481,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Uruchamianie...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny', 'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review', + 'chat.history.loadOlder': 'Wczytaj starsze wiadomości', 'chat.autoReview.title': 'Pętla code review trwa', 'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera', 'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index bcbf1aed3d..e48993b2b6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Iniciando...', 'diffView.reviewDialog.toast.noSessionDirectory': 'O diretório da sessão está indisponível', 'diffView.reviewDialog.toast.startFailed': 'Falha ao iniciar o fluxo de revisão', + 'chat.history.loadOlder': 'Carregar mensagens anteriores', 'chat.autoReview.title': 'O ciclo de revisão de código está em andamento', 'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor', 'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 38414a0aa5..9e30739ab9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Запуск...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Директорія сесії недоступна', 'diffView.reviewDialog.toast.startFailed': 'Не вдалося запустити review flow', + 'chat.history.loadOlder': 'Завантажити ще', 'chat.autoReview.title': 'Цикл код-ревʼю триває', 'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера', 'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 09ad583343..9956d237f4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1245,6 +1245,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Starting...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', + 'chat.history.loadOlder': '加载更早的消息', 'chat.autoReview.title': '代码审查循环正在运行', 'chat.autoReview.status.waitingForReviewer': '等待审查者', 'chat.autoReview.status.waitingForImplementer': '等待实现者', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 34cdf13410..68633d5c86 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1255,6 +1255,7 @@ export const dict: Record = { 'diffView.reviewDialog.actions.starting': 'Starting...', 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', + 'chat.history.loadOlder': '載入更早的訊息', 'chat.autoReview.title': '程式碼審查循環執行中', 'chat.autoReview.status.waitingForReviewer': '等待審查者', 'chat.autoReview.status.waitingForImplementer': '等待實作者', diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 6271ffde1a..4f741d68d0 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -503,11 +503,15 @@ export function useSync() { shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(), ]) - // Progressive mount on desktop: after the initial page resolves, if the - // session isn't stale and the server indicated more messages, dispatch a - // second fetch to prepend older history. Mobile avoids this background - // prepend because adding rows after first paint on a narrow viewport can - // visibly shift the timeline; user scroll still loads older history. + // Progressive mount (desktop/VS Code): after the initial page + // resolves, if the session isn't stale and the server indicated more + // messages, dispatch a second fetch to prepend older history — it + // gives the scroll container headroom so the scroll-up trigger fires + // seamlessly. Mobile deliberately opts out: it has no scroll-position + // trigger at all — ALL older history loads happen through the + // explicit "load older" button at the top, so every prepend lands + // from a resting state the user initiated. (The initial page itself, + // including the turn-boundary extension, is unaffected.) if (!isStale() && !isMobileSurfaceRuntime()) { const currentMeta = getMetaFor(sessionID) if (currentMeta.cursor && !currentMeta.complete) { diff --git a/patches/@tanstack%2Fvirtual-core@3.17.3.patch b/patches/@tanstack%2Fvirtual-core@3.17.3.patch new file mode 100644 index 0000000000..86e09c7b80 --- /dev/null +++ b/patches/@tanstack%2Fvirtual-core@3.17.3.patch @@ -0,0 +1,36 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index 52ae6ca12f8d1c650ee7f1bd55573ee7d4f8b65f..bcee09df7377c37ffb220606b741c9ff434b3470 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -723,10 +723,12 @@ class Virtualizer { + this.range = null; + return null; + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 3032c0ca457582be3f47923cba1f7d92c848745c..90b574881a073aabac99c075f7eab0a8f363fff6 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -721,10 +721,12 @@ class Virtualizer { + this.range = null; + return null; + } ++ const maxScrollOffset = Math.max(this.getTotalSize() - outerSize, 0); ++ const effectiveScrollOffset = Math.min(Math.max(scrollOffset, 0), maxScrollOffset); + this.range = calculateRangeImpl( + measurements, + outerSize, +- scrollOffset, ++ effectiveScrollOffset, + lanes, + // Pass the typed array so binary search + forward-walk can read + // start/end directly from Float64Array, skipping the Proxy traps. From 1c77fc23a8a7aa1cedbb664534c310fb07f8b451 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 3 Jul 2026 18:44:10 +0300 Subject: [PATCH 54/88] feat(ui): unify list virtualization on @tanstack/react-virtual and polish scroll behavior - Migrate sidebar session groups, git changes panel, virtualized code blocks, and JSON tree viewer from virtua to @tanstack/react-virtual; virtua remains only inside the Pierre diff viewer integration - Sidebar: preserve scroll position when virtualization enables mid-session (enable only once the ancestor scroll element is resolved, seed initial offset from its live scrollTop, render plain rows for the single pre-paint frame); disable native scroll anchoring on the sessions scroller; keep row spacing identical between plain and virtualized modes; absolute row positioning so variable-height rows cannot drift past the container - Chat: expand tool/thinking blocks downward by only adjusting scroll for rows growing above the viewport; raise the desktop history-load lead to 1.5 viewports so prepends land above the visible area - Git changes: compute the prefetch window from the first visible row, skipping overscan rows above the viewport - Sidebar rows: make the whole highlighted row area clickable, guarded against double-firing from interactive children --- .../ui/src/components/chat/MessageList.tsx | 17 +++- .../chat/hooks/useChatTimelineController.ts | 14 ++- .../message/parts/VirtualizedCodeBlock.tsx | 49 ++++++---- .../session/sidebar/SessionGroupSection.tsx | 93 ++++++++++++++++--- .../session/sidebar/SessionNodeItem.tsx | 15 ++- .../session/sidebar/SidebarProjectsList.tsx | 7 +- .../ui/src/components/ui/JsonTreeViewer.tsx | 45 +++++---- .../src/components/views/git/ChangesPanel.tsx | 81 +++++++++------- 8 files changed, 234 insertions(+), 87 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index bc45683d5e..dbac2f5089 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -50,7 +50,7 @@ const TANSTACK_MOBILE_OVERSCAN = 16; const resolveTanstackOverscan = (): number => ( isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN ); -// Post-prepend anchor hold (upstream parity): measurements of freshly +// Post-prepend anchor hold: measurements of freshly // prepended rows settle over multiple frames, so a single restore can be // invalidated by the next measurement pass. Re-assert the anchor until it // holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES. @@ -61,6 +61,8 @@ const ANCHOR_HOLD_MAX_FRAMES = 180; const TANSTACK_ESTIMATE_MIN_SAMPLES = 5; const TANSTACK_ESTIMATE_MIN = 120; const TANSTACK_ESTIMATE_MAX = 1200; +// "At bottom" tolerance for resize-adjustment decisions. +const TANSTACK_AT_END_THRESHOLD_PX = 80; // Quiet-window prepend on mobile: while a touch drag or momentum scroll is // active, iOS owns the scroll position and ANY geometry change above the @@ -1098,7 +1100,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, scrollToFn: (offset, options, instance) => { // Expose the new total height before core writes an anchor // correction so the browser does not clamp the offset to the old - // height (upstream parity). + // height. const sizeElement = sizeContainerRef.current; if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`; elementScroll(offset, options, instance); @@ -1111,6 +1113,17 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef, initialOffset: () => Number.MAX_SAFE_INTEGER, initialMeasurementsCache: initialMeasurements, }); + // Only compensate scroll for rows growing ABOVE the viewport (history + // remeasures, prepended pages). A row growing inside the viewport — + // expanding a tool call or thinking block — must grow DOWNWARD naturally; + // the end-anchored default made it expand upward. At the bottom, + // app-level auto-follow owns pinning, so skip there too instead of + // double-writing. (This is an instance field, not a constructor option.) + tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { + if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false; + const firstVisibleIndex = instance.range?.startIndex; + return firstVisibleIndex !== undefined && item.index < firstVisibleIndex; + }; React.useEffect(() => { if (!isTanstack) return; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 8112d62b55..2926ca6679 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -59,7 +59,17 @@ export interface UseChatTimelineControllerResult { } const TURN_MODEL_CACHE_MAX = 30 -const HISTORY_SCROLL_THRESHOLD = 200 +// Desktop load-older lead distance. Trigger well before the top: the fetch +// then completes and the prepend lands ABOVE the viewport, where key-anchored +// compensation is exact and invisible. A short lead (the old 200px) let the +// user reach the estimated-height region near the absolute top mid-fetch, +// where the post-insert restore is least precise and reads as a small jump. +const HISTORY_SCROLL_THRESHOLD_MIN_PX = 1200 +const HISTORY_SCROLL_VIEWPORT_FACTOR = 1.5 +const resolveHistoryScrollThreshold = (clientHeight: number): number => Math.max( + HISTORY_SCROLL_THRESHOLD_MIN_PX, + clientHeight * HISTORY_SCROLL_VIEWPORT_FACTOR, +) const VSCODE_TURN_MODEL_CACHE_MAX = 4 const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30 const MOBILE_TURN_MODEL_CACHE_MAX = 4 @@ -692,7 +702,7 @@ export const useChatTimelineController = ({ const container = scrollRef.current; if (!container) return; if (isPinnedRef.current) return; - if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return; + if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return; if (!historySignalsRef.current.canLoadEarlier) return; if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return; diff --git a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx index 028f889a94..11e7763f66 100644 --- a/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/VirtualizedCodeBlock.tsx @@ -3,7 +3,7 @@ * * Renders large code/read outputs without mounting one highlighter per line: * 1. ONE worker tokenization of the whole block (off the main thread) - * 2. virtua to only render visible rows + * 2. @tanstack/react-virtual to only render visible rows * * Tokenizing the whole block at once also preserves cross-line syntax context * (multi-line strings/comments) that per-line highlighting loses. Colors resolve @@ -11,7 +11,7 @@ */ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme'; import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines'; @@ -114,28 +114,41 @@ const VirtualizedRows: React.FC = React.memo(({ const parentRef = React.useRef(null); const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`; + const virtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + }); + const virtualItems = virtualizer.getVirtualItems(); + return (
- - {(line, index) => ( - - )} - +
+ {virtualItems.map((item) => { + const line = lines[item.index]; + if (!line) return null; + return ( +
+ +
+ ); + })} +
); }); diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 1e854bca9f..3eb58a2bf3 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import type { Session } from '@opencode-ai/sdk/v2'; // Archived buckets routinely grow into the hundreds/thousands; virtualize @@ -581,7 +581,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { }); const archivedVirtualContainerRef = React.useRef(null); - const archivedScrollRef = React.useRef(null); const [archivedScrollEl, setArchivedScrollEl] = React.useState(null); // Offset of the virtual container from the scroll element's content origin. // virtua reads startMargin from Virtualizer options and uses it @@ -617,7 +616,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { React.useLayoutEffect(() => { if (!shouldVirtualize) { if (archivedScrollEl !== null) setArchivedScrollEl(null); - archivedScrollRef.current = null; if (archivedScrollMargin !== 0) setArchivedScrollMargin(0); return; } @@ -632,7 +630,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { if (providedScrollEl && providedScrollEl.contains(container)) { scrollEl = providedScrollEl; if (scrollEl !== archivedScrollEl) { - archivedScrollRef.current = scrollEl; setArchivedScrollEl(scrollEl); return; } @@ -649,7 +646,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { el = el.parentElement; } if (scrollEl !== archivedScrollEl) { - archivedScrollRef.current = scrollEl; setArchivedScrollEl(scrollEl); return; } @@ -661,6 +657,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset)); }); + // The scroll element is an ANCESTOR of this section (the sidebar's + // ScrollableOverlay), so scrollMargin translates its scrollTop into + // container-relative coordinates — the tanstack equivalent of virtua's + // startMargin this replaces. + // Enable ONLY once the ancestor scroll element is resolved. While the + // virtualizer is disabled the core resets its cached scroll offset, so the + // first enabled read takes initialOffset() from the LIVE scrollTop below — + // making the core's attach-time scrollTo target the current position (a + // visual no-op) instead of a stale 0 that reset the sidebar to the top. + // The core only learns the offset from scroll events after that, so this + // initial seeding is what makes the first render window correct too. + const virtualizerReady = shouldVirtualize && archivedScrollEl !== null; + const sessionVirtualizer = useVirtualizer({ + count: visibleSessions.length, + enabled: virtualizerReady, + getScrollElement: () => archivedScrollEl, + initialOffset: () => archivedScrollEl?.scrollTop ?? 0, + estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX, + // Expanded parents render children inline and dwarf the row estimate; + // widen the window so their extra height stays covered. + overscan: hasExpandedParent ? 20 : 8, + scrollMargin: archivedScrollMargin, + getItemKey: (index) => visibleSessions[index]?.session.id ?? index, + }); + // Hooks below MUST stay above the search-empty early-return so they // fire in the same order every render — rules-of-hooks. const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => { @@ -913,21 +934,63 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {renderFolderItems()} {shouldVirtualize ? (
- - {(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { + {!virtualizerReady ? ( + // At most one pre-paint frame: this wrapper must exist for the + // layout effect to resolve the ancestor scroll element, which + // re-renders synchronously before paint. Rendering the plain rows + // meanwhile keeps the container's height real so the scroller + // never collapses/clamps during the flip. + visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor, - }) as React.ReactElement} - + })) + ) : ( +
+ {/* Absolutely positioned rows (canonical tanstack layout): with + variable-height rows, flow-stacking can drift from the computed + total height until measurements settle and overlap the content + below the group. Per-item offsets cannot drift. item.start + includes scrollMargin (ancestor-scroll offset), so subtract it. */} + {sessionVirtualizer.getVirtualItems().map((item) => { + const node = visibleSessions[item.index]; + if (!node) return null; + return ( +
+ {renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { + subtreeContainsActive, + subtreeContainsEditing, + menuOpenSessionId, + nodeStructureKey: resolveNodeStructureKey(node), + childRenderExtrasFor, + })} +
+ ); + })} +
+ )}
) : ( visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', { diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 89dc16201e..3fad43f604 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -747,6 +747,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { handleSessionSelect(session.id, sessionDirectory, projectId); }; + // The selection/active highlight covers the WHOLE row box (gutter, edge + // paddings), while the primary click target is the inner title button. + // Make the rest of the highlighted box clickable too — but only for clicks + // that did not originate from an interactive child (title button, chevron, + // action menu), so nothing double-fires. + const handleRowBackgroundClick = (event: React.MouseEvent) => { + if (event.defaultPrevented) return; + const target = event.target as HTMLElement | null; + if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return; + handleRowSelect(event as unknown as React.MouseEvent); + }; + const handleRowMouseDown = (event: React.MouseEvent) => { if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) { suppressNextSelectRef.current = true; @@ -974,8 +986,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { data-session-row={session.id} data-session-scope={sessionDirectory ?? ''} data-session-archived={archivedBucket ? '1' : '0'} + onClick={handleRowBackgroundClick} className={cn( - 'group relative my-0.5 flex items-center rounded-md py-1 pr-1.5', + 'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5', // Pull the row box left into the container gutter so the // selection highlight covers the chevron/status markers // (which sit in that gutter), then re-pad so the title text diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index c6b94eef43..849987b045 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -142,7 +142,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode { } return ( - + // [overflow-anchor:none] — the browser's native scroll anchoring otherwise + // latches onto content BELOW a growing session group (e.g. the "Show more" + // button) and holds it in place, which makes newly revealed sessions look + // like they insert upward. With anchoring off, scrollTop stays put and new + // rows appear below naturally. + {props.topContent} {props.showOnlyMainWorkspace ? (
diff --git a/packages/ui/src/components/ui/JsonTreeViewer.tsx b/packages/ui/src/components/ui/JsonTreeViewer.tsx index 9110e5af04..3712328d22 100644 --- a/packages/ui/src/components/ui/JsonTreeViewer.tsx +++ b/packages/ui/src/components/ui/JsonTreeViewer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { parseJsonToTree, @@ -203,6 +203,14 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD; const parentRef = React.useRef(null); + const virtualizer = useVirtualizer({ + count: flatNodes.length, + enabled: shouldVirtualize, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + getItemKey: (index) => flatNodes[index]?.node.id ?? index, + }); const handleToggle = React.useCallback((id: string) => { setCollapsedPaths((prev) => { @@ -238,21 +246,26 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: () className={className} style={{ maxHeight, overflow: 'auto' }} > - - {(flatNode) => ( - - )} - +
+ {virtualizer.getVirtualItems().map((item) => { + const flatNode = flatNodes[item.index]; + if (!flatNode) return null; + return ( +
+ +
+ ); + })} +
); } diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx index 51a549d374..23a481056f 100644 --- a/packages/ui/src/components/views/git/ChangesPanel.tsx +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Virtualizer, type VirtualizerHandle } from 'virtua'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -195,16 +195,26 @@ export const ChangesPanel: React.FC = ({ const rowCount = rows.length; const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD; - const rowVirtualizerRef = React.useRef(null); - const [visibleStartIndex, setVisibleStartIndex] = React.useState(0); - - const updateVisibleStartIndex = React.useCallback((offset: number) => { - const virtualizer = rowVirtualizerRef.current; - const next = virtualizer - ? virtualizer.findItemIndex(offset) - : Math.floor(offset / CHANGE_ROW_ESTIMATE_PX); - setVisibleStartIndex((previous) => (previous === next ? previous : next)); - }, []); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + enabled: shouldVirtualize, + getScrollElement: () => scrollRef.current, + estimateSize: () => CHANGE_ROW_ESTIMATE_PX, + overscan: 12, + getItemKey: (index) => rows[index]?.key ?? index, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + // First VISIBLE row index drives the visible-path prefetch window (the + // virtua findItemIndex/onScroll pair this replaces). virtualRows starts at + // the overscan boundary — up to `overscan` rows above the viewport — so + // skip rows that end above the current scroll offset; otherwise the + // prefetch budget leaks to offscreen files above the viewport. + const visibleStartIndex = React.useMemo(() => { + if (!shouldVirtualize) return 0; + const scrollTop = scrollRef.current?.scrollTop ?? 0; + const firstVisible = virtualRows.find((item) => item.end > scrollTop); + return firstVisible?.index ?? 0; + }, [shouldVirtualize, virtualRows, scrollRef]); React.useEffect(() => { if (!onVisiblePathsChange) { @@ -481,27 +491,34 @@ export const ChangesPanel: React.FC = ({ className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto" > {shouldVirtualize ? ( - - {(row, index) => ( -
- {renderRow(row, index === 0)} -
- )} -
+
+ {/* Absolutely positioned rows: variable-height rows can drift from + the computed total height under flow stacking until measured. */} + {virtualRows.map((item) => { + const row = rows[item.index]; + if (!row) return null; + return ( +
+ {renderRow(row, item.index === 0)} +
+ ); + })} +
) : (
{rows.map((row, index) => ( From 74167285708f10d9a41843ce0722ca779889a0ea Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 4 Jul 2026 02:48:07 +0300 Subject: [PATCH 55/88] feat(voice): first-class voice input and local TTS across web, desktop, and mobile (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rebuild of voice input on a server-authoritative streaming architecture, replacing the legacy Web Speech / whole-blob / WASM engines and the dead voice-agent layer (~4k lines removed). Speech-to-text (dictation): - Client streams 16 kHz mono PCM16 chunks over /api/dictation/ws with seq/ack ordering; buffered audio is retained and replayed on reconnect - Server transcribes and streams live partial transcripts back; segments auto-commit every ~15s with silence suppression and adaptive finalization timeouts - Local provider (default, zero config): sherpa-onnx models in a forked worker process — auto-download with progress, staged extraction with verification, corrupt-model auto-recovery, idle shutdown after 5 min - Model catalog with settings picker (accuracy/speed ratings, sizes, download/delete): Parakeet TDT v2 (English) and v3 (25 European languages, auto-detected), Whisper base and tiny (multilingual, light) - OpenAI-compatible provider for any Whisper endpoint - Composer overlay with live transcript, volume meter, timer, and cancel / insert / insert-and-send actions; failed transcriptions keep their audio for retry or accepting the partial text as-is - Configurable keyboard shortcut (default mod+alt+v) toggles dictation; Enter confirms and Escape cancels while recording - Overlay is pixel-aligned with the composer (measured footer height, matching paddings/typography/gaps) — no layout shift when toggling Text-to-speech: - Local Kokoro provider (English, 11 voices) synthesized in the same worker via /api/dictation/tts/speak, managed by the shared model pipeline; sentence-pipelined playback keeps time-to-first-audio at ~1 sentence regardless of message length, and stop cancels in-flight synthesis - Sanitizer keeps inline-code content (strips backticks only), reads interword slashes aloud, and removes only absolute file paths Settings: - Voice page unified: a single read-aloud toggle owns all playback options (the confusing "Enable Voice Mode" is gone); a new "Enable voice input" toggle (default on, persisted to settings.json) hides the composer mic entirely when disabled Mobile and transport: - iOS/Android microphone permissions added (dictation was previously impossible on mobile) - Fixed Android WebSocket upgrades: the Capacitor WebView origin (https://localhost) was missing from the packaged-client allowlist, 403-ing every WS connection — root cause of the old mobile SSE lock, which is now removed for all transports Security and conventions: - All HTTP routes sit behind the global /api auth gate; the WS upgrade explicitly validates the UI session and origin, with oc_url_token narrowly allowlisted and covered by tests; the dictation socket mints a fresh URL token before connecting - Routes register before the generic OpenCode proxy; the client goes through runtimeFetch/getRuntimeUrlResolver, and runtime switches reset the dictation socket - VS Code deliberately reports dictation as unavailable (no server process in that runtime) CI: workflow Node bumped 20 -> 22 to match the repo engines and fix better-sqlite3 installs broken by node-gyp@latest on Node 20. New dependency: sherpa-onnx-node (prebuilt N-API; macOS/Linux x64+arm64, Windows x64 — Windows-on-ARM falls back to the OpenAI-compatible provider) --- .github/workflows/build-macos-arm64-dmg.yml | 2 +- .github/workflows/oc-review.yml | 2 +- .github/workflows/release-desktop-smoke.yml | 4 +- .github/workflows/release.yml | 8 +- .github/workflows/vscode-extension.yml | 2 +- .gitignore | 1 + bun.lock | 15 + .../android/app/src/main/AndroidManifest.xml | 6 + packages/mobile/ios/App/App/Info.plist | 2 + packages/ui/src/App.tsx | 7 +- packages/ui/src/components/chat/ChatInput.tsx | 57 +- .../dictation/ComposerDictation.tsx | 439 ++ .../openchamber/OpenChamberVisualSettings.tsx | 7 +- .../sections/openchamber/VoiceSettings.tsx | 728 ++- .../ui/src/components/views/SettingsView.tsx | 2 +- .../components/voice/BrowserVoiceButton.tsx | 392 -- .../ui/src/components/voice/VoiceProvider.tsx | 30 - .../components/voice/VoiceStatusIndicator.tsx | 139 - packages/ui/src/components/voice/index.ts | 2 - packages/ui/src/hooks/useBrowserVoice.ts | 1007 ---- packages/ui/src/hooks/useDictation.ts | 408 ++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 12 + packages/ui/src/hooks/useLocalTTS.ts | 266 + packages/ui/src/hooks/useMessageTTS.ts | 15 +- packages/ui/src/hooks/useVoiceContext.ts | 57 - packages/ui/src/lib/desktop.ts | 8 +- .../ui/src/lib/dictation/dictation-client.ts | 447 ++ .../lib/dictation/dictation-stream-sender.ts | 240 + .../dictation/use-dictation-audio-source.ts | 301 + .../ui/src/lib/i18n/messages/en.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/en.ts | 12 + .../ui/src/lib/i18n/messages/es.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/es.ts | 12 + .../ui/src/lib/i18n/messages/fr.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/fr.ts | 12 + .../ui/src/lib/i18n/messages/ja.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/ja.ts | 12 + .../ui/src/lib/i18n/messages/ko.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/ko.ts | 12 + .../ui/src/lib/i18n/messages/pl.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/pl.ts | 12 + .../src/lib/i18n/messages/pt-BR.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 12 + .../ui/src/lib/i18n/messages/uk.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/uk.ts | 12 + .../src/lib/i18n/messages/zh-CN.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 12 + .../src/lib/i18n/messages/zh-TW.settings.ts | 23 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 12 + packages/ui/src/lib/persistence.ts | 56 +- packages/ui/src/lib/settings/search.ts | 14 +- packages/ui/src/lib/shortcuts.ts | 7 + .../ui/src/lib/voice/audioStreamService.ts | 397 -- .../ui/src/lib/voice/contextFormatters.ts | 131 - packages/ui/src/lib/voice/index.ts | 17 - packages/ui/src/lib/voice/summarize.ts | 14 +- packages/ui/src/lib/voice/voiceConfig.ts | 32 - packages/ui/src/lib/voice/voiceHooks.ts | 125 - packages/ui/src/lib/voice/voiceSession.ts | 28 - packages/ui/src/lib/voice/wasmSttService.ts | 555 -- packages/ui/src/lib/voice/wasmSttWorker.ts | 92 - packages/ui/src/stores/useConfigStore.ts | 209 +- packages/ui/src/sync/session-ui-store.ts | 1 - packages/ui/src/sync/sync-context.tsx | 5064 ++++++++--------- packages/ui/src/sync/voice-store.ts | 7 - packages/vscode/webview/main.tsx | 17 + packages/web/.gitignore | 1 + packages/web/package.json | 1 + packages/web/server/index.js | 10 + .../web/server/lib/dictation/DOCUMENTATION.md | 63 + packages/web/server/lib/dictation/audio.js | 195 + .../lib/dictation/local/model-catalog.js | 141 + .../lib/dictation/local/model-downloader.js | 163 + .../lib/dictation/local/sherpa-loader.js | 136 + .../lib/dictation/local/sherpa-recognizer.js | 277 + .../server/lib/dictation/local/sherpa-tts.js | 110 + .../lib/dictation/local/worker-client.js | 352 ++ .../lib/dictation/local/worker-process.js | 197 + .../dictation/openai-compatible-session.js | 98 + packages/web/server/lib/dictation/runtime.js | 278 + packages/web/server/lib/dictation/service.js | 302 + .../server/lib/dictation/stream-manager.js | 461 ++ .../lib/dictation/stream-manager.test.js | 194 + .../server/lib/opencode/settings-helpers.js | 27 +- .../lib/opencode/startup-pipeline-runtime.js | 13 + .../server/lib/security/request-security.js | 12 +- .../lib/security/request-security.test.js | 21 + packages/web/server/lib/ui-auth/ui-auth.js | 1 + .../web/server/lib/ui-auth/ui-auth.test.js | 22 + 89 files changed, 8738 insertions(+), 6059 deletions(-) create mode 100644 packages/ui/src/components/dictation/ComposerDictation.tsx delete mode 100644 packages/ui/src/components/voice/BrowserVoiceButton.tsx delete mode 100644 packages/ui/src/components/voice/VoiceProvider.tsx delete mode 100644 packages/ui/src/components/voice/VoiceStatusIndicator.tsx delete mode 100644 packages/ui/src/components/voice/index.ts delete mode 100644 packages/ui/src/hooks/useBrowserVoice.ts create mode 100644 packages/ui/src/hooks/useDictation.ts create mode 100644 packages/ui/src/hooks/useLocalTTS.ts delete mode 100644 packages/ui/src/hooks/useVoiceContext.ts create mode 100644 packages/ui/src/lib/dictation/dictation-client.ts create mode 100644 packages/ui/src/lib/dictation/dictation-stream-sender.ts create mode 100644 packages/ui/src/lib/dictation/use-dictation-audio-source.ts delete mode 100644 packages/ui/src/lib/voice/audioStreamService.ts delete mode 100644 packages/ui/src/lib/voice/contextFormatters.ts delete mode 100644 packages/ui/src/lib/voice/index.ts delete mode 100644 packages/ui/src/lib/voice/voiceConfig.ts delete mode 100644 packages/ui/src/lib/voice/voiceHooks.ts delete mode 100644 packages/ui/src/lib/voice/voiceSession.ts delete mode 100644 packages/ui/src/lib/voice/wasmSttService.ts delete mode 100644 packages/ui/src/lib/voice/wasmSttWorker.ts delete mode 100644 packages/ui/src/sync/voice-store.ts create mode 100644 packages/web/server/lib/dictation/DOCUMENTATION.md create mode 100644 packages/web/server/lib/dictation/audio.js create mode 100644 packages/web/server/lib/dictation/local/model-catalog.js create mode 100644 packages/web/server/lib/dictation/local/model-downloader.js create mode 100644 packages/web/server/lib/dictation/local/sherpa-loader.js create mode 100644 packages/web/server/lib/dictation/local/sherpa-recognizer.js create mode 100644 packages/web/server/lib/dictation/local/sherpa-tts.js create mode 100644 packages/web/server/lib/dictation/local/worker-client.js create mode 100644 packages/web/server/lib/dictation/local/worker-process.js create mode 100644 packages/web/server/lib/dictation/openai-compatible-session.js create mode 100644 packages/web/server/lib/dictation/runtime.js create mode 100644 packages/web/server/lib/dictation/service.js create mode 100644 packages/web/server/lib/dictation/stream-manager.js create mode 100644 packages/web/server/lib/dictation/stream-manager.test.js diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index 194795388c..a96c1da87f 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "20" + node-version: "22" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml index 65e490948e..655b666595 100644 --- a/.github/workflows/oc-review.yml +++ b/.github/workflows/oc-review.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 7686eb7cce..e81407d8ed 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -65,7 +65,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -206,7 +206,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68390b3fe3..301484269b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' registry-url: 'https://registry.npmjs.org' - name: Install dependencies @@ -141,7 +141,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -288,7 +288,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -365,7 +365,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Download per-arch latest-mac.yml uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 566549c12d..ef9ac3a2a5 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 305aec4c6c..8a344a6b32 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ data/ workspaces/ *.pid .worktrees/ +test-results/ diff --git a/bun.lock b/bun.lock index 37335213a5..1e4d7467d2 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,7 @@ "openai": "^4.79.0", "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.2.2", + "sherpa-onnx-node": "1.12.28", "simple-git": "^3.28.0", "web-push": "^3.6.7", "ws": "^8.18.3", @@ -2980,6 +2981,20 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-TVQ35g7JIpDPB1lUDdcog+JtI0cI45ZzOnvHXm0DtWs/dgxnJXtWMY3uLRtBbLnysV9j5ljffwZ1IX9VDHsCzQ=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-uDtZkkoP6QQ/3DHOscCpEZ2WpaiHUQsDpbyYaHURrJ7DbsjqGnS6G8l+R589Ro5Bf282QElzBy3okwxXbt3Kxw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-OFVK0GYwKwKNsjxbPmfcLQm/dfA0IwAoiIQJ96s+eFYcDqhlapcY06ocdb7SNluGBcM7xgU5jEW2QXBkMIOEvQ=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.12.28", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.12.28", "sherpa-onnx-darwin-x64": "^1.12.28", "sherpa-onnx-linux-arm64": "^1.12.28", "sherpa-onnx-linux-x64": "^1.12.28", "sherpa-onnx-win-ia32": "^1.12.28", "sherpa-onnx-win-x64": "^1.12.28" } }, "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-VDZh1M7Ccx/bkP3WwBCFoJzwAwq+b5nR1KRkYRz5p1w5bfhzfa3ACBGr7vpUt5AGUge4qSLe0MSKXyKtSmy1uA=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ZQzcSmFvZK4jzmtWckqxocDUuEjYnBV2MHrDD21HPTeUMfGdE9yfvuSPpesIVfdzKbQzIQY42RAcfZEGWu0FbQ=="], + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index 0229e44375..9ef6ac5cd1 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -53,4 +53,10 @@ + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist index 3ffde0d620..2d96c87434 100644 --- a/packages/mobile/ios/App/App/Info.plist +++ b/packages/mobile/ios/App/App/Info.plist @@ -35,6 +35,8 @@ OpenChamber connects to OpenChamber servers on your local network. NSCameraUsageDescription OpenChamber uses the camera to scan a server's pairing QR code. + NSMicrophoneUsageDescription + OpenChamber uses the microphone for voice dictation in the chat composer. CFBundleURLTypes diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 842e837230..9159a9e00a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -44,7 +44,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { VoiceProvider } from '@/components/voice'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; @@ -928,8 +927,8 @@ function App({ apis }: AppProps) { } // Always mount the full provider tree to avoid remounts when isInitialized - // flips from false → true. FireworksProvider and VoiceProvider are lightweight - // shells; their heavy children are only activated when actually needed. + // flips from false → true. FireworksProvider is a lightweight shell; its + // heavy children are only activated when actually needed. const isBootShell = !isInitialized && !isDesktopRuntime; return ( @@ -937,7 +936,6 @@ function App({ apis }: AppProps) { -
@@ -955,7 +953,6 @@ function App({ apis }: AppProps) { )}
-
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index be6bd1334b..3d32a65792 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Textarea } from '@/components/ui/textarea'; -import { BrowserVoiceButton } from '@/components/voice'; +import { ComposerDictation } from '@/components/dictation/ComposerDictation'; // sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -381,7 +381,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = }; const MemoModelControls = React.memo(ModelControls); -const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton); +const MemoComposerDictation = React.memo(ComposerDictation); const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileModelButton = React.memo(MobileModelButton); const MemoStatusRow = React.memo(StatusRow); @@ -2318,6 +2318,33 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo void handleSubmitRef.current(); }, []); + // Dictation: insert the transcript inline; optionally submit immediately. + // getCurrentInputSnapshot reads textareaRef.current.value first, so setting + // it synchronously lets handleSubmit pick up the text in the same tick. + const handleDictationInsert = React.useCallback((text: string) => { + setMessage((prev) => { + const next = appendInlineText(prev, text); + const textarea = textareaRef.current; + if (textarea) { + textarea.value = next; + } + return next; + }); + setTimeout(() => { + textareaRef.current?.focus(); + }, 0); + }, []); + + const handleDictationInsertAndSend = React.useCallback((text: string) => { + const textarea = textareaRef.current; + const next = appendInlineText(textarea?.value ?? messageRef.current, text); + if (textarea) { + textarea.value = next; + } + setMessage(next); + void handleSubmitRef.current(); + }, []); + // Preset chips rendered outside this component (e.g. under the welcome // message on narrow surfaces) request a submit via the input store; consume // it here so it routes through the same command-aware submit path. @@ -4420,6 +4447,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo : undefined} /> )} + {/* Positioning context for the dictation overlay: covers the + text area + footer exactly, excluding MobileSessionStatusBar. */} +
@@ -4559,7 +4589,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo />
- + = ({ onOpenSettings, scrollTo
- + = ({ onOpenSettings, scrollTo )}
+
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */} {isMobile && } diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx new file mode 100644 index 0000000000..7f3fd91340 --- /dev/null +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -0,0 +1,439 @@ +/** + * Composer dictation controls: a mic button for the composer footer plus a + * full-composer overlay while dictation is active (recording, transcribing, + * or failed). The overlay mirrors the composer's own layout — the transcript + * area uses the same paddings/typography as the textarea and the action row + * reuses the footer icon-button styling — so toggling dictation causes no + * vertical shift. + */ + +import React from 'react'; + +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { cn } from '@/lib/utils'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useDictation } from '@/hooks/useDictation'; +import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; + +interface ComposerDictationProps { + radius?: number | string; + isMobile: boolean; + footerIconButtonClass: string; + footerPaddingClass: string; + iconSizeClass: string; + sendIconSizeClass: string; + disabled?: boolean; + onInsert: (text: string) => void; + onInsertAndSend: (text: string) => void; +} + +const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${String(secs).padStart(2, '0')}`; +}; + +const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => { + const { currentTheme } = useThemeSystem(); + return ( + )} - {(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && ( + {(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
+ {shouldShow('sessionAssist') && ( +
setSessionAssistEnabled(!sessionAssistEnabled)} + onKeyDown={(event) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + setSessionAssistEnabled(!sessionAssistEnabled); + } + }} + > + + {t('settings.openchamber.visual.field.sessionAssist')} +
+ )} {shouldShow('reasoning') && (
{ > {t('settings.voice.page.field.ttsInputModeRaw')} +
diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index ebe833097a..b6b4037d3e 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -12,6 +12,36 @@ import { useSayTTS } from './useSayTTS'; import { useLocalTTS } from './useLocalTTS'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; import { sanitizeForTTS } from '@/lib/voice/summarize'; +import { runtimeFetch } from '@/lib/runtime-fetch'; + +// Below this length the reply is comfortable to listen to as-is; summarizing +// would only add latency. +const TTS_SUMMARIZE_MIN_CHARS = 600; + +const SUMMARIZE_SYSTEM_PROMPT = 'Summarize the assistant reply for text-to-speech listening. Reply with 2-4 sentences of plain spoken prose in the same language as the reply. No markdown, no lists, no code — mention code changes briefly in words instead.'; + +async function summarizeForSpeech( + text: string, + preferred: { providerID?: string; modelID?: string }, +): Promise { + try { + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: text, + system: SUMMARIZE_SYSTEM_PROMPT, + ...(preferred.providerID ? { preferredProviderID: preferred.providerID } : {}), + ...(preferred.modelID ? { preferredModelID: preferred.modelID } : {}), + }), + }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null) as { text?: unknown } | null; + return typeof payload?.text === 'string' && payload.text.trim() ? payload.text.trim() : null; + } catch { + return null; + } +} export interface UseMessageTTSReturn { /** Whether TTS is currently playing for this message */ @@ -69,9 +99,24 @@ export function useMessageTTS(): UseMessageTTSReturn { setIsPlaying(true); try { + // Summarized mode: replace long replies with a short spoken-prose + // summary from the small model; fall back to the sanitized + // original when summarization is unavailable. + let sourceText = text; + if (ttsInputMode === 'summarized' && text.length >= TTS_SUMMARIZE_MIN_CHARS) { + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const summary = await summarizeForSpeech(text, { + providerID: currentProviderId || undefined, + modelID: currentModelId || undefined, + }); + if (summary) { + sourceText = summary; + } + } + const shouldUseRaw = ttsInputMode === 'raw' && isServerProvider; - const sanitizedText = sanitizeForTTS(text); - const textToSpeak = shouldUseRaw ? text : sanitizedText; + const sanitizedText = sanitizeForTTS(sourceText); + const textToSpeak = shouldUseRaw ? sourceText : sanitizedText; if (isServerProvider && isServerTTSAvailable) { const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice; diff --git a/packages/ui/src/hooks/useSessionAssist.ts b/packages/ui/src/hooks/useSessionAssist.ts new file mode 100644 index 0000000000..889c0b429c --- /dev/null +++ b/packages/ui/src/hooks/useSessionAssist.ts @@ -0,0 +1,94 @@ +import React from 'react'; +import { useDirectoryStore, useSession, useSessionStatus } from '@/sync/sync-context'; +import { getSessionAssist, type SessionAssistPayload } from '@/lib/sessionAssistMetadata'; + +// 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 = 5 * 60 * 1000; + +interface LastMessageSnapshot { + id: string; + role: string; + timestamp: number; +} + +/** Narrow subscription to the last message of a session (id/role/time only). */ +function useLastMessageSnapshot(sessionId: string): LastMessageSnapshot | null { + const store = useDirectoryStore(); + const cacheRef = React.useRef(null); + + const getSnapshot = React.useCallback((): LastMessageSnapshot | null => { + if (!sessionId) return null; + const messages = store.getState().message[sessionId]; + const last = messages && messages.length > 0 ? messages[messages.length - 1] : null; + const info = last as { id?: string; role?: string; time?: { completed?: number; created?: number } } | null; + if (!info?.id) { + cacheRef.current = null; + return null; + } + const next: LastMessageSnapshot = { + id: info.id, + role: typeof info.role === 'string' ? info.role : '', + timestamp: info.time?.completed ?? info.time?.created ?? 0, + }; + const cached = cacheRef.current; + if (cached && cached.id === next.id && cached.role === next.role && cached.timestamp === next.timestamp) { + return cached; + } + cacheRef.current = next; + return next; + }, [sessionId, store]); + + const subscribe = React.useCallback((notify: () => void) => { + if (!sessionId) return () => undefined; + return store.subscribe(notify); + }, [sessionId, store]); + + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export interface SessionAssistState { + /** Valid (fresh) assist payload, or null. */ + assist: SessionAssistPayload | null; + /** Recap text, only when the 5-minute quiet window has elapsed. */ + visibleRecap: string | null; + /** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */ + suggestion: string | null; +} + +export function useSessionAssistState(sessionId: string): SessionAssistState { + const session = useSession(sessionId); + const status = useSessionStatus(sessionId); + const lastMessage = useLastMessageSnapshot(sessionId); + + const isIdle = !status || status.type === 'idle'; + const payload = getSessionAssist(session); + + // Fresh = the payload's target message is still the session's last message. + const assist = payload + && lastMessage + && lastMessage.role === 'assistant' + && lastMessage.id === payload.forMessageID + && isIdle + ? payload + : null; + + // Recap waits out the quiet window; re-render once when the boundary passes. + const lastTimestamp = lastMessage?.timestamp ?? 0; + const [, forceTick] = React.useReducer((tick: number) => tick + 1, 0); + const quietElapsed = assist ? Date.now() - lastTimestamp >= RECAP_VISIBILITY_DELAY_MS : false; + + React.useEffect(() => { + if (!assist || quietElapsed || !lastTimestamp) return undefined; + const remaining = RECAP_VISIBILITY_DELAY_MS - (Date.now() - lastTimestamp); + if (remaining <= 0) return undefined; + const timer = setTimeout(forceTick, remaining + 250); + return () => clearTimeout(timer); + }, [assist, quietElapsed, lastTimestamp]); + + return { + assist, + visibleRecap: assist && assist.recap && quietElapsed ? assist.recap : null, + suggestion: assist && assist.suggestion ? assist.suggestion : null, + }; +} diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 8e4146ab2b..ab08361266 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -6,6 +6,7 @@ import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; type AppearanceSlice = { showReasoningTraces: boolean; + sessionAssistEnabled: boolean; collapsibleThinkingBlocks: boolean; showDeletionDialog: boolean; nativeNotificationsEnabled: boolean; @@ -50,6 +51,7 @@ export const startAppearanceAutoSave = (): void => { let previous: AppearanceSlice = { showReasoningTraces: useUIStore.getState().showReasoningTraces, + sessionAssistEnabled: useUIStore.getState().sessionAssistEnabled, collapsibleThinkingBlocks: useUIStore.getState().collapsibleThinkingBlocks, showDeletionDialog: useUIStore.getState().showDeletionDialog, nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled, @@ -101,6 +103,7 @@ export const startAppearanceAutoSave = (): void => { useUIStore.subscribe((state) => { const current: AppearanceSlice = { showReasoningTraces: state.showReasoningTraces, + sessionAssistEnabled: state.sessionAssistEnabled, collapsibleThinkingBlocks: state.collapsibleThinkingBlocks, showDeletionDialog: state.showDeletionDialog, nativeNotificationsEnabled: state.nativeNotificationsEnabled, @@ -134,6 +137,9 @@ export const startAppearanceAutoSave = (): void => { if (current.showReasoningTraces !== previous.showReasoningTraces) { diff.showReasoningTraces = current.showReasoningTraces; } + if (current.sessionAssistEnabled !== previous.sessionAssistEnabled) { + diff.sessionAssistEnabled = current.sessionAssistEnabled; + } if (current.collapsibleThinkingBlocks !== previous.collapsibleThinkingBlocks) { diff.collapsibleThinkingBlocks = current.collapsibleThinkingBlocks; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8c3329fd7e..fd2de600f3 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -113,6 +113,9 @@ export type DesktopSettings = { defaultModel?: string; // format: "provider/model" defaultVariant?: string; defaultAgent?: string; + smallModelUseDefault?: boolean; + sessionAssistEnabled?: boolean; + smallModelOverride?: string; // format: "provider/model" defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index a177b7c3d3..bfa787ded2 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -2,6 +2,7 @@ import * as gitHttp from './gitApiHttp'; import { opencodeClient } from './opencode/client'; import { renderMagicPrompt } from './magicPrompts'; +import { runtimeFetch } from './runtime-fetch'; import { materializeOpenDraftSession, useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -210,6 +211,71 @@ export async function deleteRemoteBranch(directory: string, payload: import('./a return gitHttp.deleteRemoteBranch(directory, payload); } +const COMMIT_DIFF_FILE_LIMIT = 30; +const COMMIT_DIFF_TOTAL_CHAR_LIMIT = 120_000; + +const collectSelectedFileDiffs = async (directory: string, files: string[]): Promise => { + const limited = files.slice(0, COMMIT_DIFF_FILE_LIMIT); + const chunks = await Promise.all(limited.map(async (path) => { + try { + const [staged, unstaged] = await Promise.all([ + gitHttp.getGitDiff(directory, { path, staged: true }).catch(() => null), + gitHttp.getGitDiff(directory, { path, staged: false }).catch(() => null), + ]); + const text = [staged?.diff, unstaged?.diff] + .filter((diff): diff is string => typeof diff === 'string' && diff.trim().length > 0) + .join('\n'); + return text ? text : `--- ${path} (no textual diff available)`; + } catch { + return `--- ${path} (diff unavailable)`; + } + })); + + let total = ''; + for (const chunk of chunks) { + if (total.length + chunk.length > COMMIT_DIFF_TOTAL_CHAR_LIMIT) { + total += '\n[remaining diffs truncated]'; + break; + } + total += (total ? '\n\n' : '') + chunk; + } + if (files.length > limited.length) { + total += `\n[${files.length - limited.length} more selected files omitted]`; + } + return total; +}; + +const parseCommitStructured = (structured: Record | null): { subject: string; highlights: string[] } => { + const subject = typeof structured?.subject === 'string' ? structured.subject.trim() : ''; + const highlights = Array.isArray(structured?.highlights) + ? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3) + : []; + if (!subject) { + throw new Error('Structured output missing subject'); + } + return { subject, highlights }; +}; + +// Legacy transport: run the structured generation inside the active chat +// session. Kept as the fallback for setups with no direct provider login +// (vanilla installs on OpenCode's free models), where the small-model +// endpoint has nothing to call but the session itself still works. +async function generateCommitMessageViaSession( + directory: string, + visiblePrompt: string, + hiddenPrompt: string, +): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { + const generationSession = await resolveGenerationSessionContext(); + const structured = await runStructuredGenerationInActiveSession({ + directory, + visiblePrompt, + hiddenPrompt, + generationSession, + kind: 'commit', + }); + return { message: parseCommitStructured(structured) }; +} + export async function generateCommitMessage( directory: string, files: string[], @@ -217,17 +283,12 @@ export async function generateCommitMessage( ): Promise<{ message: import('./api/types').GeneratedCommitMessage }> { const startedAt = Date.now(); void options; - const generationSession = await resolveGenerationSessionContext(); console.info('[git-generation][browser] request', { - transport: 'session', + transport: 'small-model', kind: 'commit', directory, selectedFiles: files.length, - sessionId: generationSession.sessionId, - providerId: generationSession.providerID, - modelId: generationSession.modelID, - agent: generationSession.agent, }); const visiblePrompt = await renderMagicPrompt('git.commit.generate.visible'); @@ -236,26 +297,44 @@ export async function generateCommitMessage( }); try { - const structured = await runStructuredGenerationInActiveSession({ - directory, - visiblePrompt, - hiddenPrompt, - generationSession, - kind: 'commit', + const diffs = await collectSelectedFileDiffs(directory, files); + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system: visiblePrompt, + prompt: `${hiddenPrompt}\n\nDiffs of the selected files:\n${diffs}`, + directory, + ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), + ...(currentModelId ? { preferredModelID: currentModelId } : {}), + }), }); - const subject = typeof structured.subject === 'string' ? structured.subject.trim() : ''; - const highlights = Array.isArray(structured.highlights) - ? structured.highlights.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean).slice(0, 3) - : []; + if (response.status === 404) { + // No authenticated provider has a small model — fall back to the + // session transport so free-model-only setups keep a working button. + console.info('[git-generation][browser] small model unavailable, falling back to session transport'); + const result = await generateCommitMessageViaSession(directory, visiblePrompt, hiddenPrompt); + console.info('[git-generation][browser] success', { + transport: 'session-fallback', + kind: 'commit', + elapsedMs: Date.now() - startedAt, + subjectLength: result.message.subject.length, + highlightsCount: result.message.highlights.length, + }); + return result; + } - if (!subject) { - throw new Error('Structured output missing subject'); + const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null; + if (!response.ok || typeof payload?.text !== 'string') { + const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`; + throw new Error(message); } - const result = { message: { subject, highlights } }; + const result = { message: parseCommitStructured(extractJsonObject(payload.text)) }; console.info('[git-generation][browser] success', { - transport: 'session', + transport: 'small-model', kind: 'commit', elapsedMs: Date.now() - startedAt, subjectLength: result.message.subject.length, @@ -264,7 +343,7 @@ export async function generateCommitMessage( return result; } catch (error) { console.error('[git-generation][browser] failed', { - transport: 'session', + transport: 'small-model', kind: 'commit', elapsedMs: Date.now() - startedAt, message: error instanceof Error ? error.message : String(error), @@ -279,18 +358,19 @@ export async function generatePullRequestDescription( payload: { base: string; head: string; context?: string; zenModel?: string; providerId?: string; modelId?: string } ): Promise { const startedAt = Date.now(); - const generationSession = await resolveGenerationSessionContext(); const commitLog = await getGitLog(directory, { from: payload.base, to: payload.head, maxCount: 50, }); + const COMMIT_BODY_CHAR_LIMIT = 2_000; const commits = (Array.isArray(commitLog?.all) ? commitLog.all : []) .filter((entry) => typeof entry?.hash === 'string' && entry.hash.length > 0) .map((entry) => ({ hash: entry.hash, subject: typeof entry.message === 'string' ? entry.message.trim() : '', + body: typeof entry.body === 'string' ? entry.body.trim().slice(0, COMMIT_BODY_CHAR_LIMIT) : '', })); if (commits.length === 0) { @@ -317,13 +397,9 @@ export async function generatePullRequestDescription( const changedFiles = Array.from(filesSet).sort().slice(0, 300); console.info('[git-generation][browser] request', { - transport: 'session', + transport: 'small-model', kind: 'pr', directory, - sessionId: generationSession.sessionId, - providerId: generationSession.providerID, - modelId: generationSession.modelID, - agent: generationSession.agent, base: payload.base, head: payload.head, commits: commits.length, @@ -334,26 +410,67 @@ export async function generatePullRequestDescription( const hiddenPrompt = await renderMagicPrompt('git.pr.generate.instructions', { base_branch: payload.base, head_branch: payload.head, - commits: commits.map((commit) => `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`).join('\n'), + commits: commits.map((commit) => { + const line = `- ${commit.hash.slice(0, 7)} ${commit.subject || '(no subject)'}`; + if (!commit.body) return line; + const indentedBody = commit.body.split('\n').map((bodyLine) => ` ${bodyLine}`).join('\n'); + return `${line}\n${indentedBody}`; + }).join('\n'), changed_files: changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join('\n') : '- none detected', additional_context_block: payload.context?.trim() ? `\nAdditional context:\n${payload.context.trim()}` : '', }); + const parsePrStructured = (structured: Record | null) => ({ + title: typeof structured?.title === 'string' ? structured.title.trim() : '', + body: typeof structured?.body === 'string' ? structured.body.trim() : '', + }); + try { - const structured = await runStructuredGenerationInActiveSession({ - directory, - visiblePrompt, - hiddenPrompt, - generationSession, - kind: 'pr', + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system: visiblePrompt, + prompt: hiddenPrompt, + directory, + ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), + ...(currentModelId ? { preferredModelID: currentModelId } : {}), + }), }); - const result = { - title: typeof structured.title === 'string' ? structured.title.trim() : '', - body: typeof structured.body === 'string' ? structured.body.trim() : '', - }; + if (response.status === 404) { + // No authenticated provider has a small model — fall back to the + // session transport so free-model-only setups keep working. + console.info('[git-generation][browser] small model unavailable, falling back to session transport'); + const generationSession = await resolveGenerationSessionContext(); + const structured = await runStructuredGenerationInActiveSession({ + directory, + visiblePrompt, + hiddenPrompt, + generationSession, + kind: 'pr', + }); + const result = parsePrStructured(structured); + console.info('[git-generation][browser] success', { + transport: 'session-fallback', + kind: 'pr', + elapsedMs: Date.now() - startedAt, + titleLength: result.title.length, + bodyLength: result.body.length, + }); + return result; + } + + const payload = await response.json().catch(() => null) as { text?: unknown; error?: unknown } | null; + if (!response.ok || typeof payload?.text !== 'string') { + const message = typeof payload?.error === 'string' ? payload.error : `HTTP ${response.status}`; + throw new Error(message); + } + + const result = parsePrStructured(extractJsonObject(payload.text)); console.info('[git-generation][browser] success', { - transport: 'session', + transport: 'small-model', kind: 'pr', elapsedMs: Date.now() - startedAt, titleLength: result.title.length, @@ -362,7 +479,7 @@ export async function generatePullRequestDescription( return result; } catch (error) { console.error('[git-generation][browser] failed', { - transport: 'session', + transport: 'small-model', kind: 'pr', elapsedMs: Date.now() - startedAt, message: error instanceof Error ? error.message : String(error), diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c659fac12b..9deabdc19c 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1397,6 +1397,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.defaultAgent': 'Default Agent', 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Show deletion dialog', 'settings.openchamber.defaults.field.showDeletionDialog': 'Show Deletion Dialog', + 'settings.openchamber.defaults.smallModel.title': 'Small Model', + 'settings.openchamber.defaults.smallModel.description': 'A cheap model for quick utility tasks like short recaps and summaries.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Override model', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open files in preview mode', 'settings.openchamber.defaults.field.openFilesPreview': 'Open files in preview mode', 'settings.openchamber.defaults.option.default': 'Default', @@ -1601,6 +1606,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS Input Mode', 'settings.voice.page.field.ttsInputModeSanitized': 'Sanitized', 'settings.voice.page.field.ttsInputModeRaw': 'Raw Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': 'summarized', 'settings.openchamber.visual.section.colorMode': 'Color Mode', 'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout', 'settings.openchamber.visual.option.mobileLayout.default': 'Old', @@ -1684,6 +1690,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': 'User message rendering: {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid rendering: {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Diff layout: {option}', + 'settings.openchamber.visual.field.sessionAssist': 'Generate Session Recap & Suggestion', + 'settings.openchamber.visual.field.sessionAssistAria': 'Generate a recap and a suggested reply after the agent finishes', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Show reasoning traces', 'settings.openchamber.visual.field.showReasoningTraces': 'Show Reasoning Traces', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Enable collapsible reasoning blocks', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 01abb9d424..ecc0400488 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1387,6 +1387,10 @@ export const dict = { 'header.actions.toggleChangesPanelAria': 'Toggle changes panel', 'header.actions.planWithShortcut': 'Plan ({shortcut})', 'header.actions.terminalPanelWithShortcut': 'Terminal panel ({shortcut})', + 'chat.recap.aria': 'Session recap', + 'chat.recap.label': 'Recap:', + 'chat.suggestion.applyAria': 'Use suggested message', + 'chat.suggestion.dismissAria': 'Dismiss suggestion', 'header.actions.toggleTerminalPanelAria': 'Toggle terminal panel', 'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ' with code {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 94ae6b5999..ad6cb599aa 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { "settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando", "settings.openchamber.defaults.field.defaultAgent": "Agente por defecto", "settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación", + "settings.openchamber.defaults.smallModel.title": "Modelo pequeño", + "settings.openchamber.defaults.smallModel.description": "Un modelo económico para tareas utilitarias rápidas, como recapitulaciones y resúmenes breves.", + "settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado", + "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado", + "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos en modo vista previa", "settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos en modo vista previa", @@ -1568,6 +1573,7 @@ export const settingsDict = { "settings.voice.page.field.ttsInputMode": "Modo de entrada TTS", "settings.voice.page.field.ttsInputModeSanitized": "Texto limpio", "settings.voice.page.field.ttsInputModeRaw": "Markdown sin procesar", + "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de color", "settings.openchamber.visual.section.mobileLayout": "Diseño móvil", "settings.openchamber.visual.option.mobileLayout.default": "Anterior", @@ -1651,6 +1657,8 @@ export const settingsDict = { "settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensajes del usuario: {option}", "settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}", "settings.openchamber.visual.field.diffLayoutAria": "Diseño de comparación: {option}", + "settings.openchamber.visual.field.sessionAssist": "Generar resumen y sugerencia de sesión", + "settings.openchamber.visual.field.sessionAssistAria": "Generar un resumen y una respuesta sugerida cuando el agente termina", "settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de razonamiento", "settings.openchamber.visual.field.showReasoningTraces": "Mostrar trazas de razonamiento", "settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar bloques de razonamiento colapsables", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index fee8511fa1..5c4125884c 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1365,6 +1365,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "Alternar panel de cambios", "header.actions.planWithShortcut": "Plan ({shortcut})", "header.actions.terminalPanelWithShortcut": "Panel de terminal ({shortcut})", + "chat.recap.aria": "Resumen de la sesión", + "chat.recap.label": "Resumen:", + "chat.suggestion.applyAria": "Usar mensaje sugerido", + "chat.suggestion.dismissAria": "Descartar sugerencia", "header.actions.toggleTerminalPanelAria": "Mostrar u ocultar panel de terminal", "terminalView.stream.processExitedMessage": "\r\n[Proceso terminado{exitCodeSegment}{signalSegment}]\r\n", "terminalView.stream.processExitedWithCode": " con código {exitCode}", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index b3cb5487f6..c3722240d2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1346,6 +1346,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Pensée', 'settings.openchamber.defaults.field.defaultAgent': 'Agent par défaut', 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Afficher la boîte de dialogue de suppression', + 'settings.openchamber.defaults.smallModel.title': 'Petit modèle', + 'settings.openchamber.defaults.smallModel.description': 'Un modèle économique pour les tâches utilitaires rapides, comme les récapitulatifs et résumés courts.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement', 'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers en mode aperçu', 'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers en mode aperçu', @@ -1619,6 +1624,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': 'Rendu du message utilisateur : {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Rendu Mermaid : {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Disposition des différences : {option}', + 'settings.openchamber.visual.field.sessionAssist': 'Générer le récapitulatif et la suggestion de session', + 'settings.openchamber.visual.field.sessionAssistAria': "Générer un récapitulatif et une réponse suggérée quand l'agent termine", 'settings.openchamber.visual.field.showReasoningTracesAria': 'Afficher les traces de raisonnement', 'settings.openchamber.visual.field.showReasoningTraces': 'Afficher les traces de raisonnement', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Activer les blocs de raisonnement pliables', @@ -1785,6 +1792,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS', 'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé', 'settings.voice.page.field.ttsInputModeRaw': 'Markdown brut', + 'settings.voice.page.field.ttsInputModeSummarized': 'résumé', 'settings.openchamber.visual.section.mobileLayout': 'Mise en page mobile', 'settings.openchamber.visual.option.mobileLayout.default': 'Ancienne', 'settings.openchamber.visual.option.mobileLayout.new': 'Nouvelle', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 712b9c5a20..efd36d718c 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1214,6 +1214,10 @@ export const dict = { "header.actions.toggleChangesPanelAria": "Basculer le panneau des changements", 'header.actions.planWithShortcut': 'Forfait ({shortcut})', 'header.actions.terminalPanelWithShortcut': 'Panneau à bornes ({shortcut})', + 'chat.recap.aria': 'Récapitulatif de la session', + 'chat.recap.label': 'Récap :', + 'chat.suggestion.applyAria': 'Utiliser le message suggéré', + 'chat.suggestion.dismissAria': 'Ignorer la suggestion', 'header.actions.toggleTerminalPanelAria': 'Basculer le panneau à bornes', 'terminalView.stream.processExitedMessage': '[Processus terminé{exitCodeSegment}{signalSegment}]', 'terminalView.stream.processExitedWithCode': 'avec le code {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 3e7a1417fb..e4ea9ca1c8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1396,6 +1396,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考', 'settings.openchamber.defaults.field.defaultAgent': 'デフォルト Agent', 'settings.openchamber.defaults.field.showDeletionDialogAria': '削除ダイアログを表示', + 'settings.openchamber.defaults.smallModel.title': '小型モデル', + 'settings.openchamber.defaults.smallModel.description': '短い要約やまとめなどの軽いユーティリティタスク用の低コストモデルです。', + 'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用', + 'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル', 'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'ファイルをプレビューモードで開く', 'settings.openchamber.defaults.field.openFilesPreview': 'ファイルをプレビューモードで開く', @@ -1601,6 +1606,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS 入力モード', 'settings.voice.page.field.ttsInputModeSanitized': 'サニタイズ', 'settings.voice.page.field.ttsInputModeRaw': '生 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '要約', 'settings.openchamber.visual.section.colorMode': 'カラーモード', 'settings.openchamber.visual.section.mobileLayout': 'モバイルレイアウト', 'settings.openchamber.visual.option.mobileLayout.default': '旧', @@ -1684,6 +1690,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': 'ユーザーメッセージ表示: {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 表示: {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Diff レイアウト: {option}', + 'settings.openchamber.visual.field.sessionAssist': 'セッションの要約と提案を生成', + 'settings.openchamber.visual.field.sessionAssistAria': 'エージェントの完了後に要約と返信の提案を生成します', 'settings.openchamber.visual.field.showReasoningTracesAria': '推論トレースを表示', 'settings.openchamber.visual.field.showReasoningTraces': '推論トレースを表示', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '折りたたみ可能な推論ブロックを有効化', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0952044915..b48f61f3d7 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1383,6 +1383,10 @@ export const dict: Record = { 'header.actions.toggleChangesPanelAria': '変更パネルの切り替え', 'header.actions.planWithShortcut': '計画({shortcut})', 'header.actions.terminalPanelWithShortcut': 'ターミナルパネル({shortcut})', + 'chat.recap.aria': 'セッションの要約', + 'chat.recap.label': '要約:', + 'chat.suggestion.applyAria': '提案されたメッセージを使用', + 'chat.suggestion.dismissAria': '提案を閉じる', 'header.actions.toggleTerminalPanelAria': 'ターミナルパネルの切り替え', 'terminalView.stream.processExitedMessage': '\r\n[プロセスが終了しました{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ' コード {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index aa9b90be3b..3a24a08881 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Thinking', 'settings.openchamber.defaults.field.defaultAgent': '기본 에이전트', 'settings.openchamber.defaults.field.showDeletionDialogAria': '삭제 확인 대화상자 표시', + 'settings.openchamber.defaults.smallModel.title': '소형 모델', + 'settings.openchamber.defaults.smallModel.description': '짧은 요약 등 가벼운 유틸리티 작업을 위한 저렴한 모델입니다.', + 'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용', + 'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용', + 'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델', 'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시', 'settings.openchamber.defaults.field.openFilesPreviewAria': '파일을 미리보기 모드로 열기', 'settings.openchamber.defaults.field.openFilesPreview': '파일을 미리보기 모드로 열기', @@ -1568,6 +1573,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS 입력 모드', 'settings.voice.page.field.ttsInputModeSanitized': '정제된 텍스트', 'settings.voice.page.field.ttsInputModeRaw': '원본 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '요약', 'settings.openchamber.visual.section.colorMode': '색상 모드', 'settings.openchamber.visual.section.mobileLayout': '모바일 레이아웃', 'settings.openchamber.visual.option.mobileLayout.default': '이전', @@ -1651,6 +1657,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': '사용자 메시지 렌더링: {option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 렌더링: {option}', 'settings.openchamber.visual.field.diffLayoutAria': 'Diff 레이아웃: {option}', + 'settings.openchamber.visual.field.sessionAssist': '세션 요약 및 제안 생성', + 'settings.openchamber.visual.field.sessionAssistAria': '에이전트가 완료되면 요약과 제안 답장을 생성합니다', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Reasoning trace 표시', 'settings.openchamber.visual.field.showReasoningTraces': 'Reasoning Trace 표시', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '접을 수 있는 추론 블록 활성화', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index cc1d22abda..91b83456a9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1389,6 +1389,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "변경 패널 전환", 'header.actions.planWithShortcut': '플랜 ({shortcut})', 'header.actions.terminalPanelWithShortcut': '터미널 패널 ({shortcut})', + 'chat.recap.aria': '세션 요약', + 'chat.recap.label': '요약:', + 'chat.suggestion.applyAria': '제안된 메시지 사용', + 'chat.suggestion.dismissAria': '제안 닫기', 'header.actions.toggleTerminalPanelAria': '토글 터미널 패널', 'terminalView.stream.processExitedMessage': '\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ', 종료 코드 {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 86c2caed41..75a2f201ac 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -678,6 +678,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Otwieraj pliki w trybie podglądu', 'settings.openchamber.defaults.field.showDeletionDialog': 'Pokaż dialog usuwania', 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Pokaż dialog usuwania', + 'settings.openchamber.defaults.smallModel.title': 'Mały model', + 'settings.openchamber.defaults.smallModel.description': 'Tani model do szybkich zadań pomocniczych, takich jak krótkie podsumowania.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy', 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie', 'settings.openchamber.defaults.option.default': 'Domyślne', 'settings.openchamber.defaults.option.defaultLowercase': 'domyślne', @@ -969,6 +974,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.showDotfilesAria': 'Pokaż pliki ukryte', 'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Pokaż rozwinięte narzędzia bash', 'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Pokaż rozwinięte narzędzia edycji', + 'settings.openchamber.visual.field.sessionAssist': 'Generuj podsumowanie i sugestię sesji', + 'settings.openchamber.visual.field.sessionAssistAria': 'Generuj podsumowanie i sugerowaną odpowiedź po zakończeniu pracy agenta', 'settings.openchamber.visual.field.showReasoningTraces': 'Pokaż ślady rozumowania', 'settings.openchamber.visual.field.showReasoningTracesAria': 'Pokaż ślady rozumowania', 'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Włącz zwijalne bloki rozumowania', @@ -1819,6 +1826,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'Tryb wejścia TTS', 'settings.voice.page.field.ttsInputModeSanitized': 'Oczyszczony tekst', 'settings.voice.page.field.ttsInputModeRaw': 'Surowy Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': 'streszczony', 'settings.window.description': 'Okno ustawień OpenChamber.', 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up behavior', 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up behavior', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index aa36d91840..2ecc05c42d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2061,6 +2061,10 @@ export const dict: Record = { 'header.actions.planWithShortcut': 'Plan ({shortcut})', 'header.actions.rightSidebarWithShortcut': 'Prawy panel boczny ({shortcut})', 'header.actions.terminalPanelWithShortcut': 'Panel terminala ({shortcut})', + 'chat.recap.aria': 'Podsumowanie sesji', + 'chat.recap.label': 'Podsumowanie:', + 'chat.suggestion.applyAria': 'Użyj sugerowanej wiadomości', + 'chat.suggestion.dismissAria': 'Odrzuć sugestię', 'header.actions.toggleRightSidebarAria': 'Przełącz prawy panel boczny', 'header.actions.toggleTerminalPanelAria': 'Przełącz panel terminala', 'header.changes.availableAria': 'Dostępne zmiany', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 4236c6704d..453bfb72d6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { "settings.openchamber.defaults.field.thinkingPlaceholder": "Pensando", "settings.openchamber.defaults.field.defaultAgent": "Agente por padrão", "settings.openchamber.defaults.field.showDeletionDialogAria": "Mostrar diálogo de eliminación", + "settings.openchamber.defaults.smallModel.title": "Modelo pequeno", + "settings.openchamber.defaults.smallModel.description": "Um modelo barato para tarefas utilitárias rápidas, como recapitulações e resumos curtos.", + "settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão", + "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão", + "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos em modo prévia", "settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos em modo prévia", @@ -1568,6 +1573,7 @@ export const settingsDict = { "settings.voice.page.field.ttsInputMode": "Modo de entrada TTS", "settings.voice.page.field.ttsInputModeSanitized": "Texto limpo", "settings.voice.page.field.ttsInputModeRaw": "Markdown bruto", + "settings.voice.page.field.ttsInputModeSummarized": "resumido", "settings.openchamber.visual.section.colorMode": "Modo de cor", "settings.openchamber.visual.section.mobileLayout": "Layout móvel", "settings.openchamber.visual.option.mobileLayout.default": "Anterior", @@ -1651,6 +1657,8 @@ export const settingsDict = { "settings.openchamber.visual.field.userMessageRenderingAria": "Renderizado de mensagens do usuário: {option}", "settings.openchamber.visual.field.mermaidRenderingAria": "Renderizado de Mermaid: {option}", "settings.openchamber.visual.field.diffLayoutAria": "Layout de comparação: {option}", + "settings.openchamber.visual.field.sessionAssist": "Gerar resumo e sugestão da sessão", + "settings.openchamber.visual.field.sessionAssistAria": "Gerar um resumo e uma resposta sugerida quando o agente termina", "settings.openchamber.visual.field.showReasoningTracesAria": "Mostrar rastros de raciocínio", "settings.openchamber.visual.field.showReasoningTraces": "Mostrar rastros de raciocínio", "settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Habilitar blocos de raciocínio recolhíveis", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4ff09980ea..00950b28b6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1365,6 +1365,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "Alternar painel de alterações", "header.actions.planWithShortcut": "Plano ({shortcut})", "header.actions.terminalPanelWithShortcut": "Painel de terminal ({shortcut})", + "chat.recap.aria": "Resumo da sessão", + "chat.recap.label": "Resumo:", + "chat.suggestion.applyAria": "Usar mensagem sugerida", + "chat.suggestion.dismissAria": "Dispensar sugestão", "header.actions.toggleTerminalPanelAria": "Mostrar ou ocultar painel de terminal", "terminalView.stream.processExitedMessage": "\r\n[Processo encerrado{exitCodeSegment}{signalSegment}]\r\n", "terminalView.stream.processExitedWithCode": " com código {exitCode}", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 84af19b1d2..7b030311ce 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { "settings.openchamber.defaults.field.thinkingPlaceholder": "Мислення", "settings.openchamber.defaults.field.defaultAgent": "Агент за замовчуванням", "settings.openchamber.defaults.field.showDeletionDialogAria": "Показати діалогове вікно видалення", + "settings.openchamber.defaults.smallModel.title": "Мала модель", + "settings.openchamber.defaults.smallModel.description": "Дешева модель для швидких службових задач — коротких підсумків і резюме.", + "settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель", + "settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель", + "settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни", "settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення", "settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли в режимі попереднього перегляду", "settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли в режимі попереднього перегляду", @@ -1568,6 +1573,7 @@ export const settingsDict = { "settings.voice.page.field.ttsInputMode": "Режим вводу TTS", "settings.voice.page.field.ttsInputModeSanitized": "Очищений текст", "settings.voice.page.field.ttsInputModeRaw": "Сирий Markdown", + "settings.voice.page.field.ttsInputModeSummarized": "скорочений", "settings.openchamber.visual.section.colorMode": "Режим теми", "settings.openchamber.visual.section.mobileLayout": "Мобільний макет", "settings.openchamber.visual.option.mobileLayout.default": "Попередній", @@ -1651,6 +1657,8 @@ export const settingsDict = { "settings.openchamber.visual.field.userMessageRenderingAria": "Відображення повідомлень користувача: {option}", "settings.openchamber.visual.field.mermaidRenderingAria": "Візуалізація Mermaid: {option}", "settings.openchamber.visual.field.diffLayoutAria": "Компонування diff: {option}", + "settings.openchamber.visual.field.sessionAssist": "Генерувати підсумок і пропозицію для сесії", + "settings.openchamber.visual.field.sessionAssistAria": "Генерувати підсумок і запропоновану відповідь після завершення роботи агента", "settings.openchamber.visual.field.showReasoningTracesAria": "Показати сліди міркувань", "settings.openchamber.visual.field.showReasoningTraces": "Показати сліди міркувань", "settings.openchamber.visual.field.collapsibleThinkingBlocksAria": "Увімкнути згортальні блоки міркувань", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d1d37f8479..2553e30202 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1365,6 +1365,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "Перемкнути панель змін", "header.actions.planWithShortcut": "План ({shortcut})", "header.actions.terminalPanelWithShortcut": "Термінальна панель ({shortcut})", + "chat.recap.aria": "Підсумок сесії", + "chat.recap.label": "Підсумок:", + "chat.suggestion.applyAria": "Використати запропоноване повідомлення", + "chat.suggestion.dismissAria": "Прибрати пропозицію", "header.actions.toggleTerminalPanelAria": "Перемкнути панель терміналу", "terminalView.stream.processExitedMessage": "\r\n[Process exited{exitCodeSegment}{signalSegment}]\r\n", "terminalView.stream.processExitedWithCode": " з кодом {exitCode}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index b4684193e5..defb89cec4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1363,6 +1363,11 @@ export const settingsDict = { 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式', 'settings.openchamber.defaults.field.defaultAgent': '默认智能体', 'settings.openchamber.defaults.field.showDeletionDialogAria': '显示删除对话框', + 'settings.openchamber.defaults.smallModel.title': '小模型', + 'settings.openchamber.defaults.smallModel.description': '用于快速实用任务(如简短回顾和摘要)的廉价模型。', + 'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型', + 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型', + 'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型', 'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开文件', 'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开文件', @@ -1568,6 +1573,7 @@ export const settingsDict = { 'settings.voice.page.field.ttsInputMode': 'TTS 输入模式', 'settings.voice.page.field.ttsInputModeSanitized': '清理后文本', 'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.openchamber.visual.section.colorMode': '颜色模式', 'settings.openchamber.visual.section.mobileLayout': '移动端布局', 'settings.openchamber.visual.option.mobileLayout.default': '旧版', @@ -1651,6 +1657,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.userMessageRenderingAria': '用户消息渲染:{option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}', 'settings.openchamber.visual.field.diffLayoutAria': '差异布局:{option}', + 'settings.openchamber.visual.field.sessionAssist': '生成会话回顾与建议', + 'settings.openchamber.visual.field.sessionAssistAria': '代理完成后生成回顾和建议回复', 'settings.openchamber.visual.field.showReasoningTracesAria': '显示推理轨迹', 'settings.openchamber.visual.field.showReasoningTraces': '显示推理轨迹', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '启用可折叠推理块', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b81880c4e..eee31d399b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1353,6 +1353,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "切换更改面板", 'header.actions.planWithShortcut': '计划({shortcut})', 'header.actions.terminalPanelWithShortcut': '终端面板({shortcut})', + 'chat.recap.aria': '会话回顾', + 'chat.recap.label': '回顾:', + 'chat.suggestion.applyAria': '使用建议的消息', + 'chat.suggestion.dismissAria': '关闭建议', 'header.actions.toggleTerminalPanelAria': '切换终端面板', 'terminalView.stream.processExitedMessage': '\r\n[进程已退出{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ',退出码 {exitCode}', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 0479444051..0be05ab2c4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1279,6 +1279,11 @@ 'settings.openchamber.defaults.field.thinkingPlaceholder': '思考模式', 'settings.openchamber.defaults.field.defaultAgent': '預設 Agent', 'settings.openchamber.defaults.field.showDeletionDialogAria': '顯示刪除對話方塊', + 'settings.openchamber.defaults.smallModel.title': '小模型', + 'settings.openchamber.defaults.smallModel.description': '用於快速實用任務(如簡短回顧與摘要)的廉價模型。', + 'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型', + 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型', + 'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型', 'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟檔案', 'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟檔案', @@ -1484,6 +1489,7 @@ 'settings.voice.page.field.ttsInputMode': 'TTS 輸入模式', 'settings.voice.page.field.ttsInputModeSanitized': '清理後文字', 'settings.voice.page.field.ttsInputModeRaw': '原始 Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': '摘要', 'settings.openchamber.visual.section.colorMode': '顏色模式', 'settings.openchamber.visual.section.localization': '在地化', 'settings.openchamber.visual.section.spacingAndLayout': '間距與佈局', @@ -1567,6 +1573,8 @@ 'settings.openchamber.visual.field.userMessageRenderingAria': '使用者訊息渲染:{option}', 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid 渲染:{option}', 'settings.openchamber.visual.field.diffLayoutAria': '差異佈局:{option}', + 'settings.openchamber.visual.field.sessionAssist': '產生工作階段回顧與建議', + 'settings.openchamber.visual.field.sessionAssistAria': '代理完成後產生回顧與建議回覆', 'settings.openchamber.visual.field.showReasoningTracesAria': '顯示推理軌跡', 'settings.openchamber.visual.field.showReasoningTraces': '顯示推理軌跡', 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': '啟用可摺疊推理區塊', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ab424dd27e..b6cac65f7d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1357,6 +1357,10 @@ export const dict: Record = { "header.actions.toggleChangesPanelAria": "切換變更面板", 'header.actions.planWithShortcut': '計畫({shortcut})', 'header.actions.terminalPanelWithShortcut': '終端機面板({shortcut})', + 'chat.recap.aria': '工作階段回顧', + 'chat.recap.label': '回顧:', + 'chat.suggestion.applyAria': '使用建議的訊息', + 'chat.suggestion.dismissAria': '關閉建議', 'header.actions.toggleTerminalPanelAria': '切換終端機面板', 'terminalView.stream.processExitedMessage': '\r\n[處理程序已結束{exitCodeSegment}{signalSegment}]\r\n', 'terminalView.stream.processExitedWithCode': ',結束代碼 {exitCode}', diff --git a/packages/ui/src/lib/magicPrompts.ts b/packages/ui/src/lib/magicPrompts.ts index 6d12c6e09f..d9c8980160 100644 --- a/packages/ui/src/lib/magicPrompts.ts +++ b/packages/ui/src/lib/magicPrompts.ts @@ -70,7 +70,7 @@ const MAGIC_PROMPT_DEFINITIONS: readonly MagicPromptDefinition[] = [ title: 'Commit Generation Visible Prompt', group: 'Git', description: 'Visible user message for commit message generation.', - template: 'You are generating a Conventional Commits subject line using session context and selected file paths.', + template: 'You are generating a Conventional Commits subject line from the diffs of the selected files.', }, { id: 'git.commit.generate.instructions', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index a61317ad6f..d7ccf5d0d3 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -423,6 +423,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.showReasoningTraces === 'boolean' && settings.showReasoningTraces !== store.showReasoningTraces) { store.setShowReasoningTraces(settings.showReasoningTraces); } + if (typeof settings.sessionAssistEnabled === 'boolean' && settings.sessionAssistEnabled !== store.sessionAssistEnabled) { + store.setSessionAssistEnabled(settings.sessionAssistEnabled); + } if (typeof settings.collapsibleThinkingBlocks === 'boolean' && settings.collapsibleThinkingBlocks !== store.collapsibleThinkingBlocks) { store.setCollapsibleThinkingBlocks(settings.collapsibleThinkingBlocks); } @@ -765,6 +768,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } + if (typeof candidate.sessionAssistEnabled === 'boolean') { + result.sessionAssistEnabled = candidate.sessionAssistEnabled; + } if (typeof candidate.collapsibleThinkingBlocks === 'boolean') { result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks; } @@ -832,6 +838,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.defaultAgent === 'string' && candidate.defaultAgent.length > 0) { result.defaultAgent = candidate.defaultAgent; } + if (typeof candidate.smallModelUseDefault === 'boolean') { + result.smallModelUseDefault = candidate.smallModelUseDefault; + } + if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) { + result.smallModelOverride = candidate.smallModelOverride; + } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } diff --git a/packages/ui/src/lib/sessionAssistMetadata.ts b/packages/ui/src/lib/sessionAssistMetadata.ts new file mode 100644 index 0000000000..51bb95495b --- /dev/null +++ b/packages/ui/src/lib/sessionAssistMetadata.ts @@ -0,0 +1,36 @@ +import type { Session } from '@opencode-ai/sdk/v2'; + +// Recap + suggested follow-up generated by the server's session-assist +// watcher, stored under session.metadata.openchamber.assist. Freshness is +// encoded in forMessageID: the payload is only valid while that message is +// still the session's last assistant message. +export interface SessionAssistPayload { + recap: string; + suggestion: string; + forMessageID: string; + generatedAt: number; +} + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +export function getSessionAssist(session: Session | null | undefined): SessionAssistPayload | null { + const metadata = (session as { metadata?: unknown } | null | undefined)?.metadata; + if (!isRecord(metadata)) return null; + const namespace = metadata.openchamber; + if (!isRecord(namespace)) return null; + const assist = namespace.assist; + if (!isRecord(assist)) return null; + + const recap = typeof assist.recap === 'string' ? assist.recap.trim() : ''; + const suggestion = typeof assist.suggestion === 'string' ? assist.suggestion.trim() : ''; + const forMessageID = typeof assist.forMessageID === 'string' ? assist.forMessageID : ''; + if (!forMessageID || (!recap && !suggestion)) return null; + + return { + recap, + suggestion, + forMessageID, + generatedAt: typeof assist.generatedAt === 'number' ? assist.generatedAt : 0, + }; +} diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index b90476571e..8214983597 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -166,6 +166,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.visual.section.messageStreamTransport', keywords: ['streaming', 'sse', 'websocket'], }, + { + id: 'chat.session-assist', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.sessionAssist', + keywords: ['recap', 'suggestion', 'assist', 'small model', 'summary'], + }, { id: 'chat.reasoning-traces', page: 'chat', @@ -260,6 +266,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ titleKey: 'settings.openchamber.defaults.field.showDeletionDialog', keywords: ['delete', 'confirmation'], }, + { + id: 'sessions.small-model', + page: 'sessions', + titleKey: 'settings.openchamber.defaults.smallModel.title', + descriptionKey: 'settings.openchamber.defaults.smallModel.description', + keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'], + }, { id: 'sessions.auto-cleanup', page: 'sessions', diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts new file mode 100644 index 0000000000..67ecd54640 --- /dev/null +++ b/packages/ui/src/lib/smallModel.ts @@ -0,0 +1,57 @@ +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { getSessionLastAssistantModel } from '@/sync/session-actions'; + +// Selections shorter than this are already note-sized — summarizing them +// would only add latency and risk losing the exact wording. +const NOTES_SUMMARIZE_MIN_CHARS = 280; + +const NOTES_SYSTEM_PROMPT = [ + 'You distill a text selection from a coding-agent conversation into a project note.', + 'Return ONLY the note text — no preamble, no surrounding quotes, no headers.', + 'Write 1-3 tight sentences that capture the essence worth remembering later: facts, decisions, constraints, root causes, gotchas, next steps.', + 'Preserve exact identifiers verbatim — file paths, function names, commands, flags, versions — in backticks.', + 'Drop filler, hedging, greetings, and step-by-step narration.', + 'Write the note in the same language as the selection. Ignore any other language preferences or personalization — only the selection text decides the language.', +].join('\n'); + +/** + * Distills a chat selection into a compact note via the small model. Falls + * back to the original text on any failure or when no small model is + * available within the session's provider (explicit settings/config picks + * are still honored server-side). + */ +export async function summarizeSelectionForNotes(text: string, sessionId?: string | null): Promise { + const trimmed = text.trim(); + if (trimmed.length < NOTES_SUMMARIZE_MIN_CHARS) { + return trimmed; + } + + try { + // The selection's session provider is authoritative — the text came from + // that conversation. The composer picker only serves as a fallback. + const sessionModel = sessionId ? getSessionLastAssistantModel(sessionId) : null; + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const preferredProviderID = sessionModel?.providerID || currentProviderId || ''; + const preferredModelID = sessionModel?.modelID || currentModelId || ''; + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: trimmed, + system: NOTES_SYSTEM_PROMPT, + restrictToPreferredProvider: true, + ...(preferredProviderID ? { preferredProviderID } : {}), + ...(preferredModelID ? { preferredModelID } : {}), + }), + }); + if (!response.ok) { + return trimmed; + } + const payload = await response.json().catch(() => null) as { text?: unknown } | null; + const summary = typeof payload?.text === 'string' ? payload.text.trim() : ''; + return summary || trimmed; + } catch { + return trimmed; + } +} diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 9dc1fc7503..0298d6bb83 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1004,7 +1004,7 @@ interface ConfigStore { sttLocalModel: string; sttLanguage: string; showMessageTTSButtons: boolean; - ttsInputMode: 'sanitized' | 'raw'; + ttsInputMode: 'sanitized' | 'raw' | 'summarized'; // Summarization settings summarizeMessageTTS: boolean; summarizeVoiceConversation: boolean; @@ -1030,7 +1030,7 @@ interface ConfigStore { setSttLocalModel: (model: string) => void; setSttLanguage: (lang: string) => void; setShowMessageTTSButtons: (show: boolean) => void; - setTtsInputMode: (mode: 'sanitized' | 'raw') => void; + setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => void; setSummarizeMessageTTS: (enabled: boolean) => void; setSummarizeVoiceConversation: (enabled: boolean) => void; setSummarizeCharacterThreshold: (threshold: number) => void; @@ -1299,6 +1299,7 @@ export const useConfigStore = create()( if (typeof window !== 'undefined') { const saved = localStorage.getItem('ttsInputMode'); if (saved === 'raw') return 'raw' as const; + if (saved === 'summarized') return 'summarized' as const; } return 'sanitized' as const; })(), @@ -2925,7 +2926,7 @@ export const useConfigStore = create()( } }, - setTtsInputMode: (mode: 'sanitized' | 'raw') => { + setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => { set({ ttsInputMode: mode }); if (typeof window !== 'undefined') { localStorage.setItem('ttsInputMode', mode); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index b8abc30ca2..1daf530ada 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -560,6 +560,7 @@ interface UIStore { eventStreamStatus: EventStreamStatus; eventStreamHint: string | null; showReasoningTraces: boolean; + sessionAssistEnabled: boolean; collapsibleThinkingBlocks: boolean; groupReasoningBlocks: boolean; chatRenderMode: ChatRenderMode; @@ -708,6 +709,7 @@ interface UIStore { setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void; setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void; setShowReasoningTraces: (value: boolean) => void; + setSessionAssistEnabled: (value: boolean) => void; setCollapsibleThinkingBlocks: (value: boolean) => void; setChatRenderMode: (value: ChatRenderMode) => void; setActivityRenderMode: (value: ActivityRenderMode) => void; @@ -851,6 +853,7 @@ export const useUIStore = create()( eventStreamStatus: 'idle', eventStreamHint: null, showReasoningTraces: true, + sessionAssistEnabled: true, collapsibleThinkingBlocks: true, groupReasoningBlocks: true, chatRenderMode: 'live', @@ -1543,6 +1546,10 @@ export const useUIStore = create()( set({ showReasoningTraces: value }); }, + setSessionAssistEnabled: (value) => { + set({ sessionAssistEnabled: value }); + }, + setCollapsibleThinkingBlocks: (value) => { set({ collapsibleThinkingBlocks: value }); }, @@ -2227,6 +2234,7 @@ export const useUIStore = create()( isSessionCreateDialogOpen: state.isSessionCreateDialogOpen, // Note: isSettingsDialogOpen intentionally NOT persisted showReasoningTraces: state.showReasoningTraces, + sessionAssistEnabled: state.sessionAssistEnabled, collapsibleThinkingBlocks: state.collapsibleThinkingBlocks, chatRenderMode: state.chatRenderMode, activityRenderMode: state.activityRenderMode, diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 6195f1278d..90a4e89d0a 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -131,6 +131,29 @@ function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; dire return { store: dirStore(), directory: dir() } } +/** + * Provider/model of the session's last assistant message — the authoritative + * "session provider" for utility calls (notes distillation etc.), independent + * of what the composer picker currently points at. + */ +export function getSessionLastAssistantModel(sessionId: string): { providerID: string; modelID: string } | null { + try { + const { store } = dirStoreForSession(sessionId) + const messages = store.getState().message[sessionId] + if (!messages) return null + for (let i = messages.length - 1; i >= 0; i -= 1) { + const info = messages[i] as { role?: string; providerID?: string; modelID?: string } + if (info?.role === "assistant" && typeof info.providerID === "string" && info.providerID + && typeof info.modelID === "string" && info.modelID) { + return { providerID: info.providerID, modelID: info.modelID } + } + } + return null + } catch { + return null + } +} + function updateLiveSession(session: Session, directory?: string): void { const stores = _childStores if (!stores) return diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index aa4049ab55..86b800223f 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record, ctx?: Br const keysToClear = new Set(); - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) { + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride']) { const value = restChanges[key]; if (typeof value === 'string' && value.trim().length === 0) { keysToClear.add(key); @@ -299,6 +299,14 @@ export const persistSettings = async (changes: Record, ctx?: Br } } + if ('smallModelUseDefault' in restChanges && typeof restChanges.smallModelUseDefault !== 'boolean') { + delete restChanges.smallModelUseDefault; + } + + if ('sessionAssistEnabled' in restChanges && typeof restChanges.sessionAssistEnabled !== 'boolean') { + delete restChanges.sessionAssistEnabled; + } + if (typeof restChanges.usageAutoRefresh !== 'boolean') { delete restChanges.usageAutoRefresh; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 58e12a3691..fea5d94b27 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -72,6 +72,7 @@ import { createOpenCodeResolutionRuntime } from './lib/opencode/opencode-resolut import { createBootstrapRuntime } from './lib/opencode/bootstrap-runtime.js'; import { createSessionRuntime } from './lib/opencode/session-runtime.js'; import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; +import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js'; @@ -713,6 +714,12 @@ const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSen const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args); clearPendingPushBadge = () => notificationTriggerRuntime.clearPendingPushBadge(); +const sessionAssistRuntime = createSessionAssistRuntime({ + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getSmallModelService: async () => import('./lib/small-model/index.js'), +}); + const globalMessageStreamHub = createGlobalMessageStreamHub({ buildOpenCodeUrl, getOpenCodeAuthHeaders, @@ -732,6 +739,19 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ }, }); +// Session-assist subscribes to the hub directly: it needs the envelope's +// directory to route its own OpenCode calls to the right instance. +console.log('[session-assist] listening for session events'); +globalMessageStreamHub.subscribeEvent((event) => { + const raw = event?.payload; + const payload = raw?.payload && typeof raw.payload === 'object' ? raw.payload : raw; + if (!payload || typeof payload !== 'object') return; + const directory = typeof event?.directory === 'string' && event.directory && event.directory !== 'global' + ? event.directory + : ''; + sessionAssistRuntime.processPayload(payload, directory); +}); + const processForwardedEventPayload = (payload, emitSyntheticEvent) => { if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') { return; @@ -1014,11 +1034,12 @@ const bootstrapOpenCodeAtStartup = async (...args) => { if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) { startHealthMonitoring(); } - if (ENV_DESKTOP_NOTIFY) { - void ensureGlobalWatcherStarted().catch((error) => { - console.warn(`Global event watcher startup failed: ${error?.message || error}`); - }); - } + // The global watcher used to start only for desktop notifications; the + // session-assist runtime also rides its event hub, so it now starts + // unconditionally once OpenCode is up. + void ensureGlobalWatcherStarted().catch((error) => { + console.warn(`Global event watcher startup failed: ${error?.message || error}`); + }); }; const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); const waitForPortRelease = (...args) => openCodeLifecycleRuntime.waitForPortRelease(...args); @@ -1037,6 +1058,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({ }, syncToHmrState, openCodeWatcherRuntime, + sessionAssistRuntime, sessionRuntime, getHealthCheckInterval: () => healthCheckInterval, clearHealthCheckInterval: (value) => clearInterval(value), diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 697fe52843..1c203ed1b6 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -759,6 +759,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || req.path.startsWith('/api/session-folders') || + req.path.startsWith('/api/small-model') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || req.path.startsWith('/api/tts') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 25de723f59..2814771715 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -1,5 +1,6 @@ import { registerFsRoutes } from '../fs/routes.js'; import { registerQuotaRoutes } from '../quota/routes.js'; +import { registerSmallModelRoutes } from '../small-model/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; @@ -54,6 +55,14 @@ export const createFeatureRoutesRuntime = (dependencies) => { return quotaProviders; }; + let smallModelService = null; + const getSmallModelService = async () => { + if (!smallModelService) { + smallModelService = await import('../small-model/index.js'); + } + return smallModelService; + }; + const registerRoutes = async (app, routeDependencies) => { const { crypto, @@ -226,6 +235,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { }); registerQuotaRoutes(app, { getQuotaProviders }); + registerSmallModelRoutes(app, { getSmallModelService }); registerGitHubRoutes(app); registerGitRoutes(app); registerMagicPromptRoutes(app, { diff --git a/packages/web/server/lib/opencode/models-metadata.js b/packages/web/server/lib/opencode/models-metadata.js new file mode 100644 index 0000000000..0d37a62998 --- /dev/null +++ b/packages/web/server/lib/opencode/models-metadata.js @@ -0,0 +1,61 @@ +const MODELS_DEV_API_URL = 'https://models.dev/api.json'; +const DEFAULT_TTL_MS = 10 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 8000; + +// Shared in-process cache of the models.dev catalog. Used by the +// /api/openchamber/models-metadata route and the small-model resolver so the +// server fetches the catalog once, not per consumer. +let cachedMetadata = null; +let cachedAt = 0; +let inflight = null; + +const fetchCatalog = async (url, timeoutMs) => { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`models.dev responded with status ${response.status}`); + } + const metadata = await response.json(); + if (!metadata || typeof metadata !== 'object') { + throw new Error('models.dev returned an unexpected payload'); + } + return metadata; +}; + +/** + * Returns the models.dev catalog, serving the in-memory copy while fresh. + * On fetch failure a stale cached copy is returned when available; otherwise + * the error propagates. + */ +export async function getModelsMetadata({ + url = MODELS_DEV_API_URL, + ttlMs = DEFAULT_TTL_MS, + timeoutMs = DEFAULT_TIMEOUT_MS, +} = {}) { + const now = Date.now(); + if (cachedMetadata && now - cachedAt < ttlMs) { + return { metadata: cachedMetadata, fromCache: true }; + } + + if (!inflight) { + inflight = fetchCatalog(url, timeoutMs).finally(() => { + inflight = null; + }); + } + + try { + const metadata = await inflight; + cachedMetadata = metadata; + cachedAt = Date.now(); + return { metadata, fromCache: false }; + } catch (error) { + if (cachedMetadata) { + return { metadata: cachedMetadata, fromCache: true, stale: true }; + } + throw error; + } +} + +export { MODELS_DEV_API_URL }; diff --git a/packages/web/server/lib/opencode/openchamber-routes.js b/packages/web/server/lib/opencode/openchamber-routes.js index 08b22790db..21694abb9f 100644 --- a/packages/web/server/lib/opencode/openchamber-routes.js +++ b/packages/web/server/lib/opencode/openchamber-routes.js @@ -13,9 +13,6 @@ export const registerOpenChamberRoutes = (app, dependencies) => { getCachedZenModels, } = dependencies; - let cachedModelsMetadata = null; - let cachedModelsMetadataTimestamp = 0; - app.get('/api/openchamber/update-check', async (req, res) => { try { const { checkForUpdates } = await import('../package-manager.js'); @@ -254,48 +251,18 @@ export const registerOpenChamberRoutes = (app, dependencies) => { }); app.get('/api/openchamber/models-metadata', async (_req, res) => { - const now = Date.now(); - - if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) { - res.setHeader('Cache-Control', 'public, max-age=60'); - return res.json(cachedModelsMetadata); - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - try { - const response = await fetch(modelsDevApiUrl, { - signal: controller?.signal, - headers: { - Accept: 'application/json' - } + const { getModelsMetadata } = await import('./models-metadata.js'); + const { metadata, fromCache, stale } = await getModelsMetadata({ + url: modelsDevApiUrl, + ttlMs: modelsMetadataCacheTtl, }); - - if (!response.ok) { - throw new Error(`models.dev responded with status ${response.status}`); - } - - const metadata = await response.json(); - cachedModelsMetadata = metadata; - cachedModelsMetadataTimestamp = Date.now(); - - res.setHeader('Cache-Control', 'public, max-age=300'); + res.setHeader('Cache-Control', fromCache && !stale ? 'public, max-age=60' : 'public, max-age=300'); res.json(metadata); } catch (error) { console.warn('Failed to fetch models.dev metadata via server:', error); - - if (cachedModelsMetadata) { - res.setHeader('Cache-Control', 'public, max-age=60'); - res.json(cachedModelsMetadata); - } else { - const statusCode = error?.name === 'AbortError' ? 504 : 502; - res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); - } - } finally { - if (timeout) { - clearTimeout(timeout); - } + const statusCode = error?.name === 'TimeoutError' || error?.name === 'AbortError' ? 504 : 502; + res.status(statusCode).json({ error: 'Failed to retrieve model metadata' }); } }); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 087b7f8a3c..e868449e77 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -245,6 +245,9 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.showReasoningTraces === 'boolean') { result.showReasoningTraces = candidate.showReasoningTraces; } + if (typeof candidate.sessionAssistEnabled === 'boolean') { + result.sessionAssistEnabled = candidate.sessionAssistEnabled; + } if (typeof candidate.collapsibleThinkingBlocks === 'boolean') { result.collapsibleThinkingBlocks = candidate.collapsibleThinkingBlocks; } @@ -374,6 +377,13 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.defaultAgent.trim(); result.defaultAgent = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.smallModelUseDefault === 'boolean') { + result.smallModelUseDefault = candidate.smallModelUseDefault; + } + if (typeof candidate.smallModelOverride === 'string') { + const trimmed = candidate.smallModelOverride.trim(); + result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.defaultGitIdentityId === 'string') { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js index 6f568649fc..acb6e48b97 100644 --- a/packages/web/server/lib/opencode/shutdown-runtime.js +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -8,6 +8,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { syncToHmrState, openCodeWatcherRuntime, sessionRuntime, + sessionAssistRuntime, scheduledTasksRuntime, getHealthCheckInterval, clearHealthCheckInterval, @@ -41,6 +42,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { openCodeWatcherRuntime.stop(); sessionRuntime.dispose(); + sessionAssistRuntime?.stop?.(); scheduledTasksRuntime?.stop?.(); const healthCheckInterval = getHealthCheckInterval(); diff --git a/packages/web/server/lib/session-assist/DOCUMENTATION.md b/packages/web/server/lib/session-assist/DOCUMENTATION.md new file mode 100644 index 0000000000..72f2bcf14c --- /dev/null +++ b/packages/web/server/lib/session-assist/DOCUMENTATION.md @@ -0,0 +1,64 @@ +# Session Assist + +Server-side watcher that generates a short recap of the agent's last reply +and one suggested user follow-up with the small model +(`lib/small-model`), storing both on the session's metadata under +`metadata.openchamber.assist`. + +## Flow + +1. `createSessionAssistRuntime` is a consumer of the server's global SSE + fan-out (`index.js` → `onPayload`), riding the same upstream connection as + notifications. Purely event-driven — dormant sessions never generate + anything, there is no backfill and no session scanning. +2. `session.status: idle` arms a 60-second per-session timer; any `busy`/ + `retry` status or a user `message.updated` clears it (the "1 minute of + quiet" rule). +3. On fire: fetch the session (skip sub-agent sessions with `parentID`), + take the LAST exchange only — the final assistant reply plus the user + message it answered (assistant `parentID` → user id) — and call + `generateSmallModelText` with the + session's own provider/model taken from the last assistant message — so + the utility call spends the same subscription as the conversation. + `restrictToPreferredProvider` forbids the resolver's global fallback: + conversation content never goes to a provider the user didn't pick for + the session, unless the small model was chosen explicitly (settings + override or opencode config). A resolver 404 is silently skipped. +4. The `{recap, suggestion}` JSON is clamped and PATCHed onto the session + metadata together with `forMessageID` (the last assistant message id) and + `generatedAt`. Before writing, the session tail is re-checked (a stale + result is dropped) and the metadata is merged from a fresh session read so + concurrent metadata writes made during generation are preserved. + +## Settings gate + +`sessionAssistEnabled` in OpenChamber settings (Settings → Chat, default on) +is a hard generation switch checked at fire time: when off, no small-model +calls run and nothing is written. Existing payloads keep rendering and can +still be dismissed — the switch is about generation, not visibility. + +## Freshness contract (no clearing writes) + +Clients do not need the payload to be deleted: they render it only while +`assist.forMessageID` still equals the session's last assistant message id +(and the session is idle). Any new message invalidates the payload +everywhere instantly and offline; the next idle cycle overwrites it. + +## UI consumers (packages/ui) + +- `lib/sessionAssistMetadata.ts` — payload parsing. +- `hooks/useSessionAssist.ts` — freshness gating + the 5-minute quiet window + for the recap (single timeout to the boundary, no polling). +- `components/chat/SessionRecapSpacer.tsx` — renders the recap inside the + fixed-height reserved gap under the last message (height never changes). +- `components/chat/SessionSuggestionChip.tsx` — one tappable suggestion chip + near the composer (desktop chips row + above the mobile pill); hidden as + soon as the composer has any content. Tap fills the input, never sends. + +## Limitations + +- The watcher lives in the web server, so VS Code (extension-only, no web + server) does not generate assists; it still renders payloads produced by a + web/desktop instance of the same OpenCode server via `session.updated`. +- Metadata payloads ride every `session.updated` event — keep the clamps + (`RECAP_CHAR_LIMIT`, `SUGGESTION_CHAR_LIMIT`) small. diff --git a/packages/web/server/lib/session-assist/runtime.js b/packages/web/server/lib/session-assist/runtime.js new file mode 100644 index 0000000000..5843c2891a --- /dev/null +++ b/packages/web/server/lib/session-assist/runtime.js @@ -0,0 +1,349 @@ +// Session assist: after a session goes idle and stays quiet, generate a short +// recap of the agent's last reply plus one suggested user follow-up with the +// small model, and store both on the session's metadata +// (metadata.openchamber.assist). Clients decide visibility from +// assist.forMessageID — a new message makes the payload stale everywhere +// without any extra writes. +// +// Purely event-driven: only sessions that transition busy→idle while the +// server is running ever generate anything. No backfill, no session scans. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const OPENCHAMBER_SETTINGS_FILE = path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'settings.json', +); + +// The Chat setting is a hard generation switch (default on): when off, no +// small-model calls and no metadata writes happen at all. Existing payloads +// stay untouched — clients keep showing them and dismissal still works. +const isSessionAssistEnabled = () => { + try { + const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(raw); + return settings?.sessionAssistEnabled !== false; + } catch { + return true; + } +}; + +const IDLE_QUIET_MS = 60_000; +const TRANSCRIPT_MESSAGE_LIMIT = 12; +const TRANSCRIPT_PART_CHAR_LIMIT = 6_000; +const RECAP_CHAR_LIMIT = 320; +const SUGGESTION_CHAR_LIMIT = 500; +const FETCH_TIMEOUT_MS = 5_000; + +const ASSIST_SYSTEM_PROMPT = [ + 'You assist a user who chats with a coding agent. Based on the conversation transcript, return exactly one JSON object and nothing else — no prose, no markdown, no code fences.', + 'Shape: {"recap": string, "suggestion": string}', + 'recap: at most 20 words. State the substance directly — the facts, result, or conclusion, plus the next move if there is one. NEVER narrate ("The assistant explained…", "The agent did…") — write the content itself, like a note the user jotted down.', + 'suggestion: the next message to send in this conversation, addressed TO the agent — a concise instruction or question that moves the work forward, e.g. "Run the tests and fix failures" / "Commit this". Imperative or question form. Never explain, never offer help, never say "you can".', + 'Both values MUST be written in the same language as the conversation text itself. Ignore any other language preferences or personalization you may have — only the conversation text decides the language.', + 'Use double quotes for JSON strings, no trailing commas.', +].join('\n'); + +const extractJsonObject = (value) => { + const text = String(value ?? '').trim(); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = (fenced?.[1] ?? text).trim(); + const start = candidate.indexOf('{'); + if (start < 0) return null; + for (let end = candidate.length; end > start; end -= 1) { + if (candidate[end - 1] !== '}') continue; + try { + const parsed = JSON.parse(candidate.slice(start, end)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch { + // keep scanning — models wrap JSON in prose sometimes + } + } + return null; +}; + +const extractSessionStatus = (payload) => { + if (!payload || payload.type !== 'session.status') return null; + const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; + const status = properties.status && typeof properties.status === 'object' ? properties.status : {}; + const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; + const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; + const type = typeof status.type === 'string' + ? status.type.trim() + : (typeof info.type === 'string' ? info.type.trim() : ''); + if (!sessionId || !type) return null; + const directory = typeof properties.directory === 'string' && properties.directory + ? properties.directory + : (typeof info.directory === 'string' ? info.directory : ''); + return { sessionId, type, directory }; +}; + +const extractUserMessage = (payload) => { + if (!payload || payload.type !== 'message.updated') return null; + const info = payload.properties?.info; + if (!info || typeof info !== 'object' || info.role !== 'user') return null; + if (typeof info.sessionID !== 'string' || !info.sessionID) return null; + return { + sessionId: info.sessionID, + createdAt: typeof info.time?.created === 'number' ? info.time.created : 0, + }; +}; + +const messagePartsToText = (message) => { + const parts = Array.isArray(message?.parts) ? message.parts : []; + return parts + .map((part) => (part?.type === 'text' && typeof part.text === 'string' ? part.text : '')) + .filter(Boolean) + .join('\n') + .slice(0, TRANSCRIPT_PART_CHAR_LIMIT); +}; + +export const createSessionAssistRuntime = ({ + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + getSmallModelService, + quietMs = IDLE_QUIET_MS, +}) => { + const timers = new Map(); + const inflight = new Set(); + let stopped = false; + + const clearTimer = (sessionId) => { + const existing = timers.get(sessionId); + if (existing) { + clearTimeout(existing.timer); + timers.delete(sessionId); + } + }; + + const openCodeFetch = async (path, { directory, method = 'GET', body } = {}) => { + const base = buildOpenCodeUrl(path, ''); + const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base; + const response = await fetch(url, { + method, + headers: { + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...getOpenCodeAuthHeaders(), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`OpenCode ${method} ${path} failed with ${response.status}`); + } + return response.json().catch(() => null); + }; + + const fetchRecentMessages = async (sessionId, directory) => { + const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, ''); + const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) }); + if (directory) params.set('directory', directory); + const response = await fetch(`${base}?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const messages = await response.json().catch(() => null); + return Array.isArray(messages) ? messages : null; + }; + + const generateAssist = async (sessionId, directory) => { + if (!isSessionAssistEnabled()) return; + const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory }) + .catch((error) => { + console.warn(`[session-assist] session fetch failed: ${error?.message || error}`); + return null; + }); + if (!session || typeof session !== 'object') return; + // Sub-agent/task sessions never surface in chat — skip them. + if (typeof session.parentID === 'string' && session.parentID) return; + + const messages = await fetchRecentMessages(sessionId, directory); + if (!messages || messages.length === 0) { + console.warn('[session-assist] no messages fetched'); + return; + } + + let lastAssistant = null; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const info = messages[i]?.info; + if (info?.role === 'assistant') { + lastAssistant = messages[i]; + break; + } + } + const lastAssistantInfo = lastAssistant?.info; + if (!lastAssistantInfo?.id) return; + + // Only the last exchange: the assistant reply plus the user message it + // answered (assistant info.parentID → user info.id). Everything else is + // token waste for a one-line recap and a single suggestion. + const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID + ? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user') + : null; + const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : ''; + const assistantText = messagePartsToText(lastAssistant); + const transcript = [ + userText ? `User:\n${userText}` : '', + assistantText ? `Assistant:\n${assistantText}` : '', + ].filter(Boolean).join('\n\n'); + if (!transcript) return; + + const { generateSmallModelText } = await getSmallModelService(); + // Instruct the language by example, not by description — account-side + // personalization (e.g. the ChatGPT backend knowing the user's locale) + // otherwise leaks a different language into the output. + const languageSample = (userText || assistantText).slice(0, 200).replace(/\s+/g, ' ').trim(); + let generated; + try { + generated = await generateSmallModelText({ + // Background feature: conversation content must never leave the + // session's own provider unless the user explicitly picked a small + // model (settings override / opencode config). + restrictToPreferredProvider: true, + prompt: `The latest exchange in the conversation:\n\n${transcript}\n\nWrite recap and suggestion in the SAME language as this sample from the conversation: "${languageSample}"`, + system: ASSIST_SYSTEM_PROMPT, + directory, + preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined, + preferredModelID: typeof lastAssistantInfo.modelID === 'string' ? lastAssistantInfo.modelID : undefined, + }); + } catch (error) { + // No authenticated provider (404) or a transient model failure — this is + // background sugar, never retry loops or logs spam. + if (Number(error?.statusCode) !== 404) { + console.warn('[session-assist] generation failed:', error?.message || error); + } + return; + } + + const structured = extractJsonObject(generated?.text); + let recap = typeof structured?.recap === 'string' ? structured.recap.trim().slice(0, RECAP_CHAR_LIMIT) : ''; + let suggestion = typeof structured?.suggestion === 'string' ? structured.suggestion.trim().slice(0, SUGGESTION_CHAR_LIMIT) : ''; + + // Hard guard against language hallucination: if the conversation contains + // no Cyrillic/CJK at all, the output must not either (and drop per-field, + // so one hallucinated field doesn't kill the other). + const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text); + const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text); + const inputText = `${userText}\n${assistantText}`; + const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText)) + || (hasCjk(text) && !hasCjk(inputText)); + if (recap && scriptMismatch(recap)) { + console.warn('[session-assist] dropped recap: language mismatch with conversation'); + recap = ''; + } + if (suggestion && scriptMismatch(suggestion)) { + console.warn('[session-assist] dropped suggestion: language mismatch with conversation'); + suggestion = ''; + } + if (!recap && !suggestion) return; + + // The session may have moved on while we generated — a stale patch would + // flash outdated content, so re-check the tail before writing. + const latest = await fetchRecentMessages(sessionId, directory); + const latestAssistantId = (() => { + if (!latest) return null; + for (let i = latest.length - 1; i >= 0; i -= 1) { + const info = latest[i]?.info; + if (info?.role === 'assistant') return info.id; + if (info?.role === 'user') return null; + } + return null; + })(); + if (latestAssistantId !== lastAssistantInfo.id) { + console.log('[session-assist] tail moved on, dropping result'); + return; + } + + // Merge from a FRESH read: generation takes tens of seconds, and merging + // from the session snapshot fetched before it would clobber any metadata + // written meanwhile (suggestion dismissals, review links, …). + const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory }) + .catch(() => null); + const currentMetadata = freshSession?.metadata && typeof freshSession.metadata === 'object' + ? freshSession.metadata + : (session.metadata && typeof session.metadata === 'object' ? session.metadata : {}); + const currentNamespace = currentMetadata.openchamber && typeof currentMetadata.openchamber === 'object' + ? currentMetadata.openchamber + : {}; + + console.log(`[session-assist] generated for ${sessionId} via ${generated.providerID}/${generated.modelID}`); + await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { + directory, + method: 'PATCH', + body: { + metadata: { + ...currentMetadata, + openchamber: { + ...currentNamespace, + assist: { + recap, + suggestion, + forMessageID: lastAssistantInfo.id, + generatedAt: Date.now(), + }, + }, + }, + }, + }); + }; + + const armTimer = (sessionId, directory) => { + clearTimer(sessionId); + const timer = setTimeout(() => { + timers.delete(sessionId); + if (stopped || inflight.has(sessionId)) return; + inflight.add(sessionId); + generateAssist(sessionId, directory) + .catch((error) => { + console.warn('[session-assist] failed:', error?.message || error); + }) + .finally(() => { + inflight.delete(sessionId); + }); + }, quietMs); + if (typeof timer?.unref === 'function') timer.unref(); + timers.set(sessionId, { timer, armedAt: Date.now() }); + }; + + const processPayload = (payload, directoryHint = '') => { + if (stopped) return; + const status = extractSessionStatus(payload); + if (status) { + if (status.type === 'idle') { + armTimer(status.sessionId, status.directory || directoryHint); + } else { + clearTimer(status.sessionId); + } + return; + } + const userMessage = extractUserMessage(payload); + if (userMessage) { + // OpenCode re-emits message.updated for OLD user messages after the + // session settles (post-completion metadata patches). Only a message + // created after the timer was armed means the user actually moved on. + const armed = timers.get(userMessage.sessionId); + if (armed && userMessage.createdAt >= armed.armedAt) { + clearTimer(userMessage.sessionId); + } + } + }; + + const stop = () => { + stopped = true; + for (const { timer } of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + + return { processPayload, stop }; +}; diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md new file mode 100644 index 0000000000..c757d07b7b --- /dev/null +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -0,0 +1,78 @@ +# Small Model + +Server-side direct LLM calls that reuse the user's existing OpenCode provider +logins (`~/.local/share/opencode/auth.json`). OpenCode uses a "small model" +internally (titles, summaries) but does not expose it through the SDK or +plugins — this module replicates that mechanism as an OpenChamber runtime API. + +## Security boundary + +Credentials never leave the server process. The client sends only a prompt; +auth resolution, OAuth refresh, and provider dispatch all happen server-side. +Routes live under `/api/*` and are gated by the ui-auth middleware like every +other runtime API. + +## Files + +- `index.js` — orchestration: `generateSmallModelText()` / `describeSmallModel()`. +- `resolve.js` — model selection, mirroring OpenCode's `getSmallModel` chain: + 0. OpenChamber's own settings override (Settings → Sessions → Small Model): + when `smallModelUseDefault` is `false`, `smallModelOverride` + (`provider/model`) outranks everything below. Sanitized in + `settings-helpers.js` (server), `persistence.ts` (client), and + `bridge-settings-runtime.ts` (VS Code). + 1. `small_model` from the merged OpenCode config layers (`provider/model`). + 2. Family-priority scan (`gemini-flash` → `gpt-nano` → `claude-haiku`) + **within the session's provider first** (`preferredProviderID`, like + OpenCode resolves within the current provider), then over the other + providers with a usable auth entry, newest `release_date` first. + 3. GitHub Copilot hidden utility models (`gpt-*-nano/mini`) — these never + appear in the catalog, so they participate as the `gpt-nano` family entry + and as a final utility fallback. + 4. Last resort: the session's own model (`preferredModelID`) when no small + model resolves anywhere — costlier, but always valid. +- Input clamp: the prompt is truncated to the resolved model's catalog + `limit.context` (minus an output reserve, ~4 chars/token estimate; + conservative default when the model is not in the catalog). Truncation is + reported as `inputTruncated: true` in the response. +- `call.js` — wire formats and per-provider auth, replicating OpenCode's + plugin auth loaders: + - **GitHub Copilot**: OpenAI-compatible `/chat/completions` on + `https://api.githubcopilot.com` (or `copilot-api.`) with the + stored device-OAuth token as the bearer — no token exchange, no expiry. + - **OpenAI OAuth (ChatGPT plan)**: streaming Responses API on + `https://chatgpt.com/backend-api/codex/responses` with + `ChatGPT-Account-Id`; expired tokens are refreshed against + `auth.openai.com` (single-flight) and written back to `auth.json`. + - **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`. + - **Google** (`type: api`): `generateContent` with `x-goog-api-key`. + - Everything else: OpenAI-compatible `/chat/completions` against the + provider's models.dev base URL with `Authorization: Bearer `. +- `catalog.js` — models.dev catalog via the shared in-process cache + (`../opencode/models-metadata.js`, also serving + `/api/openchamber/models-metadata`). +- `routes.js` — `GET /api/small-model` (resolution preview) and + `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?, + model?, directory? }` → `{ text, providerID, modelID, source }`). + +## Registration + +Mounted lazily from `feature-routes-runtime.js` (same pattern as quota): the +module is imported on first request, not at server startup. + +## Known limitations + +- OpenCode's free models (`opencode/big-pickle`, `*-free`) work without a + token only through OpenCode's own server — direct calls are rejected, and + piggybacking on their subsidized infra is out of bounds by design. Every + resolution step therefore requires a usable auth entry for the provider: + a session on an unauthenticated `opencode` provider falls through to the + global scan (or a clean 404 on a vanilla setup with no logins). + +- Anthropic OAuth (Claude Pro/Max) entries are not supported — OpenCode itself + keeps those outside `auth.json` in this generation; only `type: api` keys + work for Anthropic. +- Amazon Bedrock, GitLab, Azure and other credential-chain providers are out + of scope; they need more than a key/token (regions, resource names). +- Responses from the codex backend are collected from the SSE stream; the + endpoint itself is non-streaming by design (small utility calls). diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js new file mode 100644 index 0000000000..8fd8116669 --- /dev/null +++ b/packages/web/server/lib/small-model/call.js @@ -0,0 +1,380 @@ +import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; +import { getCatalogProvider } from './catalog.js'; +import { getAuthEntryForProvider } from './resolve.js'; + +// Direct, non-streaming text generation against the provider APIs, replicating +// how OpenCode authenticates each of them (see the plugin auth loaders in the +// opencode repo). auth.json credentials never leave this process. + +const REQUEST_TIMEOUT_MS = 60_000; +// Generous default: thinking models that can't be switched off (DeepSeek, +// Qwen, …) spend part of this budget on reasoning before the actual answer. +const DEFAULT_MAX_OUTPUT_TOKENS = 4_000; + +const USER_AGENT = 'opencode/1.0 openchamber'; + +const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'; + +const httpError = async (response, provider) => { + const body = await response.text().catch(() => ''); + const snippet = body ? `: ${body.slice(0, 300)}` : ''; + return new Error(`${provider} request failed with ${response.status}${snippet}`); +}; + +// --------------------------------------------------------------------------- +// OpenAI OAuth (ChatGPT plan / codex) token refresh — single-flight, with the +// refreshed token written back to auth.json exactly like OpenCode does. +// --------------------------------------------------------------------------- + +let openaiRefreshPromise = null; + +const decodeJwtClaims = (token) => { + try { + const payload = token.split('.')[1]; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + } catch { + return null; + } +}; + +const extractChatgptAccountId = (accessToken) => { + const claims = decodeJwtClaims(accessToken); + const auth = claims?.['https://api.openai.com/auth']; + const value = auth?.chatgpt_account_id; + return typeof value === 'string' && value ? value : null; +}; + +const refreshOpenaiOauth = async (entry) => { + if (!openaiRefreshPromise) { + openaiRefreshPromise = (async () => { + const response = await fetch(CODEX_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: entry.refresh, + client_id: CODEX_CLIENT_ID, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw await httpError(response, 'OpenAI token refresh'); + } + const payload = await response.json(); + const access = typeof payload?.access_token === 'string' ? payload.access_token : ''; + if (!access) { + throw new Error('OpenAI token refresh returned no access token'); + } + const refreshed = { + ...entry, + type: 'oauth', + access, + refresh: typeof payload?.refresh_token === 'string' && payload.refresh_token + ? payload.refresh_token + : entry.refresh, + expires: Date.now() + (Number(payload?.expires_in) > 0 ? Number(payload.expires_in) : 3600) * 1000, + }; + const auth = readAuthFile(); + auth.openai = refreshed; + writeAuthFile(auth); + return refreshed; + })().finally(() => { + openaiRefreshPromise = null; + }); + } + return openaiRefreshPromise; +}; + +const ensureFreshOpenaiOauth = async (entry) => { + if (entry.access && Number(entry.expires) > Date.now()) { + return entry; + } + if (!entry.refresh) { + throw new Error('OpenAI OAuth entry has no refresh token'); + } + return refreshOpenaiOauth(entry); +}; + +// --------------------------------------------------------------------------- +// Wire formats +// --------------------------------------------------------------------------- + +const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => { + const trimmedBase = baseURL.replace(/\/+$/, ''); + const response = await fetch(`${trimmedBase}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, + }, + body: JSON.stringify({ + model: modelID, + messages: [ + ...(system ? [{ role: 'system', content: system }] : []), + { role: 'user', content: prompt }, + ], + max_tokens: maxOutputTokens, + stream: false, + ...(extraBody || {}), + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, providerLabel); + } + const payload = await response.json(); + const message = payload?.choices?.[0]?.message; + + // Providers disagree on the content shape: plain string, an array of + // typed parts, or (thinking models) an empty content with the budget spent + // on reasoning_content. + let text = ''; + if (typeof message?.content === 'string') { + text = message.content; + } else if (Array.isArray(message?.content)) { + text = message.content + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); + } + if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) { + const finishReason = payload?.choices?.[0]?.finish_reason; + throw new Error( + `${providerLabel} spent the output budget on reasoning and returned no answer` + + (finishReason ? ` (finish_reason: ${finishReason})` : ''), + ); + } + if (!text.trim()) { + throw new Error(`${providerLabel} returned no message content`); + } + return text; +}; + +const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: modelID, + max_tokens: maxOutputTokens, + ...(system ? { system } : {}), + messages: [{ role: 'user', content: prompt }], + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'Anthropic'); + } + const payload = await response.json(); + const text = (payload?.content || []) + .filter((part) => part?.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join(''); + if (!text) { + throw new Error('Anthropic returned no text content'); + } + return text; +}; + +const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': apiKey, + }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), + // thinkingBudget 0 switches Gemini Flash thinking off; Flash is the only + // family the small-model resolver picks for Google. + generationConfig: { maxOutputTokens, thinkingConfig: { thinkingBudget: 0 } }, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'Google'); + } + const payload = await response.json(); + const text = (payload?.candidates?.[0]?.content?.parts || []) + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); + if (!text) { + throw new Error('Google returned no text content'); + } + return text; +}; + +// ChatGPT-plan traffic goes to the codex backend, which only speaks the +// streaming Responses API — collect the output_text deltas from the SSE body. +const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => { + const response = await fetch(CODEX_RESPONSES_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + Authorization: `Bearer ${accessToken}`, + ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}), + originator: 'opencode', + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ + model: modelID, + ...(system ? { instructions: system } : {}), + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: prompt }], + }, + ], + // The codex backend rejects max_output_tokens (OpenCode forces it to + // undefined for this provider too). + stream: true, + store: false, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'OpenAI (ChatGPT plan)'); + } + + const raw = await response.text(); + let text = ''; + let completedText = ''; + for (const line of raw.split('\n')) { + if (!line.startsWith('data:')) continue; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') { + text += event.delta; + } + if (event?.type === 'response.output_text.done' && typeof event.text === 'string') { + completedText = event.text; + } + if (event?.type === 'response.failed' || event?.type === 'error') { + const message = event?.response?.error?.message || event?.message || 'response failed'; + throw new Error(`OpenAI (ChatGPT plan) stream error: ${message}`); + } + } + const result = completedText || text; + if (!result) { + throw new Error('OpenAI (ChatGPT plan) returned no text output'); + } + return result; +}; + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) { + const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; + const entry = getAuthEntryForProvider(auth, providerID); + if (!entry) { + throw new Error(`No OpenCode login found for provider "${providerID}"`); + } + + if (providerID === 'github-copilot') { + // OpenCode uses the stored device-OAuth token directly as the bearer — + // access === refresh, no exchange, no expiry. + const token = entry.refresh || entry.access || entry.key; + if (!token) { + throw new Error('GitHub Copilot login has no token'); + } + const baseURL = entry.enterpriseUrl + ? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}` + : 'https://api.githubcopilot.com'; + return callOpenaiCompatible({ + baseURL, + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': USER_AGENT, + 'Openai-Intent': 'conversation-edits', + 'x-initiator': 'agent', + 'X-GitHub-Api-Version': '2026-06-01', + }, + modelID, + prompt, + system, + maxOutputTokens: tokens, + providerLabel: 'GitHub Copilot', + }); + } + + if (providerID === 'openai' && entry.type === 'oauth') { + const fresh = await ensureFreshOpenaiOauth(entry); + return callCodexResponses({ + accessToken: fresh.access, + accountId: fresh.accountId || extractChatgptAccountId(fresh.access), + modelID, + prompt, + system, + }); + } + + const apiKey = entry.type === 'api' ? entry.key + : entry.type === 'wellknown' ? entry.token + : entry.access; + if (!apiKey) { + throw new Error(`OpenCode login for "${providerID}" has no usable credential`); + } + + if (providerID === 'anthropic') { + return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + } + if (providerID === 'google') { + return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + } + + // Everything else: OpenAI-compatible chat completions against the catalog's + // base URL for that provider (openai itself included). + const provider = getCatalogProvider(catalog, providerID); + const baseURL = providerID === 'openai' + ? 'https://api.openai.com/v1' + : typeof provider?.api === 'string' && provider.api + ? provider.api + : null; + if (!baseURL) { + throw new Error(`Provider "${providerID}" has no known API base URL`); + } + + // Thinking models burn the output budget on reasoning and leave content + // empty — disable thinking where a wire-format switch exists (mirrors + // OpenCode's smallOptions/variants special cases). There is NO universal + // parameter: unknown body fields 400 on some providers, so this stays an + // explicit allowlist. Models without a switch (DeepSeek, Qwen, Kimi, …) + // just get the generous output budget. + const lowerModel = modelID.toLowerCase(); + const supportsThinkingToggle = providerID.includes('zai') + || providerID.includes('zhipu') + || lowerModel.includes('glm') + || lowerModel.includes('minimax-m3'); + const extraBody = supportsThinkingToggle ? { thinking: { type: 'disabled' } } : undefined; + + return callOpenaiCompatible({ + baseURL, + headers: { Authorization: `Bearer ${apiKey}` }, + modelID, + prompt, + system, + maxOutputTokens: tokens, + providerLabel: provider?.name || providerID, + extraBody, + }); +} diff --git a/packages/web/server/lib/small-model/catalog.js b/packages/web/server/lib/small-model/catalog.js new file mode 100644 index 0000000000..abfba4c417 --- /dev/null +++ b/packages/web/server/lib/small-model/catalog.js @@ -0,0 +1,13 @@ +import { getModelsMetadata } from '../opencode/models-metadata.js'; + +// The models.dev catalog is shared with the /api/openchamber/models-metadata +// route through one in-process cache — no extra fetches, no cache files. +export async function getModelCatalog() { + const { metadata } = await getModelsMetadata(); + return metadata; +} + +export function getCatalogProvider(catalog, providerID) { + const entry = catalog?.[providerID]; + return entry && typeof entry === 'object' ? entry : null; +} diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js new file mode 100644 index 0000000000..43457c325c --- /dev/null +++ b/packages/web/server/lib/small-model/index.js @@ -0,0 +1,167 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { readAuthFile } from '../opencode/auth.js'; +import { readConfigLayers } from '../opencode/shared.js'; +import { getModelCatalog } from './catalog.js'; +import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js'; +import { callSmallModel } from './call.js'; + +const OPENCHAMBER_SETTINGS_FILE = path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'settings.json', +); + +// OpenChamber's own settings: when the user unchecks "use default small model" +// their explicit override outranks every other resolution step. +const readSmallModelSettingsOverride = () => { + try { + const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(raw); + if (!settings || typeof settings !== 'object') return null; + if (settings.smallModelUseDefault !== false) return null; + const override = typeof settings.smallModelOverride === 'string' ? settings.smallModelOverride.trim() : ''; + return override || null; + } catch { + return null; + } +}; + +// Rough safety clamp so a huge input never blows the model's context window. +// Token estimate is ~4 chars/token; when the catalog has no limit for the +// model (Copilot/codex utility models are not listed) a conservative default +// applies. +const DEFAULT_CONTEXT_TOKENS = 64_000; +const OUTPUT_RESERVE_TOKENS = 4_000; + +const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => { + const limit = catalog?.[providerID]?.models?.[modelID]?.limit; + const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; + const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS); + const maxChars = inputBudgetTokens * 4; + if (prompt.length <= maxChars) { + return { prompt, truncated: false }; + } + return { prompt: `${prompt.slice(0, maxChars)}…`, truncated: true }; +}; + +const readConfiguredSmallModel = (workingDirectory) => { + try { + const { mergedConfig } = readConfigLayers(workingDirectory); + const value = mergedConfig?.small_model; + return typeof value === 'string' ? value : null; + } catch { + return null; + } +}; + +/** + * Generates text with the user's small model, resolved and authenticated + * entirely server-side from the OpenCode config and auth store. + */ +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) { + if (typeof prompt !== 'string' || !prompt.trim()) { + throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); + } + + const auth = readAuthFile(); + const catalog = await getModelCatalog().catch(() => ({})); + + const explicit = parseModelRef(model); + const resolved = explicit + ? { ...explicit, source: 'request' } + : resolveSmallModel({ + auth, + catalog, + settingsSmallModel: readSmallModelSettingsOverride(), + configSmallModel: readConfiguredSmallModel(directory), + preferredProviderID, + preferredModelID, + }); + + if (!resolved) { + throw Object.assign( + new Error('No small model available — no authenticated provider has a suitable model'), + { statusCode: 404 }, + ); + } + + // Callers with a session context can forbid silently switching providers: + // an explicit user choice (settings override, opencode config, request + // model) is always allowed, anything else must stay on the session's + // provider. + if (restrictToPreferredProvider + && !['settings', 'config', 'request'].includes(resolved.source) + && resolved.providerID !== preferredProviderID) { + throw Object.assign( + new Error('No small model available within the session provider'), + { statusCode: 404 }, + ); + } + + const clamped = clampPromptToModelLimit({ + prompt: prompt.trim(), + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + }); + + const text = await callSmallModel({ + auth, + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + prompt: clamped.prompt, + system: typeof system === 'string' && system.trim() ? system.trim() : undefined, + maxOutputTokens, + }); + + return { + text: text.trim(), + providerID: resolved.providerID, + modelID: resolved.modelID, + source: resolved.source, + ...(clamped.truncated ? { inputTruncated: true } : {}), + }; +} + +/** + * Provider ids with a usable OpenCode login — the set the small model can + * actually call. Used by the settings override picker to hide providers that + * would only ever fail (e.g. opencode free models without a token). + */ +export function listAuthenticatedProviders() { + try { + const auth = readAuthFile(); + const ids = new Set( + Object.keys(auth || {}).filter((providerID) => isUsableAuthEntry(auth[providerID])), + ); + // The catalog id is github-copilot while legacy auth entries may sit + // under the copilot alias. + if (isUsableAuthEntry(getAuthEntryForProvider(auth, 'github-copilot'))) { + ids.add('github-copilot'); + } + return Array.from(ids); + } catch { + return []; + } +} + +/** + * Reports which model would be used, without calling it. + */ +export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) { + const auth = readAuthFile(); + const catalog = await getModelCatalog().catch(() => ({})); + const resolved = resolveSmallModel({ + auth, + catalog, + settingsSmallModel: readSmallModelSettingsOverride(), + configSmallModel: readConfiguredSmallModel(directory), + preferredProviderID, + preferredModelID, + }); + return resolved; +} diff --git a/packages/web/server/lib/small-model/resolve.js b/packages/web/server/lib/small-model/resolve.js new file mode 100644 index 0000000000..4d4a252079 --- /dev/null +++ b/packages/web/server/lib/small-model/resolve.js @@ -0,0 +1,131 @@ +import { getCatalogProvider } from './catalog.js'; + +// Mirrors OpenCode's getSmallModel fallback chain: +// 1. `small_model` from the merged config layers ("provider/model"). +// 2. GitHub Copilot's hidden utility models when Copilot is logged in. +// 3. Family-priority scan of the authenticated providers' catalog models. +const FAMILY_PRIORITY = ['gemini-flash', 'gpt-nano', 'claude-haiku']; +const COPILOT_UTILITY_MODELS = ['gpt-5.4-nano', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini']; +// The ChatGPT-plan codex backend only accepts a small allowlist of models +// (nano/API-key models are rejected with 400) — this is its cheapest one. +const OPENAI_OAUTH_SMALL_MODEL = 'gpt-5.4-mini'; + +const AUTH_PROVIDER_ALIASES = { + 'github-copilot': ['github-copilot', 'copilot'], +}; + +export function getAuthEntryForProvider(auth, providerID) { + const aliases = AUTH_PROVIDER_ALIASES[providerID] || [providerID]; + for (const alias of aliases) { + const entry = auth?.[alias]; + if (entry && typeof entry === 'object') { + return entry; + } + } + return null; +} + +export function isUsableAuthEntry(entry) { + if (!entry || typeof entry !== 'object') return false; + if (entry.type === 'api') return typeof entry.key === 'string' && entry.key.length > 0; + if (entry.type === 'oauth') { + return (typeof entry.access === 'string' && entry.access.length > 0) + || (typeof entry.refresh === 'string' && entry.refresh.length > 0); + } + if (entry.type === 'wellknown') return typeof entry.token === 'string' && entry.token.length > 0; + return false; +} + +export function parseModelRef(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const slash = trimmed.indexOf('/'); + if (slash <= 0 || slash === trimmed.length - 1) return null; + return { + providerID: trimmed.slice(0, slash), + modelID: trimmed.slice(slash + 1), + }; +} + +const pickByFamily = (models, family) => { + const matches = Object.values(models) + .filter((model) => model && typeof model === 'object' && model.family === family); + if (matches.length === 0) return null; + matches.sort((a, b) => String(b.release_date || '').localeCompare(String(a.release_date || ''))); + return matches[0]; +}; + +// Small-model candidates within ONE provider, by family priority. Copilot and +// ChatGPT-plan OpenAI have fixed small models that never appear in the +// catalog; everyone else is scanned through the catalog families. +const pickWithinProvider = (providerID, auth, catalog, family) => { + if (providerID === 'openai' && auth.openai?.type === 'oauth') { + return family === 'gpt-nano' + ? { providerID, modelID: OPENAI_OAUTH_SMALL_MODEL, source: 'codex-small' } + : null; + } + if (providerID === 'github-copilot') { + return family === 'gpt-nano' + ? { providerID, modelID: COPILOT_UTILITY_MODELS[0], source: 'copilot-utility' } + : null; + } + const provider = getCatalogProvider(catalog, providerID); + if (!provider || !provider.models || typeof provider.models !== 'object') return null; + const model = pickByFamily(provider.models, family); + return model?.id ? { providerID, modelID: model.id, source: 'family-scan' } : null; +}; + +export function resolveSmallModel({ auth, catalog, settingsSmallModel, configSmallModel, preferredProviderID, preferredModelID }) { + // OpenChamber's own setting (Settings → Sessions → Small Model override) + // outranks everything, including the OpenCode config. + const fromSettings = parseModelRef(settingsSmallModel); + if (fromSettings) { + return { ...fromSettings, source: 'settings' }; + } + + const explicit = parseModelRef(configSmallModel); + if (explicit) { + return { ...explicit, source: 'config' }; + } + + // Like OpenCode: when the caller has a session context, the utility call + // stays on the session's provider. Scan its families for a small model, + // otherwise run on the session's own model — never silently switch to a + // different provider's subscription. + const preferred = typeof preferredProviderID === 'string' && preferredProviderID + ? preferredProviderID + : null; + if (preferred && isUsableAuthEntry(getAuthEntryForProvider(auth, preferred))) { + for (const family of FAMILY_PRIORITY) { + const match = pickWithinProvider(preferred, auth, catalog, family); + if (match) return match; + } + if (typeof preferredModelID === 'string' && preferredModelID) { + return { providerID: preferred, modelID: preferredModelID, source: 'session-model' }; + } + } + + // No session context (or its provider has no usable login): scan all + // authenticated providers by family priority. + const authedProviders = Object.keys(auth || {}).filter((providerID) => + providerID !== preferred && isUsableAuthEntry(auth[providerID])); + + for (const family of FAMILY_PRIORITY) { + for (const providerID of authedProviders) { + const match = pickWithinProvider(providerID, auth, catalog, family); + if (match) return match; + } + } + + // Copilot's utility fallback for legacy auth aliases the loop above missed. + const copilotEntry = getAuthEntryForProvider(auth, 'github-copilot'); + if (isUsableAuthEntry(copilotEntry)) { + return { + providerID: 'github-copilot', + modelID: COPILOT_UTILITY_MODELS[0], + source: 'copilot-utility', + }; + } + + return null; +} diff --git a/packages/web/server/lib/small-model/resolve.test.js b/packages/web/server/lib/small-model/resolve.test.js new file mode 100644 index 0000000000..5e5d1299c7 --- /dev/null +++ b/packages/web/server/lib/small-model/resolve.test.js @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'bun:test'; +import { resolveSmallModel, parseModelRef, isUsableAuthEntry } from './resolve.js'; + +const catalog = { + google: { + id: 'google', + models: { + 'gemini-2.5-flash': { id: 'gemini-2.5-flash', family: 'gemini-flash', release_date: '2025-06-01' }, + 'gemini-2.0-flash': { id: 'gemini-2.0-flash', family: 'gemini-flash', release_date: '2024-12-01' }, + 'gemini-2.5-pro': { id: 'gemini-2.5-pro', family: 'gemini-pro', release_date: '2025-06-01' }, + }, + }, + anthropic: { + id: 'anthropic', + models: { + 'claude-haiku-4-5': { id: 'claude-haiku-4-5', family: 'claude-haiku', release_date: '2025-10-01' }, + 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', family: 'claude-sonnet', release_date: '2025-09-01' }, + }, + }, +}; + +describe('parseModelRef', () => { + it('splits provider/model on the first slash', () => { + expect(parseModelRef('anthropic/claude-haiku-4-5')).toEqual({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + }); + }); + + it('keeps slashes inside the model id', () => { + expect(parseModelRef('openrouter/google/gemini-2.5-flash')).toEqual({ + providerID: 'openrouter', + modelID: 'google/gemini-2.5-flash', + }); + }); + + it('rejects values without a provider or model part', () => { + expect(parseModelRef('anthropic/')).toBeNull(); + expect(parseModelRef('/model')).toBeNull(); + expect(parseModelRef('plain')).toBeNull(); + expect(parseModelRef(undefined)).toBeNull(); + }); +}); + +describe('isUsableAuthEntry', () => { + it('accepts api keys, oauth tokens, and wellknown tokens', () => { + expect(isUsableAuthEntry({ type: 'api', key: 'sk-x' })).toBe(true); + expect(isUsableAuthEntry({ type: 'oauth', access: 'a', refresh: 'r', expires: 0 })).toBe(true); + expect(isUsableAuthEntry({ type: 'wellknown', key: 'k', token: 't' })).toBe(true); + }); + + it('rejects empty or malformed entries', () => { + expect(isUsableAuthEntry({ type: 'api', key: '' })).toBe(false); + expect(isUsableAuthEntry({ type: 'oauth' })).toBe(false); + expect(isUsableAuthEntry(null)).toBe(false); + }); +}); + +describe('resolveSmallModel', () => { + it('gives the OpenChamber settings override top priority', () => { + const result = resolveSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-x' } }, + catalog, + settingsSmallModel: 'anthropic/claude-haiku-4-5', + configSmallModel: 'openai/gpt-4o-mini', + preferredProviderID: 'anthropic', + }); + expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'settings' }); + }); + + it('prefers the configured small_model', () => { + const result = resolveSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-x' } }, + catalog, + configSmallModel: 'openai/gpt-4o-mini', + }); + expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-4o-mini', source: 'config' }); + }); + + it('scans authenticated providers by family priority, newest first', () => { + const result = resolveSmallModel({ + auth: { + google: { type: 'api', key: 'g-key' }, + anthropic: { type: 'api', key: 'sk-x' }, + }, + catalog, + configSmallModel: null, + }); + expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' }); + }); + + it('skips providers without a usable credential', () => { + const result = resolveSmallModel({ + auth: { + google: { type: 'api', key: '' }, + anthropic: { type: 'api', key: 'sk-x' }, + }, + catalog, + configSmallModel: null, + }); + expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' }); + }); + + it('falls back to Copilot utility models when only Copilot is logged in', () => { + const result = resolveSmallModel({ + auth: { 'github-copilot': { type: 'oauth', access: 't', refresh: 't', expires: 0 } }, + catalog, + configSmallModel: null, + }); + expect(result?.providerID).toBe('github-copilot'); + expect(result?.source).toBe('copilot-utility'); + }); + + it('returns null when nothing is authenticated', () => { + expect(resolveSmallModel({ auth: {}, catalog, configSmallModel: null })).toBeNull(); + }); + + it('prefers the session provider over other authenticated providers', () => { + const result = resolveSmallModel({ + auth: { + google: { type: 'api', key: 'g-key' }, + anthropic: { type: 'api', key: 'sk-x' }, + }, + catalog, + configSmallModel: null, + preferredProviderID: 'anthropic', + }); + expect(result).toEqual({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'family-scan' }); + }); + + it('ignores a preferred provider without a usable login', () => { + const result = resolveSmallModel({ + auth: { google: { type: 'api', key: 'g-key' } }, + catalog, + configSmallModel: null, + preferredProviderID: 'anthropic', + }); + expect(result).toEqual({ providerID: 'google', modelID: 'gemini-2.5-flash', source: 'family-scan' }); + }); + + it('never uses a session provider without a login (opencode free models)', () => { + // Vanilla setups default the picker to opencode/big-pickle with no + // opencode token — those free models only work through OpenCode itself + // and must never be called directly, so the session context is ignored. + const result = resolveSmallModel({ + auth: { openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 } }, + catalog, + configSmallModel: null, + preferredProviderID: 'opencode', + preferredModelID: 'big-pickle', + }); + expect(result).toEqual({ providerID: 'openai', modelID: 'gpt-5.4-mini', source: 'codex-small' }); + }); + + it('resolves nothing on a vanilla setup with no logins at all', () => { + const result = resolveSmallModel({ + auth: {}, + catalog, + configSmallModel: null, + preferredProviderID: 'opencode', + preferredModelID: 'big-pickle', + }); + expect(result).toBeNull(); + }); + + it('falls back to the session model instead of scanning other providers', () => { + const result = resolveSmallModel({ + auth: { + 'opencode-go': { type: 'api', key: 'oc-key' }, + openai: { type: 'oauth', access: 'a', refresh: 'r', expires: Date.now() + 60_000 }, + }, + catalog: { + 'opencode-go': { + id: 'opencode-go', + models: { + 'deepseek-v4-flash': { id: 'deepseek-v4-flash', family: 'deepseek-flash', release_date: '2026-01-01' }, + }, + }, + }, + configSmallModel: null, + preferredProviderID: 'opencode-go', + preferredModelID: 'deepseek-v4-flash', + }); + expect(result).toEqual({ providerID: 'opencode-go', modelID: 'deepseek-v4-flash', source: 'session-model' }); + }); + + it('falls back to the session model itself when nothing resolves', () => { + const result = resolveSmallModel({ + auth: { mistral: { type: 'api', key: 'm-key' } }, + catalog, + configSmallModel: null, + preferredProviderID: 'mistral', + preferredModelID: 'mistral-large-latest', + }); + expect(result).toEqual({ providerID: 'mistral', modelID: 'mistral-large-latest', source: 'session-model' }); + }); +}); diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js new file mode 100644 index 0000000000..c53143dec4 --- /dev/null +++ b/packages/web/server/lib/small-model/routes.js @@ -0,0 +1,44 @@ +export function registerSmallModelRoutes(app, { getSmallModelService }) { + app.get('/api/small-model', async (req, res) => { + try { + const { describeSmallModel, listAuthenticatedProviders } = await getSmallModelService(); + const resolved = await describeSmallModel({ + directory: typeof req.query.directory === 'string' ? req.query.directory : undefined, + preferredProviderID: typeof req.query.providerID === 'string' ? req.query.providerID : undefined, + preferredModelID: typeof req.query.modelID === 'string' ? req.query.modelID : undefined, + }); + res.json({ + available: Boolean(resolved), + model: resolved, + authenticatedProviders: listAuthenticatedProviders(), + }); + } catch (error) { + console.error('Failed to resolve small model:', error); + res.status(500).json({ error: error.message || 'Failed to resolve small model' }); + } + }); + + app.post('/api/small-model/generate', async (req, res) => { + try { + const { generateSmallModelText } = await getSmallModelService(); + const { prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider } = req.body || {}; + const result = await generateSmallModelText({ + prompt, + system, + maxOutputTokens, + model, + directory, + preferredProviderID, + preferredModelID, + restrictToPreferredProvider: restrictToPreferredProvider === true, + }); + res.json(result); + } catch (error) { + const statusCode = Number(error?.statusCode) || 500; + if (statusCode >= 500) { + console.error('Small model generation failed:', error); + } + res.status(statusCode).json({ error: error.message || 'Small model generation failed' }); + } + }); +} diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index c530d09907..06ca544199 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -173,6 +173,18 @@ function step(label, fn) { return result; } +function printReleaseNextSteps(version) { + log.success(`Release v${version} prepared locally`); + log.info('Next steps:'); + console.log(` git add -A`); + console.log(` git commit -m "release v${version}"`); + console.log(` git tag v${version}`); + console.log(` git push origin main --tags`); + console.log(''); + console.log('This will trigger the GitHub Actions release workflow.'); + console.log(`Make sure CHANGELOG.md contains a section like "## [${version}] - YYYY-MM-DD" before pushing.`); +} + function normalizeAction(action = '') { const normalized = action.toLowerCase(); const aliases = { @@ -575,7 +587,7 @@ async function createRelease(options) { if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1'); step('Validating codebase', () => run('bun', ['run', 'release:prepare'])); step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version])); - log.success(`Release v${version} prepared locally`); + printReleaseNextSteps(version); } async function chooseAction(config) { From c4e4c8fe31851072faeac3f1c1aeaafdc6903031 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 23:21:59 +0300 Subject: [PATCH 77/88] fix: run deployed web CLI from Bun global install Detects the OpenChamber CLI from Bun's global install directory Starts the global instance via the installed CLI path instead of relying on PATH Fails fast if the global CLI was not installed --- scripts/oc-dev.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index 06ca544199..c6b2226e4c 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -286,6 +286,11 @@ function installedWebCli(directory) { return existsSync(cliPath) ? cliPath : ''; } +function installedGlobalWebCli() { + const bunInstall = process.env.BUN_INSTALL || path.join(os.homedir(), '.bun'); + return installedWebCli(path.join(bunInstall, 'install', 'global')); +} + function stopInstalledInstance(directory, port) { const cliPath = installedWebCli(directory); if (!cliPath) return; @@ -370,7 +375,11 @@ async function deployWeb(options, config) { run('bun', ['remove', '-g', 'openchamber'], { allowFail: true, label: 'remove openchamber' }); }); step('Installing package globally', () => run('bun', ['add', '-g', packageFile])); - step(`Starting global instance on ${GLOBAL_PORT}`, () => run('openchamber', ['--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } })); + step(`Starting global instance on ${GLOBAL_PORT}`, () => { + const cliPath = installedGlobalWebCli(); + if (!cliPath) throw new Error('Global OpenChamber CLI was not installed by bun add -g'); + run('node', [cliPath, '--port', GLOBAL_PORT], { env: { OPENCHAMBER_UI_PASSWORD: process.env.OPENCHAMBER_PASSWORD || '', OPENCHAMBER_HOST: '0.0.0.0' } }); + }); } async function deployRemoteWeb(options, config) { From a53e991eb181c4134a18128ab4b27aa5f3325f26 Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:33:23 +1100 Subject: [PATCH 78/88] fix(auth): narrow mobile auth fallback (#2046) Co-authored-by: bashrusakh --- packages/ui/src/apps/renderMobileApp.tsx | 4 +- .../auth/SessionAuthGate.behavior.test.tsx | 315 ++++++++++++++++++ .../components/auth/SessionAuthGate.test.ts | 13 + .../src/components/auth/SessionAuthGate.tsx | 9 +- .../components/auth/sessionAuthGateState.ts | 11 + 5 files changed, 345 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx create mode 100644 packages/ui/src/components/auth/SessionAuthGate.test.ts create mode 100644 packages/ui/src/components/auth/sessionAuthGateState.ts diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx index e20e187b1a..a665d2d511 100644 --- a/packages/ui/src/apps/renderMobileApp.tsx +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -76,9 +76,7 @@ export function renderMobileApp(apis: RuntimeAPIs) { // Auth gating differs by shell: the native Capacitor app authenticates via // its own instance-connect flow (MobileConnectionWelcome asks for the // password per instance), while the plain mobile BROWSER against a - // --ui-password server must get the classic SessionAuthGate unlock page — - // dropping it (v1.13.9) left browsers on a dead "unable to reach server" - // screen with no way to enter the password. + // --ui-password server must keep the classic SessionAuthGate unlock page. const app = ; createRoot(rootElement).render( diff --git a/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx new file mode 100644 index 0000000000..871bc74cbe --- /dev/null +++ b/packages/ui/src/components/auth/SessionAuthGate.behavior.test.tsx @@ -0,0 +1,315 @@ +import { describe, expect, mock, test } from 'bun:test'; + +type ComponentFn

= Record> = (props: P) => unknown; + +type HookRecord = { + values: unknown[]; + deps: Array; +}; + +type HookEffect = () => void | (() => void); +type HookCallback = (...args: unknown[]) => unknown; +type JSXProps = Record & { children?: unknown }; +type JSXElementType

= Record> = ComponentFn

| string | symbol; + +const hookRecords = new Map(); +let currentRecord: HookRecord | null = null; +let hookIndex = 0; +let pendingEffects: Array<() => void> = []; + +const resetHarness = () => { + hookRecords.clear(); + currentRecord = null; + hookIndex = 0; + pendingEffects = []; +}; + +const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => { + if (!left || !right) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => Object.is(value, right[index])); +}; + +const getRecord = (component: unknown): HookRecord => { + const existing = hookRecords.get(component); + if (existing) return existing; + const record: HookRecord = { values: [], deps: [] }; + hookRecords.set(component, record); + return record; +}; + +const getHookRecord = (): HookRecord => { + if (!currentRecord) { + throw new Error('Hooks can only run during a render pass'); + } + return currentRecord; +}; + +const renderComponent =

>(component: ComponentFn

, props: P): unknown => { + const previousRecord = currentRecord; + const previousHookIndex = hookIndex; + currentRecord = getRecord(component); + hookIndex = 0; + + try { + return component(props); + } finally { + currentRecord = previousRecord; + hookIndex = previousHookIndex; + } +}; + +function useCallback(callback: T, deps?: unknown[]): T { + const record = getHookRecord(); + const index = hookIndex++; + const previousDeps = record.deps[index]; + if (!shallowEqualDeps(previousDeps, deps)) { + record.values[index] = callback; + record.deps[index] = deps; + } + return record.values[index] as T; +} + +function useEffect(effect: HookEffect, deps?: unknown[]): void { + const record = getHookRecord(); + const index = hookIndex++; + const previousDeps = record.deps[index]; + if (!shallowEqualDeps(previousDeps, deps)) { + record.deps[index] = deps; + pendingEffects.push(() => { + effect(); + }); + } +} + +function useMemo(factory: () => T, deps?: unknown[]): T { + const record = getHookRecord(); + const index = hookIndex++; + const previousDeps = record.deps[index]; + if (!shallowEqualDeps(previousDeps, deps)) { + record.values[index] = factory(); + record.deps[index] = deps; + } + return record.values[index] as T; +} + +function useRef(initialValue: T): { current: T } { + const record = getHookRecord(); + const index = hookIndex++; + if (record.values[index] === undefined) { + record.values[index] = { current: initialValue }; + } + return record.values[index] as { current: T }; +} + +function useState(initialValue: T | (() => T)): readonly [T, (next: T | ((prev: T) => T)) => void] { + const record = getHookRecord(); + const index = hookIndex++; + if (record.values[index] === undefined) { + record.values[index] = typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue; + } + + const setState = (next: T | ((prev: T) => T)) => { + record.values[index] = typeof next === 'function' + ? (next as (prev: T) => T)(record.values[index] as T) + : next; + }; + + return [record.values[index] as T, setState] as const; +} + +function jsx

>(type: JSXElementType

, props: JSXProps & P): unknown { + if (type === reactJsxRuntime.Fragment) { + return props.children ?? null; + } + + if (typeof type === 'function') { + return renderComponent(type, props as P); + } + + return { type, props }; +} + +const ReactMock = { + useCallback, + useEffect, + useMemo, + useRef, + useState, +}; + +const reactJsxRuntime = { + Fragment: Symbol('Fragment'), + jsx, + jsxs: jsx, + jsxDEV: jsx, +}; + +let desktopShell = false; +let runtimeFetchRejects = true; + +mock.module('react/jsx-runtime', () => reactJsxRuntime); +mock.module('react/jsx-dev-runtime', () => reactJsxRuntime); + +mock.module('react', () => ({ + __esModule: true, + default: ReactMock, + ...ReactMock, +})); + +mock.module('@simplewebauthn/browser', () => ({ + browserSupportsWebAuthn: mock(() => false), +})); + +mock.module('@/components/ui/button', () => ({ + Button: ({ children }: { children?: unknown }) => children ?? null, +})); + +mock.module('@/components/ui/checkbox', () => ({ + Checkbox: () => null, +})); + +mock.module('@/components/ui/input', () => ({ + Input: () => null, +})); + +mock.module('@/components/ui', () => ({ + toast: { + success: mock(() => undefined), + error: mock(() => undefined), + message: mock(() => undefined), + }, +})); + +mock.module('@/components/ui/OpenChamberLogo', () => ({ + OpenChamberLogo: () => 'logo', +})); + +mock.module('@/components/icon/Icon', () => ({ + Icon: () => null, +})); + +mock.module('@/components/desktop/DesktopHostSwitcher', () => ({ + DesktopHostSwitcherInline: () => 'host-switcher', +})); + +mock.module('@/lib/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +mock.module('@/lib/desktop', () => ({ + invokeDesktop: mock(() => Promise.resolve(null)), + isDesktopShell: mock(() => desktopShell), + isVSCodeRuntime: mock(() => false), +})); + +mock.module('@/lib/persistence', () => ({ + initializeAppearancePreferences: mock(() => Promise.resolve()), + syncDesktopSettings: mock(() => Promise.resolve()), +})); + +mock.module('@/lib/directoryPersistence', () => ({ + applyPersistedDirectoryPreferences: mock(() => Promise.resolve()), +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async () => { + if (runtimeFetchRejects) { + throw new Error('offline'); + } + + return new Response(JSON.stringify({ authenticated: false }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); + }), +})); + +mock.module('@/lib/runtime-auth', () => ({ + getRuntimeExtraHeadersSync: mock(() => ({})), +})); + +mock.module('@/lib/runtime-switch', () => ({ + getRuntimeApiBaseUrl: mock(() => ''), + subscribeRuntimeEndpointChanged: mock(() => () => {}), + switchRuntimeEndpoint: mock(() => undefined), +})); + +mock.module('@/lib/desktopHosts', () => ({ + desktopHostsGet: mock(() => Promise.resolve(null)), + desktopHostsSet: mock(() => Promise.resolve()), + getDesktopHostApiUrl: mock(() => ''), + normalizeHostUrl: mock(() => ''), +})); + +mock.module('@/lib/passkeys', () => ({ + authenticateWithPasskey: mock(() => Promise.resolve(null)), + cancelPasskeyCeremony: mock(() => undefined), + defaultPasskeyStatus: { enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null }, + fetchPasskeyStatus: mock(() => Promise.resolve({ enabled: false, hasPasskeys: false, passkeyCount: 0, rpID: null })), + isPasskeyCeremonyAbort: mock(() => false), + registerCurrentDevicePasskey: mock(() => Promise.resolve(null)), +})); + +const { SessionAuthGate } = await import('./SessionAuthGate'); + +const flushEffects = async () => { + while (pendingEffects.length > 0) { + const effects = pendingEffects; + pendingEffects = []; + for (const effect of effects) { + effect(); + } + await Promise.resolve(); + } + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); +}; + +const renderGate = async () => { + const firstPass = renderComponent(SessionAuthGate, { children: 'child' }); + await flushEffects(); + const secondPass = renderComponent(SessionAuthGate, { children: 'child' }); + await flushEffects(); + return secondPass ?? firstPass; +}; + +const collectText = (node: unknown): string => { + if (node === null || node === undefined || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map((child) => collectText(child)).join(' '); + if (typeof node === 'object') { + const element = node as { props?: { children?: unknown } }; + return collectText(element.props?.children); + } + return ''; +}; + +describe('SessionAuthGate status-check failure behavior', () => { + test('keeps non-desktop status-check rejection on the error screen', async () => { + resetHarness(); + desktopShell = false; + runtimeFetchRejects = true; + + const tree = await renderGate(); + const text = collectText(tree); + + expect(text).toContain('sessionAuth.error.networkTitle'); + expect(text).not.toContain('sessionAuth.locked.unlockTitle'); + }); + + test('keeps desktop-shell status-check rejection on the locked password prompt', async () => { + resetHarness(); + desktopShell = true; + runtimeFetchRejects = true; + + const tree = await renderGate(); + const text = collectText(tree); + + expect(text).toContain('sessionAuth.locked.unlockTitle'); + expect(text).not.toContain('sessionAuth.error.networkTitle'); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.test.ts b/packages/ui/src/components/auth/SessionAuthGate.test.ts new file mode 100644 index 0000000000..543a6fbefd --- /dev/null +++ b/packages/ui/src/components/auth/SessionAuthGate.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test'; + +import { resolveStatusCheckFailureState } from './sessionAuthGateState'; + +describe('resolveStatusCheckFailureState', () => { + test('keeps the desktop-shell password login fallback intact', () => { + expect(resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: true })).toBe('locked'); + }); + + test('uses the network error screen for non-desktop status-check failures', () => { + expect(resolveStatusCheckFailureState({})).toBe('error'); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 00c7c336fc..558e77c812 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -15,6 +15,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; +import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState'; import { authenticateWithPasskey, cancelPasskeyCeremony, @@ -292,8 +293,6 @@ interface SessionAuthGateProps { children: React.ReactNode; } -type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited'; - interface ErrorScreenProps { onRetry: () => void; errorType?: 'network' | 'rate-limit'; @@ -301,7 +300,9 @@ interface ErrorScreenProps { children?: React.ReactNode; } -export const SessionAuthGate: React.FC = ({ children }) => { +export const SessionAuthGate: React.FC = ({ + children, +}) => { const { t } = useI18n(); const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []); const skipAuth = vscodeRuntime; @@ -422,7 +423,7 @@ export const SessionAuthGate: React.FC = ({ children }) => setIsTunnelLocked(false); } catch (error) { console.warn('Failed to check session status:', error); - if (shouldUseDesktopShellPasswordLogin()) { + if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') { setState('locked'); setRetryAfter(undefined); setIsTunnelLocked(false); diff --git a/packages/ui/src/components/auth/sessionAuthGateState.ts b/packages/ui/src/components/auth/sessionAuthGateState.ts new file mode 100644 index 0000000000..258d4acc76 --- /dev/null +++ b/packages/ui/src/components/auth/sessionAuthGateState.ts @@ -0,0 +1,11 @@ +export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited'; + +export const resolveStatusCheckFailureState = (options: { + shouldUseDesktopShellPasswordLogin?: boolean; +}): Exclude => { + if (options.shouldUseDesktopShellPasswordLogin) { + return 'locked'; + } + + return 'error'; +}; From 24f648d2746efceaacdca41cb54b64ea06e1b10b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 23:51:11 +0300 Subject: [PATCH 79/88] fix(capacitor): validate mobile connection on app resume Checks the runtime session before restoring a mobile connection Disconnects and resets state when the session is no longer valid Adds tests for reachable, unreachable, and unauthenticated runtimes --- packages/ui/src/apps/MobileApp.tsx | 33 +++++++-- .../ui/src/apps/mobileConnections.test.ts | 71 +++++++++++++++++++ packages/ui/src/apps/mobileConnections.ts | 25 +++++++ 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/apps/mobileConnections.test.ts diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4581b1d9fd..bc7f9a2c4b 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -57,7 +57,7 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection, validateMobileConnectionSession } from './mobileConnections'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; @@ -516,6 +516,12 @@ const mobileInputKeyboardProps = { const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; +const getRuntimeClientToken = (): string => { + if (typeof window === 'undefined') return ''; + const token = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__; + return typeof token === 'string' ? token.trim() : ''; +}; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -2184,18 +2190,33 @@ export function MobileApp({ apis }: MobileAppProps) { const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); const lastNativeResumeSyncEventAtRef = React.useRef(0); + const nativeResumeValidationSeqRef = React.useRef(0); const handleNativeResume = React.useCallback(() => { - if (!getRuntimeApiBaseUrl()) return; + const apiBaseUrl = getRuntimeApiBaseUrl(); + if (!apiBaseUrl) return; + const validationSeq = nativeResumeValidationSeqRef.current + 1; + nativeResumeValidationSeqRef.current = validationSeq; + + void validateMobileConnectionSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => { + if (nativeResumeValidationSeqRef.current !== validationSeq) return; + if (!isValid) { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + return; + } + + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }); + const now = Date.now(); if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { lastNativeResumeSyncEventAtRef.current = now; window.dispatchEvent(new Event('openchamber:system-resume')); } - void initializeApp(); - void refreshGitHubAuthStatus(apis.github, { force: true }); - if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); - if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); useNativeMobileChrome(); diff --git a/packages/ui/src/apps/mobileConnections.test.ts b/packages/ui/src/apps/mobileConnections.test.ts new file mode 100644 index 0000000000..0f16f3c577 --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import { validateMobileConnectionSession } from './mobileConnections'; + +const originalFetch = globalThis.fetch; +const originalWindow = globalThis.window; + +const installTestWindow = () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + location: { protocol: 'https:' }, + }, + }); +}; + +const restoreGlobals = () => { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); +}; + +describe('validateMobileConnectionSession', () => { + test('accepts a reachable authenticated runtime', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + if (url.endsWith('/auth/session')) return Response.json({ authenticated: true, scope: 'client' }); + return new Response(null, { status: 404 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(true); + } finally { + restoreGlobals(); + } + }); + + test('rejects unreachable runtimes', async () => { + try { + installTestWindow(); + globalThis.fetch = mock(async () => new Response(null, { status: 503 })) as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); + + test('rejects invalid or unauthenticated sessions', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + return Response.json({ authenticated: false }, { status: 401 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'expired' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); +}); diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 35a748afc3..a2307cf0f4 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -452,6 +452,31 @@ export const autoConnectLastInstance = async (): Promise => { return true; }; +export const validateMobileConnectionSession = async (input: { + url: string; + clientToken?: string | null; +}): Promise => { + let url = ''; + try { + url = normalizeConnectionUrl(input.url); + } catch { + return false; + } + if (!url) return false; + + const token = input.clientToken?.trim() || undefined; + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + if (!session || (!session.ok && session.status !== 404)) return false; + + const status = await readSessionStatus(session); + return !(status && status.disabled !== true && status.authenticated === false); +}; + // --------------------------------------------------------------------------- // Shared connection controller // --------------------------------------------------------------------------- From 3378b844fab604ba8114957937735be7ccfb20fe Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 00:37:24 +0300 Subject: [PATCH 80/88] feat: add temporary sidebar share opinion prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dialog with call booking and survey actions for user feedback Shows a one-time sidebar toast prompting users to share what’s useful or missing Adds localized copy for the new feedback entry points --- .../feedback/ShareOpinionDialog.tsx | 65 +++++++++++++++++++ packages/ui/src/components/icon/sprite.ts | 1 + .../src/components/session/SessionSidebar.tsx | 39 +++++++++++ .../session/sidebar/SidebarFooter.tsx | 14 +++- packages/ui/src/lib/i18n/messages/en.ts | 7 ++ packages/ui/src/lib/i18n/messages/es.ts | 7 ++ packages/ui/src/lib/i18n/messages/fr.ts | 7 ++ packages/ui/src/lib/i18n/messages/ja.ts | 7 ++ packages/ui/src/lib/i18n/messages/ko.ts | 7 ++ packages/ui/src/lib/i18n/messages/pl.ts | 7 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 ++ packages/ui/src/lib/i18n/messages/uk.ts | 7 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 ++ 14 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/feedback/ShareOpinionDialog.tsx diff --git a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx new file mode 100644 index 0000000000..1095913641 --- /dev/null +++ b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { useI18n } from '@/lib/i18n'; +import { openExternalUrl } from '@/lib/url'; + +type ShareOpinionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +const SHARE_OPINION_MARKDOWN = `**Help shape what OpenChamber becomes next!** + +Hey 👋, + +OpenChamber has grown mostly through word of mouth, GitHub issues, Discord feedback, and people telling me what is broken, confusing, or surprisingly useful. + +I'm planning the next chapter now - mobile, better remote access, a tighter VS Code ↔ desktop/web/mobile flow, and more, but before building too much, I want to hear from the people actually using it. + +I'm doing a round of short 1-on-1 calls. No sales pitch, no formal script - just a real conversation about how you use OpenChamber, what you love, what frustrates you, and what would make it much more valuable. + +You can book a call or use the short survey below. + +What you get: + +- A direct chance to influence the roadmap +- Your pain points and feature requests prioritized with more context +- A Power User role in Discord for people helping shape the product +- My genuine thanks for helping make OpenChamber better + +**This project is what it is because of your feedback. Thank you, genuinely.** + +I'll remove this button in two weeks 🙂`; + +export function ShareOpinionDialog({ open, onOpenChange }: ShareOpinionDialogProps): React.ReactNode { + const { t } = useI18n(); + + return ( +

+ + {t('shareOpinion.dialog.title')} + +
+ + +
+
+
+ ); +} diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 5f1df0dc85..0cc872c6a3 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -227,6 +227,7 @@ export const iconSpriteData = { "unpin": ``, "user-3": ``, "user": ``, + "video-chat": ``, "volume-up": ``, "window": ``, } as const satisfies Record; diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index af841951bc..f8e8b562ff 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -35,6 +35,7 @@ import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; +import { ShareOpinionDialog } from '@/components/feedback/ShareOpinionDialog'; import { SessionGroupSection } from './sidebar/SessionGroupSection'; import { SidebarHeader } from './sidebar/SidebarHeader'; import { SidebarActivitySections } from './sidebar/SidebarActivitySections'; @@ -80,6 +81,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; +const SHARE_OPINION_TOAST_STORAGE_KEY = 'openchamber.shareOpinionToast.dismissed.v2'; const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; @@ -196,6 +198,7 @@ export const SessionSidebar: React.FC = ({ const newWorktreeDialogOpen = useUIStore((state) => state.isNewWorktreeDialogOpen); const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); + const [shareOpinionDialogOpen, setShareOpinionDialogOpen] = React.useState(false); const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState(null); const [renamingFolderId, setRenamingFolderId] = React.useState(null); const [renameFolderDraft, setRenameFolderDraft] = React.useState(''); @@ -635,6 +638,36 @@ export const SessionSidebar: React.FC = ({ }); }, [t, updateStore]); + const handleOpenShareOpinionDialog = React.useCallback(() => { + setShareOpinionDialogOpen(true); + }, []); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + if (window.localStorage.getItem(SHARE_OPINION_TOAST_STORAGE_KEY) === 'true') { + return; + } + window.localStorage.setItem(SHARE_OPINION_TOAST_STORAGE_KEY, 'true'); + } catch { + // If storage is unavailable, still show once for this sidebar mount. + } + const timeoutId = window.setTimeout(() => { + toast.info(t('shareOpinion.toast.title'), { + description: t('shareOpinion.toast.description'), + action: { + label: t('shareOpinion.actions.shareOpinion'), + onClick: () => setShareOpinionDialogOpen(true), + }, + duration: 12_000, + }); + }, 1_000); + + return () => window.clearTimeout(timeoutId); + }, [t]); + const handleOpenSettings = React.useCallback(() => { if (mobileVariant) { setSessionSwitcherOpen(false); @@ -1619,10 +1652,16 @@ export const SessionSidebar: React.FC = ({ onOpenShortcuts={toggleHelpDialog} onOpenAbout={() => setAboutDialogOpen(true)} onOpenUpdate={handleOpenUpdateDialog} + onOpenShareOpinion={handleOpenShareOpinionDialog} showRuntimeButtons={!isVSCode} showUpdateButton={showSidebarUpdateButton} /> + + void; onOpenAbout: () => void; onOpenUpdate: () => void; + onOpenShareOpinion: () => void; showRuntimeButtons?: boolean; showUpdateButton?: boolean; }; @@ -20,6 +21,7 @@ export function SidebarFooter({ onOpenShortcuts, onOpenAbout, onOpenUpdate, + onOpenShareOpinion, showRuntimeButtons = true, showUpdateButton = true, }: Props): React.ReactNode { @@ -65,7 +67,17 @@ export function SidebarFooter({ > {t('sessions.sidebar.footer.actions.update')} - ) : null} + ) : ( + + )}
); } diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index ecc0400488..175439bd45 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -22,6 +22,13 @@ export const dict = { 'pwa.installPrompt.dismiss': 'Dismiss', 'pwa.installPrompt.started': 'Install started', 'pwa.installPrompt.installed': 'OpenChamber installed', + 'shareOpinion.actions.shareOpinion': 'Share opinion', + 'shareOpinion.actions.bookCall': 'Book a call', + 'shareOpinion.actions.shortSurvey': 'Short survey', + 'shareOpinion.dialog.title': 'Share your opinion', + 'shareOpinion.toast.title': 'Help shape OpenChamber', + 'shareOpinion.toast.description': 'Share what is useful, confusing, or missing to help shape what comes next.', + 'sessions.sidebar.footer.actions.shareOpinion': 'Share opinion', 'layout.mainTab.chat': 'Chat', 'layout.mainTab.plan': 'Plan', 'layout.mainTab.diff': 'Diff', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 5c4125884c..22f56c4829 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -23,6 +23,13 @@ export const dict: Record = { "pwa.installPrompt.dismiss": "Descartar", "pwa.installPrompt.started": "Instalación iniciada", "pwa.installPrompt.installed": "OpenChamber se instaló", + "shareOpinion.actions.shareOpinion": "Compartir opinión", + "shareOpinion.actions.bookCall": "Reservar llamada", + "shareOpinion.actions.shortSurvey": "Encuesta breve", + "shareOpinion.dialog.title": "Comparte tu opinión", + "shareOpinion.toast.title": "Ayuda a dar forma a OpenChamber", + "shareOpinion.toast.description": "Cuenta qué es útil, confuso o falta para ayudar a definir lo que viene después.", + "sessions.sidebar.footer.actions.shareOpinion": "Compartir opinión", "layout.mainTab.chat": "Chat", "layout.mainTab.plan": "Plan", "layout.mainTab.diff": "Diff", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index efd36d718c..330028e50c 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -22,6 +22,13 @@ export const dict = { 'pwa.installPrompt.dismiss': 'Ignorer', 'pwa.installPrompt.started': 'Installation démarrée', 'pwa.installPrompt.installed': 'OpenChamber installé', + 'shareOpinion.actions.shareOpinion': 'Donner son avis', + 'shareOpinion.actions.bookCall': 'Réserver un appel', + 'shareOpinion.actions.shortSurvey': 'Court sondage', + 'shareOpinion.dialog.title': 'Donnez votre avis', + 'shareOpinion.toast.title': 'Aidez à façonner OpenChamber', + 'shareOpinion.toast.description': 'Dites ce qui est utile, confus ou manquant pour aider à définir la suite.', + 'sessions.sidebar.footer.actions.shareOpinion': 'Donner son avis', 'layout.mainTab.chat': 'Chat', 'layout.mainTab.plan': 'Plan', 'layout.mainTab.diff': 'Diff', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index b48f61f3d7..df98d74a2e 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '閉じる', 'pwa.installPrompt.started': 'インストールを開始しました', 'pwa.installPrompt.installed': 'OpenChamberをインストールしました', + 'shareOpinion.actions.shareOpinion': '意見を共有', + 'shareOpinion.actions.bookCall': '通話を予約', + 'shareOpinion.actions.shortSurvey': '短いアンケート', + 'shareOpinion.dialog.title': 'ご意見をお聞かせください', + 'shareOpinion.toast.title': 'OpenChamber の次を一緒に作る', + 'shareOpinion.toast.description': '便利な点、分かりにくい点、足りない点を教えて、次の方向づくりに参加してください。', + 'sessions.sidebar.footer.actions.shareOpinion': '意見を共有', 'layout.mainTab.chat': 'チャット', 'layout.mainTab.plan': '計画', 'layout.mainTab.diff': '差分', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 91b83456a9..8047d3c6aa 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '닫기', 'pwa.installPrompt.started': '설치가 시작되었습니다', 'pwa.installPrompt.installed': 'OpenChamber가 설치되었습니다', + 'shareOpinion.actions.shareOpinion': '의견 공유', + 'shareOpinion.actions.bookCall': '통화 예약', + 'shareOpinion.actions.shortSurvey': '짧은 설문', + 'shareOpinion.dialog.title': '의견을 공유해 주세요', + 'shareOpinion.toast.title': 'OpenChamber의 방향을 함께 만들어 주세요', + 'shareOpinion.toast.description': '유용한 점, 혼란스러운 점, 부족한 점을 알려 주셔서 다음 방향을 함께 만들어 주세요.', + 'sessions.sidebar.footer.actions.shareOpinion': '의견 공유', 'layout.mainTab.chat': '채팅', 'layout.mainTab.plan': '계획', 'layout.mainTab.diff': '변경사항', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2ecc05c42d..2b4c2feb80 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -24,6 +24,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': 'Odrzuć', 'pwa.installPrompt.started': 'Instalacja rozpoczęta', 'pwa.installPrompt.installed': 'OpenChamber został zainstalowany', + 'shareOpinion.actions.shareOpinion': 'Podziel się opinią', + 'shareOpinion.actions.bookCall': 'Umów rozmowę', + 'shareOpinion.actions.shortSurvey': 'Krótka ankieta', + 'shareOpinion.dialog.title': 'Podziel się opinią', + 'shareOpinion.toast.title': 'Pomóż kształtować OpenChamber', + 'shareOpinion.toast.description': 'Powiedz, co jest przydatne, mylące albo czego brakuje, aby pomóc kształtować kolejne kroki.', + 'sessions.sidebar.footer.actions.shareOpinion': 'Podziel się opinią', 'layout.mainTab.chat': 'Czat', 'layout.mainTab.plan': 'Plan', 'layout.mainTab.diff': 'Różnice', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 00950b28b6..2ffd48ac92 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -23,6 +23,13 @@ export const dict: Record = { "pwa.installPrompt.dismiss": "Dispensar", "pwa.installPrompt.started": "Instalação iniciada", "pwa.installPrompt.installed": "OpenChamber instalado", + "shareOpinion.actions.shareOpinion": "Compartilhar opinião", + "shareOpinion.actions.bookCall": "Agendar chamada", + "shareOpinion.actions.shortSurvey": "Pesquisa breve", + "shareOpinion.dialog.title": "Compartilhe sua opinião", + "shareOpinion.toast.title": "Ajude a moldar o OpenChamber", + "shareOpinion.toast.description": "Conte o que é útil, confuso ou está faltando para ajudar a definir os próximos passos.", + "sessions.sidebar.footer.actions.shareOpinion": "Compartilhar opinião", "layout.mainTab.chat": "Chat", "layout.mainTab.plan": "Plano", "layout.mainTab.diff": "Diff", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2553e30202..40792dc864 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -23,6 +23,13 @@ export const dict: Record = { "pwa.installPrompt.dismiss": "Сховати", "pwa.installPrompt.started": "Установлення розпочато", "pwa.installPrompt.installed": "OpenChamber встановлено", + "shareOpinion.actions.shareOpinion": "Поділитися думкою", + "shareOpinion.actions.bookCall": "Забронювати дзвінок", + "shareOpinion.actions.shortSurvey": "Коротке опитування", + "shareOpinion.dialog.title": "Поділіться своєю думкою", + "shareOpinion.toast.title": "Допоможіть сформувати OpenChamber", + "shareOpinion.toast.description": "Розкажіть, що корисне, незрозуміле чи відсутнє, щоб допомогти сформувати наступні кроки.", + "sessions.sidebar.footer.actions.shareOpinion": "Поділитися думкою", "layout.mainTab.chat": "Чат", "layout.mainTab.plan": "План", "layout.mainTab.diff": "Diff", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index eee31d399b..7c68825cef 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '忽略', 'pwa.installPrompt.started': '已开始安装', 'pwa.installPrompt.installed': 'OpenChamber 已安装', + 'shareOpinion.actions.shareOpinion': '分享意见', + 'shareOpinion.actions.bookCall': '预约通话', + 'shareOpinion.actions.shortSurvey': '简短问卷', + 'shareOpinion.dialog.title': '分享你的意见', + 'shareOpinion.toast.title': '帮助塑造 OpenChamber', + 'shareOpinion.toast.description': '告诉我们哪些有用、令人困惑或缺失,帮助塑造下一步。', + 'sessions.sidebar.footer.actions.shareOpinion': '分享意见', 'layout.mainTab.chat': '聊天', 'layout.mainTab.plan': '计划', 'layout.mainTab.diff': '差异', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index b6cac65f7d..605b3eae4d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -23,6 +23,13 @@ export const dict: Record = { 'pwa.installPrompt.dismiss': '忽略', 'pwa.installPrompt.started': '已開始安裝', 'pwa.installPrompt.installed': 'OpenChamber 已安裝', + 'shareOpinion.actions.shareOpinion': '分享意見', + 'shareOpinion.actions.bookCall': '預約通話', + 'shareOpinion.actions.shortSurvey': '簡短問卷', + 'shareOpinion.dialog.title': '分享你的意見', + 'shareOpinion.toast.title': '協助塑造 OpenChamber', + 'shareOpinion.toast.description': '告訴我們哪些有用、令人困惑或缺少什麼,協助塑造下一步。', + 'sessions.sidebar.footer.actions.shareOpinion': '分享意見', 'layout.mainTab.chat': '聊天', 'layout.mainTab.plan': '計畫', 'layout.mainTab.diff': '差異', From 50b85049f74c4a9d3645a30a32428cb5f2d6d625 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 00:39:49 +0300 Subject: [PATCH 81/88] fix: preserve chat composer focus on mobile keyboard Keeps the textarea reference available during mobile viewport adjustments Ensures the composer scrolls back into view after keyboard interactions Updates text selection menu dependencies to include the current session --- packages/ui/src/components/chat/ChatInput.tsx | 2 +- packages/ui/src/components/chat/message/TextSelectionMenu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 00d7cccfee..f7c8eae211 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -4265,6 +4265,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!isMobile || !isMobileExpanded || isCapacitorApp()) return; const vv = window.visualViewport; const form = composerFormRef.current; + const textarea = textareaRef.current; if (!vv || !form) return; // The form is trapped inside lower stacking contexts (the composer // wrapper's z-10), so it cannot out-stack the app header with z-index @@ -4299,7 +4300,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // session and won't re-reveal the (still focused) field on its own, // which left the composer parked behind the keyboard. requestAnimationFrame(() => { - const textarea = textareaRef.current; if (textarea && document.activeElement === textarea) { textarea.scrollIntoView({ block: 'nearest' }); } diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index d100267bb7..3e5b11993d 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -544,7 +544,7 @@ export const TextSelectionMenu: React.FC = ({ containerR } finally { setIsAddingToNotes(false); } - }, [currentProjectRef, hideMenu, selectedText, selectedTextMarkdown, t]); + }, [currentProjectRef, currentSessionId, hideMenu, selectedText, selectedTextMarkdown, t]); if (!position.show) return null; From 1a457df27d01b2c373bc8dc4b39966d381ff3e87 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 00:45:17 +0300 Subject: [PATCH 82/88] fix: update button removal notice duration in ShareOpinionDialog --- packages/ui/src/components/feedback/ShareOpinionDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx index 1095913641..b3609c63da 100644 --- a/packages/ui/src/components/feedback/ShareOpinionDialog.tsx +++ b/packages/ui/src/components/feedback/ShareOpinionDialog.tsx @@ -31,7 +31,7 @@ What you get: **This project is what it is because of your feedback. Thank you, genuinely.** -I'll remove this button in two weeks 🙂`; +I'll remove this button in 10 days 🙂`; export function ShareOpinionDialog({ open, onOpenChange }: ShareOpinionDialogProps): React.ReactNode { const { t } = useI18n(); From e91a046b956cac8e55f051c326eaf1d6c32085f1 Mon Sep 17 00:00:00 2001 From: Catan <84828825+catan271@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:58:44 +0700 Subject: [PATCH 83/88] feat(chat): recognize file:start-end as a clickable reference (#2000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File references in chat messages can now use the 'path:start-end' form (e.g. 'src/foo.ts:120-145'). The reference becomes clickable in the renderer and, on click, the file opens at the start line. Range selection is intentionally not done at this layer — the 'path:start-end' form is parsed only so the link resolves to the correct path; navigation jumps to the start line, matching the behavior of the existing 'path:line' form. - Extract the file-reference parser to a dedicated module so it can be unit-tested without pulling in the markdown renderer's worker dependencies. - Add a range branch to 'parseFileReference' and update the block-code path regex to recognize the new form. - Switch the colon-form regex to a non-greedy path match so 'path:line:col' is no longer mis-parsed as 'path:line' with the first numeric suffix dropped into the path. - Add a unit test covering the new and existing parser forms. --- .../chat/MarkdownRendererImpl.test.ts | 98 +++++++++++ .../components/chat/MarkdownRendererImpl.tsx | 139 ++-------------- .../components/chat/fileReferenceParser.ts | 157 ++++++++++++++++++ 3 files changed, 268 insertions(+), 126 deletions(-) create mode 100644 packages/ui/src/components/chat/MarkdownRendererImpl.test.ts create mode 100644 packages/ui/src/components/chat/fileReferenceParser.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts new file mode 100644 index 0000000000..eb8b0e5499 --- /dev/null +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseFileReference, type ParsedFileReference } from './fileReferenceParser'; + +const parse = (value: string): ParsedFileReference | null => parseFileReference(value); + +describe('parseFileReference', () => { + test('returns null for empty or whitespace input', () => { + expect(parse('')).toBeNull(); + expect(parse(' ')).toBeNull(); + }); + + test('parses bare path', () => { + expect(parse('src/foo.ts')).toEqual({ path: 'src/foo.ts' }); + }); + + test('parses path with single line', () => { + expect(parse('src/foo.ts:42')).toEqual({ path: 'src/foo.ts', line: 42 }); + }); + + test('parses path with line and column', () => { + expect(parse('src/foo.ts:42:8')).toEqual({ path: 'src/foo.ts', line: 42, column: 8 }); + }); + + test('parses path with line range', () => { + expect(parse('src/foo.ts:42-58')).toEqual({ + path: 'src/foo.ts', + line: 42, + endLine: 58, + }); + }); + + test('parses path with single-line range (start equals end)', () => { + expect(parse('src/foo.ts:10-10')).toEqual({ + path: 'src/foo.ts', + line: 10, + endLine: 10, + }); + }); + + test('rejects range with end before start', () => { + expect(parse('src/foo.ts:20-10')).toBeNull(); + }); + + test('falls back to path-only when range endpoint is non-numeric', () => { + // `src/foo.ts:10-abc` and `src/foo.ts:abc-20` are malformed; the + // line info is discarded and only the path is returned (the trailing + // `:`-suffix is stripped). + expect(parse('src/foo.ts:10-abc')).toEqual({ path: 'src/foo.ts' }); + expect(parse('src/foo.ts:abc-20')).toEqual({ path: 'src/foo.ts' }); + }); + + test('strips backtick and quote wrapping from range forms', () => { + expect(parse('`src/foo.ts:10-20`')).toEqual({ + path: 'src/foo.ts', + line: 10, + endLine: 20, + }); + expect(parse('"src/foo.ts:1-3"')).toEqual({ + path: 'src/foo.ts', + line: 1, + endLine: 3, + }); + }); + + test('parses absolute Windows path with line range', () => { + expect(parse('C:/repo/src/foo.ts:5-9')).toEqual({ + path: 'C:/repo/src/foo.ts', + line: 5, + endLine: 9, + }); + }); + + test('preserves line:col form (does not interpret as range)', () => { + expect(parse('src/foo.ts:42:8')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 8, + }); + }); + + test('preserves hash form', () => { + expect(parse('src/foo.ts#L42C8')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 8, + }); + expect(parse('src/foo.ts#L42')).toEqual({ + path: 'src/foo.ts', + line: 42, + }); + }); + + test('range form takes precedence over line-only when suffix matches digits-dash-digits', () => { + const result = parse('src/foo.ts:42-58'); + expect(result).toEqual({ path: 'src/foo.ts', line: 42, endLine: 58 }); + }); +}); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index db97312ce4..6e71125c47 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -18,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; -import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; +import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils'; import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore'; import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; import { @@ -28,6 +28,13 @@ import { type DecorateLabels, type MermaidRender, } from './markdown/decorate'; +import { + BLOCK_PATH_TOKEN_RE, + isAbsoluteReferencePath, + normalizeReferencePath, + parseFileReference, + type ParsedFileReference, +} from './fileReferenceParser'; const useCurrentMermaidTheme = () => { const themeSystem = useOptionalThemeSystem(); @@ -148,16 +155,9 @@ const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token'; const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`; const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned'; -// Matches `path[:line[:col]]` inside shell/grep-style output. Requires a file -// extension (1-8 alphanumerics) so plain words don't qualify; the path itself -// must contain at least one extension-bearing segment. -// -// Known limitation: backslash-separated Windows paths (e.g. -// `C:\Users\test\file.ts:12`) are not matched because the path character class -// does not include `\`. Compiler output inside fenced code blocks predominantly -// uses forward slashes, so this is a niche gap. The inline-code pipeline is not -// affected — it reads full text content rather than matching with a regex. -const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+){0,2}/g; +// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style +// output. The regex is defined in `./fileReferenceParser`; the inline-code +// pipeline reads full text content rather than using this regex. const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000; const FILE_REFERENCE_STAT_CONCURRENCY = 4; const FILE_REFERENCE_STAT_CACHE_MAX = 1000; @@ -177,12 +177,6 @@ const getFileReferenceLinkLimit = (): number => ( isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT ); -type ParsedFileReference = { - path: string; - line?: number; - column?: number; -}; - const KNOWN_FILE_BASENAMES = new Set([ 'dockerfile', 'makefile', @@ -192,126 +186,19 @@ const KNOWN_FILE_BASENAMES = new Set([ '.gitignore', '.npmrc', ]); -const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) - .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) - .join('|'); const normalizePath = (value: string): string => { - return normalizeFilePath(value); + return normalizeReferencePath(value); }; const isAbsolutePath = (value: string): boolean => { - return isAbsoluteFilePath(value); + return isAbsoluteReferencePath(value); }; const toAbsolutePath = (basePath: string, targetPath: string): string => { return toAbsoluteFilePath(basePath, targetPath); }; -const trimPathCandidate = (value: string): string => { - let next = (value || '').trim(); - if (!next) { - return ''; - } - - if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { - next = next.slice(1, -1).trim(); - } - - next = next.replace(/[.,;!?]+$/g, ''); - - if (next.endsWith(')') && !next.includes('(')) { - next = next.slice(0, -1); - } - if (next.endsWith(']') && !next.includes('[')) { - next = next.slice(0, -1); - } - - return next; -}; - -const stripTrailingReference = (value: string): string => { - let next = trimPathCandidate(value); - if (!next) { - return ''; - } - - const semicolonIndex = next.indexOf(';'); - if (semicolonIndex >= 0) { - next = next.slice(0, semicolonIndex); - } - - next = next.replace(/#.*$/, ''); - - const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); - if (extensionSuffixMatch) { - next = extensionSuffixMatch[1] ?? next; - } - - const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 - ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) - : null; - if (basenameSuffixMatch) { - next = basenameSuffixMatch[1] ?? next; - } - - return trimPathCandidate(next); -}; - -const parseFileReference = (value: string): ParsedFileReference | null => { - const trimmed = trimPathCandidate(value); - if (!trimmed) { - return null; - } - - const semicolonIndex = trimmed.indexOf(';'); - const withoutSemicolonSuffix = semicolonIndex >= 0 - ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) - : trimmed; - if (!withoutSemicolonSuffix) { - return null; - } - - const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); - if (hashMatch) { - const path = stripTrailingReference(hashMatch[1] ?? ''); - const line = Number.parseInt(hashMatch[2] ?? '', 10); - const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; - if (!path || !Number.isFinite(line)) { - return null; - } - - return { - path, - line, - column: Number.isFinite(column ?? Number.NaN) ? column : undefined, - }; - } - - const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/); - if (colonMatch) { - const path = stripTrailingReference(colonMatch[1] ?? ''); - const line = Number.parseInt(colonMatch[2] ?? '', 10); - const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; - if (!path || !Number.isFinite(line)) { - return null; - } - - return { - path, - line, - column: Number.isFinite(column ?? Number.NaN) ? column : undefined, - }; - } - - const pathOnly = stripTrailingReference(withoutSemicolonSuffix); - if (!pathOnly) { - return null; - } - - return { path: pathOnly }; -}; - const hasFileExtension = (path: string): boolean => { const base = path.split('/').filter(Boolean).pop() ?? ''; if (!base || base.endsWith('.')) { diff --git a/packages/ui/src/components/chat/fileReferenceParser.ts b/packages/ui/src/components/chat/fileReferenceParser.ts new file mode 100644 index 0000000000..b2c6b91e28 --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceParser.ts @@ -0,0 +1,157 @@ +import { isAbsoluteFilePath, normalizeFilePath } from '@/lib/path-utils'; + +export type ParsedFileReference = { + path: string; + line?: number; + column?: number; + endLine?: number; +}; + +const KNOWN_FILE_BASENAMES = new Set([ + 'dockerfile', + 'makefile', + 'readme', + 'license', + '.env', + '.gitignore', + '.npmrc', +]); +const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES) + .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + +export const normalizeReferencePath = (value: string): string => normalizeFilePath(value); + +export const isAbsoluteReferencePath = (value: string): boolean => isAbsoluteFilePath(value); + +const trimPathCandidate = (value: string): string => { + let next = (value || '').trim(); + if (!next) { + return ''; + } + + if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) { + next = next.slice(1, -1).trim(); + } + + next = next.replace(/[.,;!?]+$/g, ''); + + if (next.endsWith(')') && !next.includes('(')) { + next = next.slice(0, -1); + } + if (next.endsWith(']') && !next.includes('[')) { + next = next.slice(0, -1); + } + + return next; +}; + +const stripTrailingReference = (value: string): string => { + let next = trimPathCandidate(value); + if (!next) { + return ''; + } + + const semicolonIndex = next.indexOf(';'); + if (semicolonIndex >= 0) { + next = next.slice(0, semicolonIndex); + } + + next = next.replace(/#.*$/, ''); + + const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/); + if (extensionSuffixMatch) { + next = extensionSuffixMatch[1] ?? next; + } + + const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0 + ? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i')) + : null; + if (basenameSuffixMatch) { + next = basenameSuffixMatch[1] ?? next; + } + + return trimPathCandidate(next); +}; + +export const parseFileReference = (value: string): ParsedFileReference | null => { + const trimmed = trimPathCandidate(value); + if (!trimmed) { + return null; + } + + const semicolonIndex = trimmed.indexOf(';'); + const withoutSemicolonSuffix = semicolonIndex >= 0 + ? trimPathCandidate(trimmed.slice(0, semicolonIndex)) + : trimmed; + if (!withoutSemicolonSuffix) { + return null; + } + + // Range form: `path:start-end`. Tried before the colon form so a suffix + // like `:10-20` is consumed as a range rather than truncated to a line + // number. Range and col (`:line:col`) are mutually exclusive. + const rangeMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)-(\d+)$/); + if (rangeMatch) { + const path = stripTrailingReference(rangeMatch[1] ?? ''); + const line = Number.parseInt(rangeMatch[2] ?? '', 10); + const endLine = Number.parseInt(rangeMatch[3] ?? '', 10); + if (!path || !Number.isFinite(line) || !Number.isFinite(endLine) || endLine < line) { + return null; + } + + return { path, line, endLine }; + } + + const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i); + if (hashMatch) { + const path = stripTrailingReference(hashMatch[1] ?? ''); + const line = Number.parseInt(hashMatch[2] ?? '', 10); + const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const colonMatch = withoutSemicolonSuffix.match(/^(.*?):(\d+)(?::(\d+))?$/); + if (colonMatch) { + const path = stripTrailingReference(colonMatch[1] ?? ''); + const line = Number.parseInt(colonMatch[2] ?? '', 10); + const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined; + if (!path || !Number.isFinite(line)) { + return null; + } + + return { + path, + line, + column: Number.isFinite(column ?? Number.NaN) ? column : undefined, + }; + } + + const pathOnly = stripTrailingReference(withoutSemicolonSuffix); + if (!pathOnly) { + return null; + } + + return { path: pathOnly }; +}; + +// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style +// output. Requires a file extension (1-8 alphanumerics) so plain words don't +// qualify; the path itself must contain at least one extension-bearing +// segment. The line suffix is either `:N`, `:N:M`, or `:N-M` (range); col and +// range are mutually exclusive. +// +// Known limitation: backslash-separated Windows paths (e.g. +// `C:\Users\test\file.ts:12`) are not matched because the path character class +// does not include `\`. Compiler output inside fenced code blocks predominantly +// uses forward slashes, so this is a niche gap. The inline-code pipeline is not +// affected — it reads full text content rather than matching with a regex. +export const BLOCK_PATH_TOKEN_RE = /(?:[A-Za-z]:[\\/])?[\w.\-/@+]*[\w\-/@+]\.[A-Za-z0-9]{1,8}(?::\d+(?:-\d+)?(?::\d+)?)?/g; From 0e4b4ed658604ca22c645481ffdd071617b03959 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 01:26:56 +0300 Subject: [PATCH 84/88] fix: compute first changed diff line from patch hunks Uses hunk contents to find the first modified line instead of the hunk start Handles added, removed, and binary-only patches more accurately Adds tests for patch parsing edge cases --- .../ui/src/components/views/DiffView.test.ts | 26 ++++++++++++++ packages/ui/src/components/views/DiffView.tsx | 25 +++---------- .../ui/src/components/views/diffPatchUtils.ts | 36 +++++++++++++++++++ 3 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/components/views/DiffView.test.ts create mode 100644 packages/ui/src/components/views/diffPatchUtils.ts diff --git a/packages/ui/src/components/views/DiffView.test.ts b/packages/ui/src/components/views/DiffView.test.ts new file mode 100644 index 0000000000..19f38bf505 --- /dev/null +++ b/packages/ui/src/components/views/DiffView.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; + +import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils'; + +describe('getFirstChangedModifiedLineFromPatch', () => { + test('returns the first added line instead of the hunk context start', () => { + expect(getFirstChangedModifiedLineFromPatch(`diff --git a/src/file.ts b/src/file.ts +@@ -56,10 +56,11 @@ + unchanged 58 + unchanged 59 + unchanged 60 ++changed 61 + unchanged 62`)).toBe(59); + }); + + test('returns the following modified line for deletion-only hunks', () => { + expect(getFirstChangedModifiedLineFromPatch(`@@ -10,4 +10,3 @@ + context +-removed + after`)).toBe(11); + }); + + test('returns null when the patch has no hunk change lines', () => { + expect(getFirstChangedModifiedLineFromPatch('Binary files a/image.png and b/image.png differ')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 6a620a6beb..c1447a67be 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -39,6 +39,7 @@ import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; import { isVSCodeRuntime } from '@/lib/desktop'; import { startReviewFlow } from '@/lib/reviewFlow'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils'; import type { FileDiffMetadata } from '@pierre/diffs'; // Minimum width for side-by-side diff view (px) @@ -160,24 +161,6 @@ const getFirstChangedModifiedLine = (original: string, modified: string): number return 1; }; -const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => { - if (!patch) { - return null; - } - - const match = patch.match(/@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/m); - if (!match) { - return null; - } - - const parsed = Number.parseInt(match[1], 10); - if (!Number.isFinite(parsed) || parsed < 1) { - return null; - } - - return parsed; -}; - const isBinaryPatch = (patch: string): boolean => /^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch); @@ -1422,7 +1405,9 @@ export const DiffView: React.FC = ({ try { let targetLine: number | null = null; - if (cachedDiffData && !cachedDiffData.isBinary && !isImageFile(filePath)) { + if (cachedDiffData?.patch && !cachedDiffData.isBinary && !isImageFile(filePath)) { + targetLine = getFirstChangedModifiedLineFromPatch(cachedDiffData.patch); + } else if (cachedDiffData && cachedDiffData.contextMode === 'full' && !cachedDiffData.isBinary && !isImageFile(filePath)) { targetLine = getFirstChangedModifiedLine(cachedDiffData.original, cachedDiffData.modified); } @@ -1433,7 +1418,7 @@ export const DiffView: React.FC = ({ staged: activeDiffStaged, contextLines: 3, }); - targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff); + targetLine = getFirstChangedModifiedLineFromPatch(patchResponse.diff); } catch { targetLine = null; } diff --git a/packages/ui/src/components/views/diffPatchUtils.ts b/packages/ui/src/components/views/diffPatchUtils.ts new file mode 100644 index 0000000000..8ab335c3c3 --- /dev/null +++ b/packages/ui/src/components/views/diffPatchUtils.ts @@ -0,0 +1,36 @@ +export const getFirstChangedModifiedLineFromPatch = (patch: string): number | null => { + if (!patch) { + return null; + } + + const lines = patch.split('\n'); + let modifiedLine: number | null = null; + + for (const line of lines) { + const hunkMatch = line.match(/^@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/); + if (hunkMatch) { + const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); + modifiedLine = Number.isFinite(parsed) && parsed >= 1 ? parsed : null; + continue; + } + + if (modifiedLine === null) { + continue; + } + + if (line.startsWith(' ')) { + modifiedLine += 1; + continue; + } + + if (line.startsWith('+')) { + return modifiedLine; + } + + if (line.startsWith('-')) { + return Math.max(1, modifiedLine); + } + } + + return null; +}; From e1b1181c1e642e54204a89f466f944dad5699cf9 Mon Sep 17 00:00:00 2001 From: Catan <84828825+catan271@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:16:23 +0700 Subject: [PATCH 85/88] fix(vscode): restore previous view when exiting settings (closes #1776) (closes #1848) --- .../ui/src/components/layout/VSCodeLayout.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index a2dd707d06..10d44b28b8 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -108,6 +108,10 @@ export const VSCodeLayout: React.FC = () => { }, []); const [currentView, setCurrentView] = React.useState(() => (bootDraftOpen ? 'chat' : 'sessions')); + // Mirror currentView so the navigate event handler (registered once) can read the live value. + const currentViewRef = React.useRef(currentView); + // Snapshot of the view the user was on before opening Settings, so close restores it. + const viewBeforeSettingsRef = React.useRef(null); const [containerWidth, setContainerWidth] = React.useState(0); const [expandedSidebarWidth, setExpandedSidebarWidth] = React.useState(SESSIONS_SIDEBAR_WIDTH); const [isResizingExpandedSidebar, setIsResizingExpandedSidebar] = React.useState(false); @@ -185,6 +189,11 @@ export const VSCodeLayout: React.FC = () => { } }, [currentSessionId]); + // Keep currentViewRef in sync so the stable navigate handler reads the live view. + React.useEffect(() => { + currentViewRef.current = currentView; + }, [currentView]); + React.useEffect(() => { const vscodeApi = runtimeApis.vscode; if (!vscodeApi) { @@ -347,6 +356,9 @@ export const VSCodeLayout: React.FC = () => { const detail = (event as CustomEvent<{ view?: string }>).detail; const view = detail?.view; if (view === 'settings') { + if (currentViewRef.current !== 'settings') { + viewBeforeSettingsRef.current = currentViewRef.current; + } setCurrentView('settings'); } else if (view === 'chat') { setCurrentView('chat'); @@ -532,7 +544,11 @@ export const VSCodeLayout: React.FC = () => { // Settings view setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')} + onClose={() => { + const previousView = viewBeforeSettingsRef.current; + viewBeforeSettingsRef.current = null; + setCurrentView(previousView ?? (usesExpandedLayout ? 'chat' : 'sessions')); + }} forceMobile={usesMobileLayout} /> From 0a5953a2b750efe595c5ce2f1d7ddfd55fa7e190 Mon Sep 17 00:00:00 2001 From: Catan <84828825+catan271@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:18:38 +0700 Subject: [PATCH 86/88] fix(vscode): persist model favorites (#1995) --- packages/ui/src/lib/modelPrefsAutoSave.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ui/src/lib/modelPrefsAutoSave.ts b/packages/ui/src/lib/modelPrefsAutoSave.ts index e9d8ecbf49..130f525360 100644 --- a/packages/ui/src/lib/modelPrefsAutoSave.ts +++ b/packages/ui/src/lib/modelPrefsAutoSave.ts @@ -1,6 +1,5 @@ import { useUIStore } from '@/stores/useUIStore'; import { updateDesktopSettings } from '@/lib/persistence'; -import { isVSCodeRuntime } from '@/lib/desktop'; type ModelRef = { providerID: string; modelID: string }; type ModelPrefsPayload = { @@ -72,9 +71,6 @@ export const startModelPrefsAutoSave = () => { if (typeof window === 'undefined') { return () => {}; } - if (isVSCodeRuntime()) { - return () => {}; - } let timer: number | null = null; let lastSent: ModelPrefsPayload | null = null; From f61eb30c885a9397d02fc933b8a396e6bfabbed8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 6 Jul 2026 17:24:01 +0300 Subject: [PATCH 87/88] feat: add load earlier support to chat timeline Adds a load older button when earlier history is available Preserves scroll position while older messages are loaded Shows date-grouped messages with clearer per-message timestamps --- .../ui/src/components/chat/ChatContainer.tsx | 3 + .../ui/src/components/chat/TimelineDialog.tsx | 290 ++++++++++++------ 2 files changed, 200 insertions(+), 93 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 47e5d92b82..883b832e9a 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -993,6 +993,9 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr onScrollToMessage={timelineController.scrollToMessage} onScrollByTurnOffset={navigation.scrollByTurnOffset} onResumeToLatest={resumeToLatestInstant} + canLoadEarlier={timelineController.historySignals.canLoadEarlier} + isLoadingEarlier={timelineController.isLoadingOlder} + onLoadEarlier={handleLoadOlderClick} />
); diff --git a/packages/ui/src/components/chat/TimelineDialog.tsx b/packages/ui/src/components/chat/TimelineDialog.tsx index 4800c27b97..bc16b00534 100644 --- a/packages/ui/src/components/chat/TimelineDialog.tsx +++ b/packages/ui/src/components/chat/TimelineDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords } from '@/sync/sync-context'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -22,6 +23,9 @@ interface TimelineDialogProps { onScrollToMessage?: (messageId: string) => void | Promise; onScrollByTurnOffset?: (offset: number) => void; onResumeToLatest?: () => void; + canLoadEarlier?: boolean; + isLoadingEarlier?: boolean; + onLoadEarlier?: () => void; } export const TimelineDialog: React.FC = ({ @@ -30,6 +34,9 @@ export const TimelineDialog: React.FC = ({ onScrollToMessage, onScrollByTurnOffset, onResumeToLatest, + canLoadEarlier = false, + isLoadingEarlier = false, + onLoadEarlier, }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -43,31 +50,31 @@ export const TimelineDialog: React.FC = ({ const [searchQuery, setSearchQuery] = React.useState(''); const [selectedIndex, setSelectedIndex] = React.useState(0); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); + const listRef = React.useRef(null); + const pendingLoadAnchorRef = React.useRef<{ messageId: string; top: number } | null>(null); + const preservingLoadPositionRef = React.useRef(false); + const wasOpenRef = React.useRef(open); - const formatRelativeTime = React.useCallback((timestamp: number): string => { - const now = Date.now(); - const diffMs = now - timestamp; - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffSecs < 60) return t('chat.timeline.relative.justNow'); - if (diffMins < 60) return t('chat.timeline.relative.minutesAgo', { count: diffMins }); - if (diffHours < 24) return t('chat.timeline.relative.hoursAgo', { count: diffHours }); - if (diffDays < 7) return t('chat.timeline.relative.daysAgo', { count: diffDays }); - return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale()); - }, [t]); + const formatDateGroup = React.useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleDateString(getCurrentIntlLocale(), { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }, []); + + const formatMessageTime = React.useCallback((timestamp: number): string => { + return new Date(timestamp).toLocaleTimeString(getCurrentIntlLocale(), { + hour: 'numeric', + minute: '2-digit', + }); + }, []); // Timeline actions are only valid for user messages. const userMessages = React.useMemo(() => { return messages .filter((message) => message.info.role === 'user') - .map((message, index) => ({ - message, - messageNumber: index + 1, - })) - .reverse(); + .map((message) => ({ message })); }, [messages]); // Filter by search query using all text parts in each user message. @@ -83,19 +90,89 @@ export const TimelineDialog: React.FC = ({ }, [userMessages, searchQuery]); React.useEffect(() => { - setSelectedIndex(0); - }, [filteredMessages]); + if (preservingLoadPositionRef.current) { + return; + } + + setSelectedIndex(searchQuery.trim() ? 0 : Math.max(0, filteredMessages.length - 1)); + }, [filteredMessages, searchQuery]); React.useEffect(() => { itemRefs.current = itemRefs.current.slice(0, filteredMessages.length); }, [filteredMessages.length]); React.useEffect(() => { + if (preservingLoadPositionRef.current) { + return; + } + itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', }); }, [selectedIndex]); + React.useEffect(() => { + if (!preservingLoadPositionRef.current || pendingLoadAnchorRef.current || isLoadingEarlier) { + return; + } + + preservingLoadPositionRef.current = false; + }, [filteredMessages.length, isLoadingEarlier]); + + React.useLayoutEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = open; + + if (!open || wasOpen || preservingLoadPositionRef.current || searchQuery.trim()) { + return; + } + + const container = listRef.current; + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + }, [open, searchQuery]); + + React.useLayoutEffect(() => { + const anchor = pendingLoadAnchorRef.current; + const container = listRef.current; + if (!anchor || !container || isLoadingEarlier) { + return; + } + + pendingLoadAnchorRef.current = null; + const anchoredRow = itemRefs.current.find((row) => row?.dataset.timelineMessageId === anchor.messageId); + if (!anchoredRow) { + return; + } + + const nextTop = anchoredRow.getBoundingClientRect().top - container.getBoundingClientRect().top; + container.scrollTop += nextTop - anchor.top; + }, [filteredMessages.length, isLoadingEarlier]); + + const handleLoadEarlier = React.useCallback(() => { + const container = listRef.current; + if (container) { + const containerTop = container.getBoundingClientRect().top; + const firstVisibleRow = itemRefs.current.find((row) => { + if (!row) return false; + return row.getBoundingClientRect().bottom >= containerTop; + }); + + if (firstVisibleRow?.dataset.timelineMessageId) { + pendingLoadAnchorRef.current = { + messageId: firstVisibleRow.dataset.timelineMessageId, + top: firstVisibleRow.getBoundingClientRect().top - containerTop, + }; + } + } + + preservingLoadPositionRef.current = true; + onLoadEarlier?.(); + }, [onLoadEarlier]); + const navigateToMessage = React.useCallback(async (messageId: string) => { const didNavigate = await onScrollToMessage?.(messageId); if (didNavigate === false) { @@ -171,16 +248,40 @@ export const TimelineDialog: React.FC = ({ />
-
+ {canLoadEarlier && onLoadEarlier && ( +
+ +
+ )} + +
{filteredMessages.length === 0 ? (
{searchQuery ? t('chat.timeline.empty.search') : t('chat.timeline.empty.session')}
) : ( - filteredMessages.map(({ message, messageNumber }, index) => { + filteredMessages.map(({ message }, index) => { const preview = getMessagePreview(message.parts); const timestamp = message.info.time.created; - const relativeTime = formatRelativeTime(timestamp); + const dateGroup = formatDateGroup(timestamp); + const previous = filteredMessages[index - 1]; + const previousDateGroup = previous + ? formatDateGroup(previous.message.info.time.created) + : null; + const showDateGroup = dateGroup !== previousDateGroup; + const messageTime = formatMessageTime(timestamp); const isSelected = index === selectedIndex; const snippet = searchQuery.trim() @@ -188,82 +289,85 @@ export const TimelineDialog: React.FC = ({ : null; return ( -
{ - itemRefs.current[index] = element; - }} - className={cn( - "group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer", - isSelected && "bg-interactive-selection text-interactive-selection-foreground" + + {showDateGroup && ( +
+
+ + {dateGroup} + +
+
)} - onClick={() => void navigateToMessage(message.info.id)} - onMouseEnter={() => setSelectedIndex(index)} - > - - {messageNumber}. - -

- {snippet ?? (preview || t('chat.timeline.noTextContent'))} - {!snippet && preview && preview.length >= 80 && '…'} -

- -
+
{ + itemRefs.current[index] = element; + }} + data-timeline-message-id={message.info.id} + className={cn( + "group flex items-center gap-3 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer", + isSelected && "bg-interactive-selection text-interactive-selection-foreground" + )} + onClick={() => void navigateToMessage(message.info.id)} + onMouseEnter={() => setSelectedIndex(index)} + > - {relativeTime} + {messageTime} +

+ {snippet ?? (preview || t('chat.timeline.noTextContent'))} + {!snippet && preview && preview.length >= 80 && '…'} +

+ +
+
+ + + + + {t('chat.timeline.actions.revertFromHere')} + -
- - - - - {t('chat.timeline.actions.revertFromHere')} - - - - - - - {t('chat.timeline.actions.forkFromHere')} - + + + + + {t('chat.timeline.actions.forkFromHere')} + +
-
+ ); }) )} From 8009ab8b4d05c51c15cc1bd3a7f59b0f827adafd Mon Sep 17 00:00:00 2001 From: bashrusakh Date: Tue, 7 Jul 2026 05:08:26 +1100 Subject: [PATCH 88/88] fix(sidebar): add project sort modes --- .../src/components/session/SessionSidebar.tsx | 74 ++++++++++++++++++- .../session/sidebar/SidebarHeader.tsx | 57 ++++++++++++++ .../session/sidebar/SidebarProjectsList.tsx | 5 ++ .../session/sidebar/sortableItems.tsx | 4 +- packages/ui/src/lib/i18n/messages/en.ts | 6 ++ packages/ui/src/lib/i18n/messages/es.ts | 6 ++ packages/ui/src/lib/i18n/messages/fr.ts | 6 ++ packages/ui/src/lib/i18n/messages/ja.ts | 6 ++ packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ packages/ui/src/stores/useProjectsStore.ts | 56 ++++++++++---- .../ui/src/stores/useSessionDisplayStore.ts | 16 +++- 16 files changed, 255 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f8e8b562ff..abe99af5dc 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -910,6 +910,9 @@ export const SessionSidebar: React.FC = ({ color?: string; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' }; iconBackground?: string; + addedAt?: number; + lastOpenedAt?: number; + sidebarCollapsed?: boolean; }>; }, [projects]); @@ -1014,7 +1017,7 @@ export const SessionSidebar: React.FC = ({ sectionsForRender, searchMatchCount, } = useSessionSidebarSections({ - normalizedProjects, + normalizedProjects: sortedProjects, getSessionsForProject, getArchivedSessionsForProject, availableWorktreesByProject, @@ -1122,6 +1125,74 @@ export const SessionSidebar: React.FC = ({ const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection); const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); + const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); + const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder); + + const recentProjectIds = React.useMemo(() => { + const recentSessions = deriveRecentSessions(sessions); + if (recentSessions.length === 0) return new Set(); + + // Build path→id map once for O(1) lookups + const pathToId = new Map(); + for (const project of normalizedProjects) { + if (project.normalizedPath) { + pathToId.set(project.normalizedPath, project.id); + } + } + + const ids = new Set(); + for (const session of recentSessions) { + const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + if (directory) { + const projectId = pathToId.get(directory); + if (projectId) ids.add(projectId); + } + } + return ids; + }, [sessions, normalizedProjects]); + + const sortedProjects = React.useMemo(() => { + const list = [...normalizedProjects]; + + // 1. Sort by the selected order + switch (projectSortOrder) { + case 'a-z': + list.sort((a, b) => { + const aLabel = (a.label || a.path).toLowerCase(); + const bLabel = (b.label || b.path).toLowerCase(); + return aLabel.localeCompare(bLabel); + }); + break; + case 'z-a': + list.sort((a, b) => { + const aLabel = (a.label || a.path).toLowerCase(); + const bLabel = (b.label || b.path).toLowerCase(); + return bLabel.localeCompare(aLabel); + }); + break; + case 'date-added': + list.sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0)); + break; + case 'recent': + list.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0)); + break; + case 'manual': { + // Restore from snapshot, keep unknown projects at the end + const orderMap = new Map(manualProjectOrder.map((id, i) => [id, i])); + list.sort((a, b) => { + const ai = orderMap.get(a.id) ?? Infinity; + const bi = orderMap.get(b.id) ?? Infinity; + return ai - bi; + }); + break; + } + } + + // 2. Pin projects with recent sessions to the top + const recent = list.filter((p) => recentProjectIds.has(p.id)); + const rest = list.filter((p) => !recentProjectIds.has(p.id)); + return [...recent, ...rest]; + }, [normalizedProjects, projectSortOrder, manualProjectOrder, recentProjectIds]); const activeNowSessions = React.useMemo(() => { if (!showRecentSection || isVSCode) { @@ -1625,6 +1696,7 @@ export const SessionSidebar: React.FC = ({ removeProject={removeProject} projectHeaderSentinelRefs={projectHeaderSentinelRefs} reorderProjects={reorderProjects} + projectSortOrder={projectSortOrder} getOrderedGroups={getOrderedGroups} setGroupOrderByProject={setGroupOrderByProject} openSidebarMenuKey={openSidebarMenuKey} diff --git a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx index b1d37f8974..ae3de278d6 100644 --- a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx @@ -68,6 +68,8 @@ export function SidebarHeader(props: Props): React.ReactNode { const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode); const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection); const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions); + const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); + const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder); // VS Code forces the expanded layout, so the mode toggle is meaningless there. const showDisplayModeToggle = !isVSCodeRuntime(); @@ -175,6 +177,60 @@ export function SidebarHeader(props: Props): React.ReactNode { + + + + + + + +

{t('sessions.sidebar.header.actions.sortProjects')}

+
+ + setProjectSortOrder('manual')} + className="flex items-center justify-between" + > + {t('sessions.sidebar.header.projectSort.manual')} + {projectSortOrder === 'manual' ? : null} + + setProjectSortOrder('a-z')} + className="flex items-center justify-between" + > + {t('sessions.sidebar.header.projectSort.aToZ')} + {projectSortOrder === 'a-z' ? : null} + + setProjectSortOrder('z-a')} + className="flex items-center justify-between" + > + {t('sessions.sidebar.header.projectSort.zToA')} + {projectSortOrder === 'z-a' ? : null} + + setProjectSortOrder('date-added')} + className="flex items-center justify-between" + > + {t('sessions.sidebar.header.projectSort.dateAdded')} + {projectSortOrder === 'date-added' ? : null} + + setProjectSortOrder('recent')} + className="flex items-center justify-between" + > + {t('sessions.sidebar.header.projectSort.recent')} + {projectSortOrder === 'recent' ? : null} + + +
+ @@ -237,6 +293,7 @@ export function SidebarHeader(props: Props): React.ReactNode { {t('sessions.sidebar.header.displayMode.expandAll')} +
diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index 849987b045..91dc346eb4 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -17,6 +17,7 @@ import { SortableGroupItem, SortableProjectItem } from './sortableItems'; import { formatProjectLabel } from './utils'; import { useI18n } from '@/lib/i18n'; import type { MainTab } from '@/stores/useUIStore'; +import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore'; type ProjectSection = { project: { @@ -69,6 +70,7 @@ type Props = { removeProject: (id: string) => void; projectHeaderSentinelRefs: React.MutableRefObject>; reorderProjects: (fromIndex: number, toIndex: number) => void; + projectSortOrder: ProjectSortOrder; getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[]; setGroupOrderByProject: React.Dispatch>>; openSidebarMenuKey: string | null; @@ -188,6 +190,8 @@ export function SidebarProjectsList(props: Props): React.ReactNode { collisionDetection={closestCenter} onDragEnd={(event) => { if (props.isInlineEditing) return; + // Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes + if (props.projectSortOrder !== 'manual') return; const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id); @@ -219,6 +223,7 @@ export function SidebarProjectsList(props: Props): React.ReactNode { = ({ id, + disabled = false, projectLabel, projectDescription, projectIcon, @@ -85,7 +87,7 @@ export const SortableProjectItem: React.FC = ({ transform, transition, isDragging, - } = useSortable({ id }); + } = useSortable({ id, disabled }); const suppressNextToggleRef = React.useRef(false); const menuInstanceKey = `project:${id}`; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 175439bd45..77d2c6674c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -376,6 +376,7 @@ export const dict = { 'sessions.sidebar.header.actions.searchSessions': 'Search sessions', 'sessions.sidebar.header.actions.exitSelection': 'Exit selection', 'sessions.sidebar.header.actions.selectSessions': 'Select sessions', + 'sessions.sidebar.header.actions.sortProjects': 'Sort projects', 'sessions.sidebar.header.actions.sessionDisplayMode': 'Session display mode', 'sessions.sidebar.header.displayMode.label': 'Display mode', 'sessions.sidebar.header.displayMode.default': 'Default', @@ -384,6 +385,11 @@ export const dict = { 'sessions.sidebar.header.displayMode.showArchived': 'Show archived sessions', 'sessions.sidebar.header.displayMode.collapseAll': 'Collapse all', 'sessions.sidebar.header.displayMode.expandAll': 'Expand all', + 'sessions.sidebar.header.projectSort.manual': 'Manual', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': 'Newest', + 'sessions.sidebar.header.projectSort.recent': 'Recent', 'sessions.sidebar.header.search.matchCountSingle': '{count} match', 'sessions.sidebar.header.search.matchCountPlural': '{count} matches', 'sessions.sidebar.header.search.escapeHint': 'Esc to clear', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 22f56c4829..c92a8a2034 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -377,6 +377,7 @@ export const dict: Record = { "sessions.sidebar.header.actions.searchSessions": "Buscar sesiones", "sessions.sidebar.header.actions.exitSelection": "Salir de selección", "sessions.sidebar.header.actions.selectSessions": "Seleccionar sesiones", + "sessions.sidebar.header.actions.sortProjects": "Ordenar proyectos", "sessions.sidebar.header.actions.sessionDisplayMode": "Modo de visualización de sesión", "sessions.sidebar.header.displayMode.label": "Modo de visualización", "sessions.sidebar.header.displayMode.default": "Predeterminado", @@ -385,6 +386,11 @@ export const dict: Record = { "sessions.sidebar.header.displayMode.showArchived": "Mostrar sesiones archivadas", "sessions.sidebar.header.displayMode.collapseAll": "Colapsar todo", "sessions.sidebar.header.displayMode.expandAll": "Expandir todo", + "sessions.sidebar.header.projectSort.manual": "Manual", + "sessions.sidebar.header.projectSort.aToZ": "A → Z", + "sessions.sidebar.header.projectSort.zToA": "Z → A", + "sessions.sidebar.header.projectSort.dateAdded": "Más recientes", + "sessions.sidebar.header.projectSort.recent": "Recientes", "sessions.sidebar.header.search.matchCountSingle": "{count} coincidencia", "sessions.sidebar.header.search.matchCountPlural": "{count} coincidencias", "sessions.sidebar.header.search.escapeHint": "Esc para limpiar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 330028e50c..20bb037eb2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -227,6 +227,7 @@ export const dict = { 'sessions.sidebar.header.actions.searchSessions': 'Sessions de recherche', 'sessions.sidebar.header.actions.exitSelection': 'Quitter la sélection', 'sessions.sidebar.header.actions.selectSessions': 'Sélectionnez des sessions', + 'sessions.sidebar.header.actions.sortProjects': 'Trier les projets', 'sessions.sidebar.header.actions.sessionDisplayMode': 'Mode d\'affichage des sessions', 'sessions.sidebar.header.displayMode.label': 'Mode d\'affichage', 'sessions.sidebar.header.displayMode.default': 'Défaut', @@ -234,6 +235,11 @@ export const dict = { 'sessions.sidebar.header.displayMode.showRecent': 'Afficher la section récente', 'sessions.sidebar.header.displayMode.collapseAll': 'Tout réduire', 'sessions.sidebar.header.displayMode.expandAll': 'Tout développer', + 'sessions.sidebar.header.projectSort.manual': 'Manuel', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': 'Les plus récentes', + 'sessions.sidebar.header.projectSort.recent': 'Récentes', 'sessions.sidebar.header.search.matchCountSingle': 'Correspondance {count}', 'sessions.sidebar.header.search.matchCountPlural': 'Correspondances {count}', 'sessions.sidebar.header.search.escapeHint': 'Echap pour effacer', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index df98d74a2e..f14d9bc154 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -377,6 +377,7 @@ export const dict: Record = { 'sessions.sidebar.header.actions.searchSessions': 'セッションを検索', 'sessions.sidebar.header.actions.exitSelection': '選択を終了', 'sessions.sidebar.header.actions.selectSessions': 'セッションを選択', + 'sessions.sidebar.header.actions.sortProjects': 'プロジェクトを並べ替え', 'sessions.sidebar.header.actions.sessionDisplayMode': 'セッション表示モード', 'sessions.sidebar.header.displayMode.label': '表示モード', 'sessions.sidebar.header.displayMode.default': 'デフォルト', @@ -385,6 +386,11 @@ export const dict: Record = { 'sessions.sidebar.header.displayMode.showArchived': 'アーカイブ済みセッションを表示', 'sessions.sidebar.header.displayMode.collapseAll': 'すべて折りたたむ', 'sessions.sidebar.header.displayMode.expandAll': 'すべて展開', + 'sessions.sidebar.header.projectSort.manual': '手動', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': '新しい順', + 'sessions.sidebar.header.projectSort.recent': '最近', 'sessions.sidebar.header.search.matchCountSingle': '{count}件一致', 'sessions.sidebar.header.search.matchCountPlural': '{count}件一致', 'sessions.sidebar.header.search.escapeHint': 'Escでクリア', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 8047d3c6aa..5c6d5a1986 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -377,6 +377,7 @@ export const dict: Record = { 'sessions.sidebar.header.actions.searchSessions': '세션 검색', 'sessions.sidebar.header.actions.exitSelection': '선택 종료', 'sessions.sidebar.header.actions.selectSessions': '세션 선택', + 'sessions.sidebar.header.actions.sortProjects': '프로젝트 정렬', 'sessions.sidebar.header.actions.sessionDisplayMode': '세션 표시 모드', 'sessions.sidebar.header.displayMode.label': '표시 모드', 'sessions.sidebar.header.displayMode.default': '기본값', @@ -385,6 +386,11 @@ export const dict: Record = { 'sessions.sidebar.header.displayMode.showArchived': '보관된 세션 표시', 'sessions.sidebar.header.displayMode.collapseAll': '모두 접기', 'sessions.sidebar.header.displayMode.expandAll': '모두 펼치기', + 'sessions.sidebar.header.projectSort.manual': '수동', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': '최신순', + 'sessions.sidebar.header.projectSort.recent': '최근', 'sessions.sidebar.header.search.matchCountSingle': '{count}개 일치', 'sessions.sidebar.header.search.matchCountPlural': '{count}개 일치', 'sessions.sidebar.header.search.escapeHint': 'Esc로 지우기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2b4c2feb80..ffa3018538 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -199,6 +199,7 @@ export const dict: Record = { 'sessions.sidebar.header.actions.searchSessions': 'Szukaj sesji', 'sessions.sidebar.header.actions.exitSelection': 'Wyjdź z zaznaczenia', 'sessions.sidebar.header.actions.selectSessions': 'Wybierz sesje', + 'sessions.sidebar.header.actions.sortProjects': 'Sortuj projekty', 'sessions.sidebar.header.actions.sessionDisplayMode': 'Tryb wyświetlania sesji', 'sessions.sidebar.header.displayMode.label': 'Tryb wyświetlania', 'sessions.sidebar.header.displayMode.default': 'Domyślny', @@ -207,6 +208,11 @@ export const dict: Record = { 'sessions.sidebar.header.displayMode.showArchived': 'Pokaż zarchiwizowane sesje', 'sessions.sidebar.header.displayMode.collapseAll': 'Zwiń wszystkie', 'sessions.sidebar.header.displayMode.expandAll': 'Rozwiń wszystkie', + 'sessions.sidebar.header.projectSort.manual': 'Ręcznie', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': 'Najnowsze', + 'sessions.sidebar.header.projectSort.recent': 'Ostatnie', 'sessions.sidebar.header.search.matchCountSingle': '{count} dopasowanie', 'sessions.sidebar.header.search.matchCountPlural': '{count} dopasowań', 'sessions.sidebar.header.search.escapeHint': 'Esc aby wyczyścić', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2ffd48ac92..2850e5b610 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -377,6 +377,7 @@ export const dict: Record = { "sessions.sidebar.header.actions.searchSessions": "Pesquisar sessões", "sessions.sidebar.header.actions.exitSelection": "Sair da seleção", "sessions.sidebar.header.actions.selectSessions": "Selecionar sessões", + "sessions.sidebar.header.actions.sortProjects": "Ordenar projetos", "sessions.sidebar.header.actions.sessionDisplayMode": "Modo de visualização da sessão", "sessions.sidebar.header.displayMode.label": "Modo de visualização", "sessions.sidebar.header.displayMode.default": "Padrão", @@ -385,6 +386,11 @@ export const dict: Record = { "sessions.sidebar.header.displayMode.showArchived": "Mostrar sessões arquivadas", "sessions.sidebar.header.displayMode.collapseAll": "Recolher tudo", "sessions.sidebar.header.displayMode.expandAll": "Expandir tudo", + "sessions.sidebar.header.projectSort.manual": "Manual", + "sessions.sidebar.header.projectSort.aToZ": "A → Z", + "sessions.sidebar.header.projectSort.zToA": "Z → A", + "sessions.sidebar.header.projectSort.dateAdded": "Mais recentes", + "sessions.sidebar.header.projectSort.recent": "Recentes", "sessions.sidebar.header.search.matchCountSingle": "{count} correspondência", "sessions.sidebar.header.search.matchCountPlural": "{count} correspondências", "sessions.sidebar.header.search.escapeHint": "Esc para limpar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 40792dc864..87070832b9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -377,6 +377,7 @@ export const dict: Record = { "sessions.sidebar.header.actions.searchSessions": "Пошук сесій", "sessions.sidebar.header.actions.exitSelection": "Вийти з вибору", "sessions.sidebar.header.actions.selectSessions": "Вибрати сесії", + "sessions.sidebar.header.actions.sortProjects": "Сортувати проєкти", "sessions.sidebar.header.actions.sessionDisplayMode": "Режим відображення сесії", "sessions.sidebar.header.displayMode.label": "Режим відображення", "sessions.sidebar.header.displayMode.default": "За замовчуванням", @@ -385,6 +386,11 @@ export const dict: Record = { "sessions.sidebar.header.displayMode.showArchived": "Показувати архівовані сесії", "sessions.sidebar.header.displayMode.collapseAll": "Згорнути все", "sessions.sidebar.header.displayMode.expandAll": "Розгорнути все", + "sessions.sidebar.header.projectSort.manual": "Вручну", + "sessions.sidebar.header.projectSort.aToZ": "A → Z", + "sessions.sidebar.header.projectSort.zToA": "Z → A", + "sessions.sidebar.header.projectSort.dateAdded": "Найновіші", + "sessions.sidebar.header.projectSort.recent": "Нещодавні", "sessions.sidebar.header.search.matchCountSingle": "{count} збіг", "sessions.sidebar.header.search.matchCountPlural": "Збігів: {count}", "sessions.sidebar.header.search.escapeHint": "Esc, щоб очистити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 7c68825cef..c15a4f9a5e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -377,6 +377,7 @@ export const dict: Record = { 'sessions.sidebar.header.actions.searchSessions': '搜索会话', 'sessions.sidebar.header.actions.exitSelection': '退出选择', 'sessions.sidebar.header.actions.selectSessions': '选择会话', + 'sessions.sidebar.header.actions.sortProjects': '排序项目', 'sessions.sidebar.header.actions.sessionDisplayMode': '会话显示模式', 'sessions.sidebar.header.displayMode.label': '显示模式', 'sessions.sidebar.header.displayMode.default': '默认', @@ -385,6 +386,11 @@ export const dict: Record = { 'sessions.sidebar.header.displayMode.showArchived': '显示已归档会话', 'sessions.sidebar.header.displayMode.collapseAll': '全部折叠', 'sessions.sidebar.header.displayMode.expandAll': '全部展开', + 'sessions.sidebar.header.projectSort.manual': '手动', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': '最新', + 'sessions.sidebar.header.projectSort.recent': '最近', 'sessions.sidebar.header.search.matchCountSingle': '{count} 个匹配', 'sessions.sidebar.header.search.matchCountPlural': '{count} 个匹配', 'sessions.sidebar.header.search.escapeHint': '按 Esc 清除', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 605b3eae4d..72a429b34b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -390,6 +390,7 @@ export const dict: Record = { 'sessions.sidebar.header.actions.searchSessions': '搜尋會話', 'sessions.sidebar.header.actions.exitSelection': '退出選取', 'sessions.sidebar.header.actions.selectSessions': '選擇會話', + 'sessions.sidebar.header.actions.sortProjects': '排序專案', 'sessions.sidebar.header.actions.sessionDisplayMode': '會話顯示模式', 'sessions.sidebar.header.displayMode.label': '顯示模式', 'sessions.sidebar.header.displayMode.default': '預設', @@ -398,6 +399,11 @@ export const dict: Record = { 'sessions.sidebar.header.displayMode.showArchived': '顯示已封存會話', 'sessions.sidebar.header.displayMode.collapseAll': '全部摺疊', 'sessions.sidebar.header.displayMode.expandAll': '全部展開', + 'sessions.sidebar.header.projectSort.manual': '手動', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': '最新', + 'sessions.sidebar.header.projectSort.recent': '最近', 'sessions.sidebar.header.search.matchCountSingle': '{count} 個符合', 'sessions.sidebar.header.search.matchCountPlural': '{count} 個符合', 'sessions.sidebar.header.search.escapeHint': '按 Esc 清除', diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index bb3f258fcb..3691c0f70a 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -46,6 +46,7 @@ interface VSCodeWorkspaceFolderConfig { interface ProjectsStore { projects: ProjectEntry[]; activeProjectId: string | null; + manualProjectOrder: string[]; addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null; removeProject: (id: string) => void; @@ -57,6 +58,7 @@ interface ProjectsStore { removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>; discoverProjectIcon: (id: string, options?: { force?: boolean }) => Promise<{ ok: boolean; skipped?: boolean; reason?: string; error?: string }>; reorderProjects: (fromIndex: number, toIndex: number) => void; + setManualProjectOrder: (order: string[]) => void; resetForRuntimeSwitch: () => void; validateProjectPath: (path: string) => ProjectPathValidationResult; synchronizeFromSettings: (settings: DesktopSettings) => void; @@ -290,6 +292,17 @@ const readPersistedProjects = (): ProjectEntry[] => { } }; +const readPersistedManualOrder = (): string[] => { + try { + const raw = safeStorage.getItem(getProjectsStorageKey() + ':manualOrder'); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; + } catch { + return []; + } +}; + const readPersistedActiveProjectId = (): string | null => { try { const raw = safeStorage.getItem(getActiveProjectStorageKey()) @@ -322,8 +335,15 @@ const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) } }; -const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null) => { +const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null, manualOrder?: string[]) => { cacheProjects(projects, activeProjectId); + if (manualOrder) { + try { + safeStorage.setItem(getProjectsStorageKey() + ':manualOrder', JSON.stringify(manualOrder)); + } catch { + // ignored + } + } void updateDesktopSettings({ projects, activeProjectId: activeProjectId ?? undefined }); }; @@ -510,6 +530,7 @@ export const useProjectsStore = create()( devtools((set, get) => ({ projects: effectiveInitialProjects, activeProjectId: initialActiveProjectId, + manualProjectOrder: readPersistedManualOrder(), validateProjectPath: (path: string): ProjectPathValidationResult => { if (typeof path !== 'string' || path.trim().length === 0) { @@ -578,8 +599,9 @@ export const useProjectsStore = create()( nextActiveId = nextProjects[0]?.id ?? null; } - set({ projects: nextProjects, activeProjectId: nextActiveId }); - persistProjects(nextProjects, nextActiveId); + const nextManualOrder = get().manualProjectOrder.filter((oid) => oid !== id); + set({ projects: nextProjects, activeProjectId: nextActiveId, manualProjectOrder: nextManualOrder }); + persistProjects(nextProjects, nextActiveId, nextManualOrder); // Clean up worktree entries for the removed project if (project) { @@ -621,7 +643,7 @@ export const useProjectsStore = create()( ); set({ projects: nextProjects, activeProjectId: id }); - persistProjects(nextProjects, id); + persistProjects(nextProjects, id, get().manualProjectOrder); opencodeClient.setDirectory(target.path); useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false }); @@ -646,7 +668,7 @@ export const useProjectsStore = create()( ); set({ projects: nextProjects, activeProjectId: id }); - persistProjects(nextProjects, id); + persistProjects(nextProjects, id, get().manualProjectOrder); }, renameProject: (id: string, label: string) => { @@ -663,7 +685,7 @@ export const useProjectsStore = create()( project.id === id ? { ...project, label: trimmed } : project ); set({ projects: nextProjects }); - persistProjects(nextProjects, activeProjectId); + persistProjects(nextProjects, activeProjectId, get().manualProjectOrder); }, updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => { @@ -686,7 +708,7 @@ export const useProjectsStore = create()( return updated; }); set({ projects: nextProjects }); - persistProjects(nextProjects, activeProjectId); + persistProjects(nextProjects, activeProjectId, get().manualProjectOrder); }, uploadProjectIcon: async (id: string, file: File) => { @@ -823,8 +845,14 @@ export const useProjectsStore = create()( const [moved] = nextProjects.splice(fromIndex, 1); nextProjects.splice(toIndex, 0, moved); - set({ projects: nextProjects }); - persistProjects(nextProjects, activeProjectId); + const newOrder = nextProjects.map((p) => p.id); + set({ projects: nextProjects, manualProjectOrder: newOrder }); + persistProjects(nextProjects, activeProjectId, newOrder); + }, + + setManualProjectOrder: (order: string[]) => { + set({ manualProjectOrder: order }); + persistProjects(get().projects, get().activeProjectId, order); }, resetForRuntimeSwitch: () => { @@ -836,7 +864,7 @@ export const useProjectsStore = create()( const nextActiveProjectId = projects.some((project) => project.id === activeProjectId) ? activeProjectId : projects[0]?.id ?? null; - set({ projects, activeProjectId: nextActiveProjectId }); + set({ projects, activeProjectId: nextActiveProjectId, manualProjectOrder: [] }); }, synchronizeFromSettings: (settings: DesktopSettings) => { @@ -862,7 +890,7 @@ export const useProjectsStore = create()( : true; if (activeExists) { set({ activeProjectId: incomingActive }); - cacheProjects(current.projects, incomingActive); + persistProjects(current.projects, incomingActive, get().manualProjectOrder); } } return; @@ -875,8 +903,10 @@ export const useProjectsStore = create()( return; } - set({ projects: incomingProjects, activeProjectId: incomingActive }); - cacheProjects(incomingProjects, incomingActive); + const incomingIds = new Set(incomingProjects.map((p) => p.id)); + const cleanedOrder = get().manualProjectOrder.filter((id) => incomingIds.has(id)); + set({ projects: incomingProjects, activeProjectId: incomingActive, manualProjectOrder: cleanedOrder }); + persistProjects(incomingProjects, incomingActive, cleanedOrder); if (incomingActive) { const activeProject = incomingProjects.find((project) => project.id === incomingActive); diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts index 0b87c83faa..3e579a4d93 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -3,15 +3,19 @@ import { persist } from 'zustand/middleware'; type SessionDisplayMode = 'default' | 'minimal'; +type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent'; + type SessionDisplayStore = { displayMode: SessionDisplayMode; showRecentSection: boolean; showArchivedSessions: boolean; + projectSortOrder: ProjectSortOrder; setDisplayMode: (mode: SessionDisplayMode) => void; setShowRecentSection: (show: boolean) => void; setShowArchivedSessions: (show: boolean) => void; toggleRecentSection: () => void; toggleArchivedSessions: () => void; + setProjectSortOrder: (order: ProjectSortOrder) => void; }; export const useSessionDisplayStore = create()( @@ -24,25 +28,33 @@ export const useSessionDisplayStore = create()( // disappear once the persisted preference rehydrates. Users who opted into // showing archived have `true` persisted, which is preserved on rehydrate. showArchivedSessions: false, + projectSortOrder: 'recent', setDisplayMode: (mode) => set({ displayMode: mode }), setShowRecentSection: (show) => set({ showRecentSection: show }), setShowArchivedSessions: (show) => set({ showArchivedSessions: show }), toggleRecentSection: () => set((state) => ({ showRecentSection: !state.showRecentSection })), toggleArchivedSessions: () => set((state) => ({ showArchivedSessions: !state.showArchivedSessions })), + setProjectSortOrder: (order) => set({ projectSortOrder: order }), }), { name: 'session-display-mode', - version: 1, + version: 2, // v0 shipped 'default' as the only/initial mode, so most existing users // have it persisted by accident rather than choice. Nudge everyone onto // minimal once so the mode can be evaluated before removing it entirely. + // v1→v2 adds projectSortOrder defaulting to 'recent'. migrate: (persisted, version) => { const state = (persisted ?? {}) as Partial; if (version < 1) { - return { ...state, displayMode: 'minimal' }; + return { ...state, displayMode: 'minimal', projectSortOrder: 'recent' }; + } + if (version < 2) { + return { ...state, projectSortOrder: 'recent' }; } return state; }, }, ), ); + +export type { ProjectSortOrder };