* refactor(settings): settings registry and intent-gated writes
Problem: every setting lived in a flat document with ten hand-maintained
key lists that had drifted (three keys the server silently dropped, five
it kept that nothing read), and three code paths wrote to the server
without a person changing anything: the theme persist effect on mount,
bootstrap seeding of server-missing keys, and the auto-save echoing
values just adopted from the server.
Approach: one registry (packages/ui/src/lib/settings/registry.ts) names
every key with its scope (instance / profile / device), a boundary parser
and its store binding; DesktopSettings, the sanitizer, the mirror, the
apply step and the auto-save derive from it. A generated JSON snapshot
carries the key list to the server and the VS Code bridge. Writes carry
intent: the theme context writes only from its user-facing setters, a
missing server key leaves the local store alone instead of resetting it,
updateDesktopSettings drops values the server already holds, and the
auto-savers treat values applied from the server as a new baseline.
Testing: bun test packages/ui (registry + persistence suites cover zero
writes on load, dedup, toggle-back cancellation, failed-save retry, and
snapshot freshness); tsc for every workspace.
* refactor(ui): read and write settings through the shared path only
Problem: fourteen pages and stores fetched /api/config/settings on their
own and re-parsed the raw document by hand, so the registry could not
guard them and two of them treated a failed load as an empty list.
Approach: loadDesktopSettings() and updateDesktopSettings() (which now
resolves { ok }) replace every direct call; SkillsCatalogPage and
AddCatalogDialog refuse to write the catalog list until it is known.
Testing: bun test packages/ui (403 files), eslint on the changed files.
* refactor(server): validate settings writes against the registry snapshot
Problem: the server whitelist was the only guard on PUT /api/config/settings
and had drifted from the client; dead keys were still persisted.
Approach: settings-helpers.js drops any key the generated registry
snapshot does not list as persistable and strips secret keys from
responses; the dead keys (markdownDisplayMode, toolCallExpansion,
typographySizes, expandedEditorToolbar, gitProviderId/gitModelId) are
gone; the profile keys that were client-only now round-trip. A drift
test requires a valid sample for every persistable registry key.
Testing: vitest run in packages/web (182 files), including the packed
tarball import.
* refactor(vscode): gate bridge settings writes by the registry
Problem: the extension host wrote any key the webview sent straight into
settings.json, and commit-message generation read the dead
gitProviderId/gitModelId pair instead of the small-model setting.
Approach: filterPersistableSettingsChanges applies the registry snapshot
before the file write; chooseBridgeGitGenerationModel honours
smallModelUseDefault/smallModelOverride ahead of the zen fallback.
Testing: bun test packages/vscode (37 files), tsc, build:extension.
* feat(settings): split the user's profile into preferences.json
Problem: one flat settings.json held instance facts, the user's
preferences and device state together, so device state travelled between
installs and the profile had no document of its own to sync from.
Approach: the server keeps one merged document for clients but routes
each key by registry scope on disk (settings-files.js): profile keys go to
preferences.json as { value, updatedAt } entries stamped when the value
changes, everything else stays in settings.json, device keys are dropped
from writes. A missing preferences.json is seeded once from settings.json,
which is left intact; an unreadable one is a failure that pauses profile
writes and never gets overwritten. Server modules that read a profile key
off the disk use the merged sync read. Electron main reads the theme mode
from both files and now owns the splash colours, handed over the
window-theme IPC instead of the settings document. Clients stop sending
device keys, seed them once from a pre-split document, and persist
inputBarOffset locally. The PWA manifest keys are instance facts.
Testing: vitest in packages/web (seed, split write, timestamp retention,
unreadable file), bun test in packages/ui and packages/electron, tsc for
every workspace.
* feat(vscode): write the profile to preferences.json from the extension host
Problem: the extension host writes the shared settings files directly and
had to follow the server's split, and its file writes reported success on
failure.
Approach: settings-files.ts mirrors the server's format and split rules
(seed once, unreadable preferences.json is a failure); persistSettings
routes profile keys to preferences.json and the rest to settings.json,
and the atomic writers now throw so a failed save reaches the webview.
Clearing a key now actually removes it from the owning file.
Testing: bun test packages/vscode (38 files), tsc, build:extension.
* feat(settings): store the per-surface profile fields by surface kind
Problem: theme, chat-layout switches and typography sizes are one value
for every client of an instance, so the phone and the desktop cannot
disagree without a hard-coded runtime branch.
Approach: every settings request carries the client's surface kind in the
x-openchamber-surface header (web, desktop, vscode, mobile — the phone app
and the hosted mobile shell are one kind). For the registry's perSurface
keys the store writes a changed value under fields[key].surfaces[kind] in
preferences.json and never touches the base from a surface; reads resolve
the kind's own value, then the base, then nothing. Writes without a
surface (migrations, the seed) set the base. The VS Code host is always
vscode; Electron main resolves desktop for the native window theme. The
Settings UI is unchanged.
Testing: vitest in packages/web (surface write/read, no base copy, unknown
surface falls back to base), bun test in packages/vscode and packages/ui,
tsc for every workspace, build:extension.
* fix(settings): keep a legacy copy of the profile in settings.json
The first write after the split rewrote settings.json with the instance
part only, and that write happens on startup (relay reconcile). A build
from before the split reads only settings.json, so rolling back would
have lost every preference: theme, default model, all of it.
Every write now stores the profile's base values in settings.json next
to the instance part (`legacySettingsDocumentOf`), on the server and in
the VS Code extension host alike. Current builds ignore the copy because
preferences.json wins in the merged read. When preferences.json is
unreadable the copy already on disk is kept rather than dropped.
Testing: settings-runtime tests updated for the copy; full web suite
(182 files), VS Code tests and extension build, tsc clean. Verified live
on a scratch OPENCHAMBER_DATA_DIR: all 136 keys survive startup, theme
changes land per surface, plain keys land in the base.
* feat(settings): make the UI password and tunnel preset tokens write-only
GET /api/config/settings returned desktopUiPassword and the managed
remote tunnel preset tokens to every authenticated client, including
paired phones and the VS Code webview that never need them.
Both keys are now `secret` in the registry: accepted on write, withheld
from reads. The server answers with a hasDesktopUiPassword flag; the
desktop network page shows "Password set" and sends a value only when
the user types a new one or presses "Remove password" (an empty string
clears it and turns LAN access off). The tunnel page already learned
token presence from the status endpoint. The VS Code bridge strips
secret keys from what it hands the webview while still merging them
from disk on write.
Testing: registry, i18n parity, server settings, VS Code gate tests and
tsc; workspace type-check. Verified against a scratch server: GET
carries the flag and no password, PUT with '' clears, PUT with a value
sets. The desktop-only page itself awaits the owner's run.
* fix(settings): send the surface kind as a query parameter, not a header
The packaged desktop shell (openchamber-ui://app) and the phone app are
cross-origin to the OpenChamber server, so the x-openchamber-surface
header turned every settings request into a CORS preflight the server
did not allow. Settings looked reset and every save reported "Save
failed" without reaching persistSettings. An older remote instance would
refuse the header the same way even with the allow-list fixed.
The client now sends ?surface=<kind>, which keeps the request
CORS-simple on every server version; the server reads the query
parameter and still honours the header. The header is also in the CORS
allow-list for completeness.
Testing: workspace type-check, persistence and registry tests, server
opencode tests. On a scratch server: PUT with ?surface=vscode lands
under surfaces.vscode, GET without or with an unknown surface serves the
base, the header fallback resolves. Confirmed in the owner's rebuilt
desktop and on the phone.
* refactor(settings): drop the show-password toggle from the desktop network page
With the password write-only, the field only ever holds a value the user
is typing right now; the reveal toggle and its strings are gone from
every locale.
* refactor(projects): serve project setup through the server, drop the legacy migration
The shared UI read and wrote ~/.config/openchamber/projects/<id>.json
itself: it resolved the home directory, composed the path, and used the
Files API, which only desktop and VS Code have natively and which cannot
see a remote instance's file at all. It also still carried the months-old
migration from <repo>/.openchamber/openchamber.json, which deleted files in
the folder the upcoming shared project config will use.
The client-owned keys (worktree setup commands, project actions, draft
starters) now live behind GET/PUT /api/projects/:projectId/config.
project-setup.js sanitizes and builds the view; the project-config runtime
merges a patch under the same cross-process lock the scheduled-task writers
hold, so unknown and server-owned keys survive. A wrongly shaped key is a
400, not a silent drop. openchamberConfig.ts keeps its exported functions
and is now an HTTP client. The VS Code webview handles the route locally
and bridges to the extension host, which owns the file with a TS mirror of
the sanitizers.
Testing: server tests for sanitizers, round trip, lock, and invalid patch;
client tests against a mocked route; VS Code sanitizer and bridge tests;
workspace type-check, both VS Code builds, UI isolated suite (409 files),
server projects and project-context suites. Live GET/PUT against a
running server with the owner's real project config.
* feat(projects): read the team's shared config and merge it with the personal one
A project can now carry <repo>/.openchamber/project.json (version 1:
setupWorktree, setupWorktreeWait, projectActions, draftStarters,
plansDir). The server finds the checkout from the path-derived project
id, parses the file, and answers GET /api/projects/:id/config with one
merged view: what runs at the top level, plus shared and personal blocks
so a page can edit the personal file without copying a teammate's entry
into it.
Merge rules: shared setup commands run first (a personal
setupWorktreeMode of "replace" uses the personal list only); the
personal wait flag wins when set; actions union by id with a personal
action replacing the shared one and personal hiddenSharedActionIds
dropping shared ones; starters union by type:name; the primary action is
personal only. A shared file that exists but cannot be parsed, or that
names a plansDir outside the repo, is reported as invalid with a reason
and never treated as "no shared setup". Nothing writes the repo file yet.
Client: getProjectSetup exposes the view; the existing helpers return
effective values, while the Projects page sections and the draft
starters hook edit the personal block only. Shared entries show a quiet
"shared" mark in the actions dropdown and read-only lists above the
editable ones on the Projects page; shared starter chips have no remove
handle. The VS Code extension host mirrors the parser and merge.
Testing: server tests for the parser, plansDir guard, merge table, id
round trip, and a runtime test against a temp checkout; client tests
against a mocked route; VS Code sanitizer, merge, and bridge tests; the
section test covers the shared row; locale parity; workspace type-check;
UI isolated suite (409 files). Live: GET against a temp repo with a
shared file and with a broken one.
* feat(projects): ask before the team's shared commands run, once per set of commands
Shared setup commands and shared actions come from a file a git pull can
change, and they run on the machine of whoever pulls. The first time one
would run, a dialog now shows exactly what would run and asks: "Trust and
run" or "Not this time". A "trust" answer is recorded in the personal
config against a SHA-256 of the executable parts (setup commands and each
action's id, command, and runIn; renames and icons do not count), so a
pull that changes a command brings the prompt back. Nothing asks when the
shared file has nothing that executes.
Worktree creation (session creator, new-worktree dialog, session store,
multi-run launcher, agent-manager empty state) resolves its commands
through the prompt; "not this time" runs only the user's own commands.
The actions dropdown asks before a shared action runs. The Projects page
shows "Trusted on this instance" with a "Reset trust" button next to the
shared actions. The dialog is mounted beside the app-link confirmation on
every shell. The VS Code extension host mirrors the hash and the record.
Testing: server tests for hash stability, ordering, and the trusted flag,
plus a runtime test that changes the shared file and sees trust drop;
client tests for the confirmation store (ask, trust, skip, replace mode,
newer request, failed record, reset); VS Code mirror tests; the actions
button, new-worktree dialog, and issue-2039 tests updated for the trust
path; locale parity; workspace type-check; UI isolated suite (410 files).
* feat(projects): share and unshare setup with the team from the Projects page
The repo file <repo>/.openchamber/project.json is now written by the app,
and only when the user shares something: nothing appears in a repository
until then. PUT /api/projects/:id/config/shared replaces the keys it
names over the current file, writes it pretty-printed with version first
and only the keys that carry something, removes the file (and an empty
.openchamber folder) when nothing is left, refuses a missing checkout or
a plansDir outside the repo, and records trust for the writer, who has
seen what they shared.
On the Projects page, actions and setup commands get "Share with team"
and "Make personal"; shared actions can be hidden for this user; a
checkbox switches to "Use only my setup commands". Project starter chips
get share and make-personal hover buttons. A new "Shared config" block
shows the file's path and status, the shared plans folder, and the trust
status with "Reset trust". A share is a repo write followed by a personal
write; a failure after the first leaves the item visible once, as
personal. The VS Code extension host mirrors the writer.
Testing: server tests for the patch, serialization, emptiness, the write
and removal round trip, the writer's trust record, and the refusals;
client test for the shared route; VS Code bridge test for write and
removal; locale parity; workspace type-check; UI isolated suite (410
files). Live on a scratch server: share, invalid plansDir (400), unshare
to removal of file and folder.
* feat(projects): list, edit, and move plans in the team's shared plans folder
When the shared config names a plansDir, every markdown file in that
folder is a plan on the Plans tab: listed after the user's own plans,
marked shared, addressed as shared:<file>, read and edited in place
(the raw document is written verbatim, so a plan another tool wrote
keeps its shape), and deletable. Share moves one of the user's plans
into the folder; make personal moves it back under a new id; a name
collision gets a numeric suffix. Sharing is refused, with a hint in the
panel, until a shared plans folder is set in Project settings. This
answers the request to read plans from an existing folder such as
docs/plans.
Server: the project-context runtime takes resolveSharedPlansDir from the
project-config runtime; readContext reports sharedPlansDir; POST
.../plans/:id/share and /unshare. Client: movePlan in the context store,
a shared badge and a share / make-personal button per plan row. Session
attachments reference plan ids, so an attached plan that moves has to be
attached again.
Testing: runtime tests for listing, foreign markdown titles, id
traversal, in-place update and delete, share and unshare with a
collision, and the refusal without a folder; HTTP route tests; store and
locale parity tests; workspace type-check; full web suite (183 files);
UI isolated suite (410 files). Live on a scratch server against a temp
repo: list, share, read, unshare.
* fix(server): make OPENCHAMBER_DATA_DIR move every folder, not just the flat files
The variable is documented as the OpenChamber data directory, but only
settings, preferences, auth, and push files followed it; projects,
themes, speech models, and the chats default stayed under
~/.config/openchamber. A second instance started with a custom
directory therefore read and wrote the default instance's project
configs.
Every folder now hangs off the one root. An instance that already used
a custom directory gets projects, themes, and speech-models copied in
once at startup; copied, not moved, so a second instance beside the
default one cannot strip it, and nothing is merged into a folder that
already exists. Existing managed chats are not copied, as with
OPENCHAMBER_CHATS_DIR.
Testing: migration tests for copy-once, no-merge, and same-root no-op;
full web suite; a scratch server with an empty data dir copied the real
project configs and kept its writes in the copy.
* fix(projects): keep a plan's id when it moves into or out of the repository folder
A plan moved into the repository plans folder used to be listed under a
new shared:<file> id, so a session that had attached it lost the
attachment. The manifest entry now stays with a `shared` flag that says
which folder holds the file; the id survives both directions. Only a
plan that never had an entry (one written by another tool) gets an id
when it is brought in. A personal file and a repository file may share
a name because they live in different folders.
Testing: runtime tests for share and unshare with a stable id, reading
and editing the moved plan, the suffix on a name collision, and the
adoption of a foreign file.
* feat(projects): default repository plans folder, "move to repository" wording, tooltips
Plans now have a repository folder without any setup: .openchamber/plans
by default. A custom plansDir replaces the default outright (only that
folder is read and written; moving files between the two is the user's
job), and the field's placeholder and hint say so. The move buttons on
plans are therefore always available.
The word "share" is gone from the UI: it read like publishing, while
the action stores an item in the repository so everyone who pulls it
gets it. Labels are "Move to repository" / "Move to my settings", the
badge is "In repo", the block is "Repository config", and every button
on the Projects page carries a tooltip that says what happens (the
"Move to repository" button explains that edits save first while the
form is dirty). The trust status with "reset trust" moved from the
repository block into the Worktree section next to the commands it
guards; the plan row's badge sits beside the title.
Testing: locale parity, section test, workspace type-check, UI isolated
suite (410 files), full web suite.
* fix(projects): leave the icon key out of the repository file when an action has none
Actions without an icon were written as "icon": null into
.openchamber/project.json. The key is now omitted; readers already fall
back to the play icon. Server and VS Code serializers, tests updated.
* docs: describe the repository config file and how items move into it
A new page in every locale: what stays personal and what can move into
the repository, the .openchamber/project.json format with an example
and every key explained (setup commands, actions with the supported icon
names, starters, plansDir), the merge rules, the trust prompt, and plans
in the repository. Linked from the sidebar and from Project Actions.
Translations written by hand.
2088 lines
87 KiB
TypeScript
2088 lines
87 KiB
TypeScript
import { createVSCodeAPIs } from './api';
|
|
import { createRemovalTombstones } from './inlineCommentRemovals';
|
|
import { resolveCommentTarget } from './inlineCommentTarget';
|
|
import { onCommand, onThemeChange, postBridgeNotification, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
|
import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf';
|
|
import { extractBodyBase64, extractBodyText, extractJsonBody, hasInitBody } from './requestBodyTransport';
|
|
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
|
import { opencodeClient } from '@openchamber/ui/lib/opencode/client';
|
|
import { sanitizeHeadersForBrowser } from '@openchamber/ui/lib/runtime-fetch';
|
|
import {
|
|
buildVSCodeThemeFromPalette,
|
|
readVSCodeThemePalette,
|
|
type VSCodeThemeKind,
|
|
type VSCodeThemePayload,
|
|
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
|
import { getBootstrapMessages, readStoredLocaleForBootstrap } from '@openchamber/ui/lib/i18n';
|
|
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
|
|
import { usePermissionStore } from '@openchamber/ui/stores/permissionStore';
|
|
import { processVSCodePermissionAutoAccept } from '@openchamber/ui/sync/vscode-permission-auto-accept';
|
|
import type { PermissionRequest } from '@opencode-ai/sdk/v2/client';
|
|
import { focusChatInput } from '@openchamber/ui/components/chat/composer/editor/dom';
|
|
|
|
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
|
type PanelType = 'chat' | 'agentManager';
|
|
|
|
declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string;
|
|
|
|
declare global {
|
|
interface Window {
|
|
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
|
__VSCODE_CONFIG__?: {
|
|
apiUrl?: string;
|
|
workspaceFolder: string;
|
|
workspaceFolders?: Array<{ name: string; path: string }>;
|
|
theme: string;
|
|
connectionStatus: string;
|
|
cliAvailable?: boolean;
|
|
extensionVersion?: string;
|
|
platform?: string;
|
|
arch?: string;
|
|
panelType?: PanelType;
|
|
viewMode?: 'sidebar' | 'editor';
|
|
initialSessionId?: string | null;
|
|
};
|
|
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
|
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
|
|
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
|
|
__OPENCHAMBER_HOME__?: string;
|
|
__OPENCHAMBER_PANEL_TYPE__?: PanelType;
|
|
__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__?: boolean;
|
|
}
|
|
}
|
|
|
|
console.log('[OpenChamber] VS Code webview starting...');
|
|
console.log('[OpenChamber] VS Code webview build:', __OPENCHAMBER_WEBVIEW_BUILD_TIME__);
|
|
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
|
|
try {
|
|
if (window.localStorage.getItem('openchamber_stream_debug') === '1') {
|
|
console.log('[OpenChamber] Debug: openchamber_stream_debug=1');
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
|
|
|
|
const bootstrapLocale = readStoredLocaleForBootstrap();
|
|
const bootstrapMessages = getBootstrapMessages(bootstrapLocale);
|
|
|
|
const bootstrapConnectionStatus = () => {
|
|
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
|
|
const cliAvailable = window.__VSCODE_CONFIG__?.cliAvailable ?? true;
|
|
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus, cliAvailable };
|
|
};
|
|
|
|
bootstrapConnectionStatus();
|
|
|
|
// Expose panel type globally for the VS Code app root to conditionally render.
|
|
window.__OPENCHAMBER_PANEL_TYPE__ = (window.__VSCODE_CONFIG__?.panelType as PanelType) || 'chat';
|
|
|
|
const handleConnectionMessage = (event: MessageEvent) => {
|
|
const msg = event.data;
|
|
if (msg?.type === 'connectionStatus') {
|
|
const payload: ConnectionStatus = msg.status;
|
|
const error: string | undefined = msg.error;
|
|
const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
|
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable };
|
|
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
|
|
}
|
|
};
|
|
|
|
window.addEventListener('message', handleConnectionMessage);
|
|
window.addEventListener('openchamber:connection-status', () => {
|
|
maybeHideLoadingOverlay();
|
|
});
|
|
|
|
const fadeOutLoadingScreen = () => {
|
|
const loadingEl = document.getElementById('initial-loading');
|
|
if (!loadingEl) return;
|
|
loadingEl.classList.add('fade-out');
|
|
setTimeout(() => {
|
|
try {
|
|
loadingEl.remove();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const setLoadingStatusText = (text: string, variant: 'normal' | 'error' = 'normal') => {
|
|
const statusEl = document.getElementById('loading-status');
|
|
if (!statusEl) return;
|
|
statusEl.textContent = text;
|
|
if (variant === 'error') {
|
|
statusEl.classList.add('error-text');
|
|
} else {
|
|
statusEl.classList.remove('error-text');
|
|
}
|
|
};
|
|
|
|
const waitForUiMount = (timeoutMs = 8000): Promise<boolean> => {
|
|
if (typeof document === 'undefined') return Promise.resolve(false);
|
|
const root = document.getElementById('root');
|
|
if (!root) return Promise.resolve(false);
|
|
|
|
const hasContent = () => root.childNodes.length > 0;
|
|
if (hasContent()) return Promise.resolve(true);
|
|
|
|
return new Promise((resolve) => {
|
|
const observer = new MutationObserver(() => {
|
|
if (hasContent()) {
|
|
observer.disconnect();
|
|
clearTimeout(timeout);
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
observer.observe(root, { childList: true, subtree: true });
|
|
|
|
const timeout = setTimeout(() => {
|
|
observer.disconnect();
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
});
|
|
};
|
|
|
|
let uiMounted = false;
|
|
|
|
const maybeHideLoadingOverlay = () => {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting';
|
|
|
|
if (!uiMounted) {
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'connected') {
|
|
// The UI hydrates pickers and the sidebar from cache and refreshes
|
|
// providers/agents in the background, so once it's mounted and OpenCode is
|
|
// connected there's real interactive content underneath the splash. Don't
|
|
// keep the overlay up waiting on the live provider/agent fetches — on a cold
|
|
// start those are the slowest tail, and gating on them makes the splash
|
|
// linger long after the app is usable. Per-widget loaders convey any
|
|
// remaining background work.
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'error') {
|
|
const error = window.__OPENCHAMBER_CONNECTION__?.error;
|
|
setLoadingStatusText(error || bootstrapMessages.connectionError, 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'disconnected') {
|
|
setLoadingStatusText(bootstrapMessages.disconnected, 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
// Connecting — no jargon; the animated logo conveys progress.
|
|
setLoadingStatusText('');
|
|
};
|
|
|
|
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
|
|
if (typeof document === 'undefined' || !theme) return;
|
|
const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light';
|
|
const root = document.documentElement;
|
|
root.classList.remove('light', 'dark');
|
|
root.classList.add(variant);
|
|
|
|
const background = theme.colors?.surface?.background;
|
|
if (background) {
|
|
document.body.style.backgroundColor = background;
|
|
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
|
|
if (!meta) {
|
|
meta = document.createElement('meta');
|
|
meta.setAttribute('name', 'theme-color');
|
|
document.head.appendChild(meta);
|
|
}
|
|
meta.setAttribute('content', background);
|
|
}
|
|
};
|
|
|
|
const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
|
|
const palette = readVSCodeThemePalette(preferredKind);
|
|
if (!palette) {
|
|
return;
|
|
}
|
|
const theme = buildVSCodeThemeFromPalette(palette);
|
|
window.__OPENCHAMBER_VSCODE_THEME__ = theme;
|
|
applyInitialTheme(theme);
|
|
window.dispatchEvent(new CustomEvent<VSCodeThemePayload>('openchamber:vscode-theme', {
|
|
detail: { theme, palette },
|
|
}));
|
|
};
|
|
|
|
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
|
|
|
|
const scheduleThemeRecompute = (kind?: VSCodeThemeKind) => {
|
|
// VS Code updates webview CSS variables asynchronously around theme changes.
|
|
// Re-read on the next frames so we don't snapshot the old palette.
|
|
requestAnimationFrame(() => {
|
|
emitVSCodeTheme(kind);
|
|
requestAnimationFrame(() => emitVSCodeTheme(kind));
|
|
});
|
|
};
|
|
|
|
onThemeChange((payload) => {
|
|
const kind = (typeof payload === 'string'
|
|
? payload
|
|
: typeof payload === 'object' && payload
|
|
? payload.kind
|
|
: undefined) as VSCodeThemeKind | undefined;
|
|
|
|
if (typeof payload === 'object' && payload?.shikiThemes !== undefined) {
|
|
window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__ = payload.shikiThemes;
|
|
window.dispatchEvent(
|
|
new CustomEvent('openchamber:vscode-shiki-themes', {
|
|
detail: { shikiThemes: payload.shikiThemes },
|
|
}),
|
|
);
|
|
}
|
|
|
|
scheduleThemeRecompute(kind);
|
|
});
|
|
|
|
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
|
if (workspaceFolder) {
|
|
const normalizeWorkspacePath = (value: string) => {
|
|
const normalized = value
|
|
.replace(/\\/g, '/')
|
|
.replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`)
|
|
.replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`);
|
|
if (normalized === '/') {
|
|
return '/';
|
|
}
|
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
};
|
|
|
|
const normalizedWorkspaceFolder = normalizeWorkspacePath(workspaceFolder);
|
|
window.__OPENCHAMBER_HOME__ = normalizedWorkspaceFolder;
|
|
try {
|
|
window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder);
|
|
window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder);
|
|
|
|
// VS Code defaults: show dotfiles, hide gitignored
|
|
if (window.localStorage.getItem('directoryTreeShowHidden') === null) {
|
|
window.localStorage.setItem('directoryTreeShowHidden', 'true');
|
|
}
|
|
if (window.localStorage.getItem('filesViewShowGitignored') === null) {
|
|
window.localStorage.setItem('filesViewShowGitignored', 'false');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to persist workspace folder', error);
|
|
}
|
|
}
|
|
|
|
const normalizeUrl = (input: string | URL) => {
|
|
try {
|
|
return typeof input === 'string' ? new URL(input, window.location.href) : new URL(input.toString(), window.location.href);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
|
|
if (!headers) return {};
|
|
const normalized = new Headers(sanitizeHeadersForBrowser(headers) ?? headers);
|
|
const result: Record<string, string> = {};
|
|
normalized.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const getRequestHeaders = (input?: RequestInfo | URL, init?: RequestInit): Record<string, string> => {
|
|
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
|
|
const headersFromInit = headersToRecord(init?.headers);
|
|
return { ...headersFromRequest, ...headersFromInit };
|
|
};
|
|
|
|
const getRequestDirectoryHint = (url: URL, input?: RequestInfo | URL, init?: RequestInit): string | undefined => {
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
if (queryDirectory) return queryDirectory;
|
|
const headers = getRequestHeaders(input, init);
|
|
const directoryEncoding = Object.entries(headers).find(([key]) => key.toLowerCase() === 'x-opencode-directory-encoding')?.[1];
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === 'x-opencode-directory') {
|
|
// headersToRecord marks encoded directory hints so direct/raw percent
|
|
// sequences from other callers are not decoded accidentally.
|
|
if (directoryEncoding !== 'uri') return value;
|
|
try { return decodeURIComponent(value); } catch { return value; }
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const decodeBase64 = (value: string): ArrayBuffer => {
|
|
const binary = atob(value);
|
|
const buffer = new ArrayBuffer(binary.length);
|
|
const bytes = new Uint8Array(buffer);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return buffer;
|
|
};
|
|
|
|
const jsonResponse = (body: unknown, status = 200): Response => {
|
|
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
};
|
|
|
|
const unsupportedWebRouteResponse = (feature: string): Response => {
|
|
return jsonResponse({ error: `${feature} is not supported in VS Code` }, 501);
|
|
};
|
|
|
|
const pluginConfigErrorStatus = (message: string): number => {
|
|
const lower = message.toLowerCase();
|
|
if (lower.includes('already exists')) return 409;
|
|
if (lower.includes('not found')) return 404;
|
|
if (lower.includes('required') || lower.includes('invalid') || lower.includes('must ')) return 400;
|
|
return 500;
|
|
};
|
|
|
|
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
|
|
|
|
const buildProxiedResponse = (
|
|
proxied: { status: number; headers: Record<string, string>; bodyBase64?: string; bodyText?: string }
|
|
): Response => {
|
|
if (isNullBodyStatus(proxied.status)) {
|
|
return new Response(null, { status: proxied.status, headers: proxied.headers });
|
|
}
|
|
|
|
if (typeof proxied.bodyText === 'string') {
|
|
return new Response(proxied.bodyText, { status: proxied.status, headers: proxied.headers });
|
|
}
|
|
|
|
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new ArrayBuffer(0);
|
|
return new Response(body, { status: proxied.status, headers: proxied.headers });
|
|
};
|
|
|
|
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
|
|
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
|
|
const isApiPath = (pathname: string) => pathname === '/api' || pathname.startsWith('/api/');
|
|
const isLocalRuntimePath = (pathname: string) => isApiPath(pathname) || pathname === '/auth/session';
|
|
|
|
const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: RequestInit | undefined, method: string) => {
|
|
const pathname = url.pathname;
|
|
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
|
|
|
|
if (normalizedPathname === '/api/system/info' && method === 'GET') {
|
|
const config = window.__VSCODE_CONFIG__;
|
|
return jsonResponse({
|
|
openchamberVersion: config?.extensionVersion || 'VS Code Extension',
|
|
runtime: 'vscode',
|
|
platform: config?.platform || '',
|
|
arch: config?.arch || '',
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/preview/targets') {
|
|
return unsupportedWebRouteResponse('Preview proxy');
|
|
}
|
|
|
|
if (normalizedPathname.startsWith('/api/openchamber/tunnel/')) {
|
|
return unsupportedWebRouteResponse('Remote tunnel settings');
|
|
}
|
|
|
|
// Archiving a batch of sessions server-side needs an OpenChamber server
|
|
// process; the extension host has none. Answering explicitly keeps the
|
|
// shared UI on its per-session archive path instead of leaving the request
|
|
// to the generic proxy.
|
|
if (normalizedPathname === '/api/openchamber/sessions/archive') {
|
|
return unsupportedWebRouteResponse('Server-side session archiving');
|
|
}
|
|
|
|
if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) {
|
|
return unsupportedWebRouteResponse('Scheduled tasks');
|
|
}
|
|
|
|
// Project setup (worktree setup commands, project actions, draft starters)
|
|
// lives in the user's OpenChamber config dir; the extension host owns the
|
|
// file the way the OpenChamber server does elsewhere.
|
|
const projectSetupMatch = normalizedPathname.match(/^\/api\/projects\/([^/]+)\/config(\/shared)?$/);
|
|
if (projectSetupMatch && (method === 'GET' || method === 'PUT') && !(method === 'GET' && projectSetupMatch[2])) {
|
|
const projectId = decodeURIComponent(projectSetupMatch[1]);
|
|
const payload = method === 'GET'
|
|
? { projectId }
|
|
: { projectId, patch: await extractJsonBody(input, init, method) };
|
|
const bridgeType = method === 'GET'
|
|
? 'api:project-setup:get'
|
|
: projectSetupMatch[2] ? 'api:project-setup:update-shared' : 'api:project-setup:update';
|
|
try {
|
|
const data = await sendBridgeMessage(bridgeType, payload);
|
|
return jsonResponse(data, 200);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Project config request failed';
|
|
return jsonResponse({ error: message }, /must be|is required|unsupported characters/.test(message) ? 400 : 500);
|
|
}
|
|
}
|
|
|
|
if (normalizedPathname === '/api/fs/git-dirs') {
|
|
return unsupportedWebRouteResponse('Nested git repository discovery');
|
|
}
|
|
|
|
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
|
|
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
|
|
.catch(() => ({}));
|
|
return new Response(
|
|
JSON.stringify({
|
|
statusSessions: {},
|
|
attentionSessions: {},
|
|
activitySessions: activity || {},
|
|
serverTime: Date.now(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') {
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/permission-auto-accept' && method === 'GET') {
|
|
const snapshot = await sendBridgeMessage('api:permission-auto-accept:get');
|
|
return new Response(JSON.stringify(snapshot), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const permissionPolicyMatch = normalizedPathname.match(/^\/api\/permission-auto-accept\/sessions\/([^/]+)$/);
|
|
if (permissionPolicyMatch && method === 'PUT') {
|
|
const bodyText = await extractBodyText(url, init, method);
|
|
const body = bodyText ? JSON.parse(bodyText) as { enabled?: unknown } : {};
|
|
const snapshot = await sendBridgeMessage('api:permission-auto-accept:set', {
|
|
sessionId: decodeURIComponent(permissionPolicyMatch[1]),
|
|
enabled: body.enabled,
|
|
});
|
|
return new Response(JSON.stringify(snapshot), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') {
|
|
const sessionId = normalizedPathname.split('/')[3] || '';
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
sessionId,
|
|
messageSent: true,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (normalizedPathname === '/api/session-activity' && method === 'GET') {
|
|
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
|
|
.catch(() => ({}));
|
|
return new Response(JSON.stringify(activity || {}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/sessions/status' && method === 'GET') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
sessions: {},
|
|
serverTime: Date.now(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (normalizedPathname === '/api/sessions/attention' && method === 'GET') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
sessions: {},
|
|
serverTime: Date.now(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/status$/.test(normalizedPathname) && method === 'GET') {
|
|
const sessionId = normalizedPathname.split('/')[3] || '';
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Session not found or no state available',
|
|
sessionId,
|
|
}),
|
|
{
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/attention$/.test(normalizedPathname) && method === 'GET') {
|
|
const sessionId = normalizedPathname.split('/')[3] || '';
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Session not found or no attention state available',
|
|
sessionId,
|
|
}),
|
|
{
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (normalizedPathname === '/api/tts/status' && method === 'GET') {
|
|
return new Response(JSON.stringify({ available: false }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/tts/say/status' && method === 'GET') {
|
|
return new Response(JSON.stringify({ available: false, voices: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak') && method === 'POST') {
|
|
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
|
|
status: 501,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Dictation runs on the OpenChamber web server (WebSocket + worker); the VS
|
|
// Code bridge has no server process, so report it deterministically
|
|
// unavailable. The mic button hides itself when capture is unsupported.
|
|
if (normalizedPathname === '/api/dictation/status' && method === 'GET') {
|
|
return new Response(JSON.stringify({ provider: 'local', available: false, reasonCode: 'unsupported_runtime', models: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname.startsWith('/api/dictation/') ) {
|
|
return new Response(JSON.stringify({ error: 'Dictation is not available in VS Code runtime' }), {
|
|
status: 501,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Health endpoints: reflect actual connection status
|
|
if (pathname === '/health' || pathname === '/api/health') {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
|
const isReady = connectionStatus === 'connected';
|
|
const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
|
return new Response(JSON.stringify({
|
|
status: isReady ? 'ok' : 'connecting',
|
|
isOpenCodeReady: isReady,
|
|
cliAvailable,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (pathname.startsWith('/api/fs/list')) {
|
|
const targetPath = url.searchParams.get('path') || '';
|
|
const respectGitignore = url.searchParams.get('respectGitignore') === 'true';
|
|
const data = await sendBridgeMessage('api:fs:list', { path: targetPath, respectGitignore });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/fs/mkdir')) {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/fs/home')) {
|
|
const data = await sendBridgeMessage('api:fs/home');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/pick-files')) {
|
|
const data = await sendBridgeMessage('api:files/pick');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const uris = Array.isArray((body as { uris?: unknown[] }).uris)
|
|
? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string')
|
|
: [];
|
|
const data = await sendBridgeMessage('api:files/drop', { uris });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
|
|
? (body as { fileName: string }).fileName
|
|
: undefined;
|
|
const dataUrl = typeof (body as { dataUrl?: unknown }).dataUrl === 'string'
|
|
? (body as { dataUrl: string }).dataUrl
|
|
: undefined;
|
|
const data = await sendBridgeMessage('api:files/save-image', { fileName, dataUrl });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/save-markdown') && method === 'POST') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
|
|
? (body as { fileName: string }).fileName
|
|
: undefined;
|
|
const content = typeof (body as { content?: unknown }).content === 'string'
|
|
? (body as { content: string }).content
|
|
: undefined;
|
|
const data = await sendBridgeMessage('api:files/save-markdown', { fileName, content });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/agents/')) {
|
|
const encodedName = pathname.slice('/api/config/agents/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/commands/')) {
|
|
const encodedName = pathname.slice('/api/config/commands/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/mcp') {
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/mcp/')) {
|
|
const encodedName = pathname.slice('/api/config/mcp/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/snippets') {
|
|
const verb = method;
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/snippets', { method: verb, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/snippets/expand') {
|
|
const verb = method === 'GET' && !hasInitBody(init) && !(input instanceof Request) ? 'POST' : method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/snippets', { method: verb, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/snippets/')) {
|
|
const encodedName = pathname.slice('/api/config/snippets/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/snippets', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills file operations: /api/config/skills/:name/files/:filePath
|
|
const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/);
|
|
if (skillsFilesMatch) {
|
|
const name = decodeURIComponent(skillsFilesMatch[1]);
|
|
const filePath = decodeURIComponent(skillsFilesMatch[2]);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills/files', {
|
|
method: verb,
|
|
name,
|
|
filePath,
|
|
content: body.content
|
|
});
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
const skillsCatalogStatusFromPayload = (payload: unknown): number => {
|
|
if (!payload || typeof payload !== 'object') return 200;
|
|
const data = payload as { ok?: boolean; error?: { kind?: string } };
|
|
if (data.ok === false) {
|
|
const kind = data.error?.kind;
|
|
if (kind === 'conflicts') return 409;
|
|
if (kind === 'authRequired') return 401;
|
|
return 400;
|
|
}
|
|
return 200;
|
|
};
|
|
|
|
// Skills catalog: /api/config/skills/catalog
|
|
if (pathname === '/api/config/skills/catalog') {
|
|
const refresh = url.searchParams.get('refresh') === 'true';
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills:catalog', { refresh });
|
|
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills scan: /api/config/skills/scan
|
|
if (pathname === '/api/config/skills/scan') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills:scan', body);
|
|
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills install: /api/config/skills/install
|
|
if (pathname === '/api/config/skills/install') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills:install', body);
|
|
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills CRUD: /api/config/skills/:name or /api/config/skills
|
|
if (pathname === '/api/config/skills') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills', { method: 'GET' });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/skills/')) {
|
|
const encodedName = pathname.slice('/api/config/skills/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = method;
|
|
const body = await extractJsonBody(input, init, method);
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/settings')) {
|
|
if (method === 'GET') {
|
|
const settings = await sendBridgeMessage('api:config/settings:get');
|
|
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
const body = await extractJsonBody(input, init, method);
|
|
const updated = await sendBridgeMessage('api:config/settings:save', body);
|
|
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (normalizedPathname === '/api/behavior/agents-md') {
|
|
if (method === 'GET') {
|
|
const data = await sendBridgeMessage('api:behavior/agents-md:get');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
if (method === 'PUT') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const data = await sendBridgeMessage('api:behavior/agents-md:save', body);
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/magic-prompts') {
|
|
if (method === 'GET') {
|
|
const data = await sendBridgeMessage('api:magic-prompts:get');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
if (method === 'DELETE') {
|
|
const data = await sendBridgeMessage('api:magic-prompts:reset-all');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/magic-prompts/')) {
|
|
const id = decodeURIComponent(pathname.slice('/api/magic-prompts/'.length));
|
|
if (method === 'PUT') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const data = await sendBridgeMessage('api:magic-prompts:save', { id, text: body?.text });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
if (method === 'DELETE') {
|
|
const data = await sendBridgeMessage('api:magic-prompts:reset', { id });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/opencode-resolution' && method === 'GET') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/opencode-resolution:get');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/reload')) {
|
|
await sendBridgeMessage('api:config/reload');
|
|
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins' && method === 'GET') {
|
|
try {
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'list', directory });
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins/registry' && method === 'GET') {
|
|
try {
|
|
const rawSpecs = url.searchParams.get('specs') || '';
|
|
const specs = rawSpecs ? rawSpecs.split(',').map((spec) => spec.trim()).filter(Boolean) : [];
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', {
|
|
method,
|
|
target: 'registry',
|
|
specs,
|
|
refresh: url.searchParams.get('refresh') === 'true',
|
|
directory,
|
|
});
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins/entry' && method === 'POST') {
|
|
try {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'entry', body, directory });
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
const pluginEntryMatch = pathname.match(/^\/api\/config\/plugins\/entry\/([^/]+)$/);
|
|
if (pluginEntryMatch) {
|
|
try {
|
|
const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', {
|
|
method,
|
|
target: 'entry',
|
|
pluginId: decodeURIComponent(pluginEntryMatch[1]),
|
|
body,
|
|
directory,
|
|
});
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/plugins/file' && method === 'POST') {
|
|
try {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', { method, target: 'file', body, directory });
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
const pluginFileMatch = pathname.match(/^\/api\/config\/plugins\/file\/([^/]+)$/);
|
|
if (pluginFileMatch) {
|
|
try {
|
|
const body = method === 'GET' || method === 'DELETE' ? undefined : await extractJsonBody(input, init, method);
|
|
const directory = getRequestDirectoryHint(url, input, init);
|
|
const data = await sendBridgeMessage('api:config/plugins', {
|
|
method,
|
|
target: 'file',
|
|
pluginId: decodeURIComponent(pluginFileMatch[1]),
|
|
body,
|
|
directory,
|
|
});
|
|
return jsonResponse(data);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return jsonResponse({ error: message }, pluginConfigErrorStatus(message));
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/openchamber/models-metadata')) {
|
|
try {
|
|
const data = await sendBridgeMessage('api:models/metadata');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error);
|
|
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/opencode/version' && method === 'GET') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:opencode/version');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ version: null, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/opencode/health' && method === 'GET') {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
|
return new Response(JSON.stringify({ healthy: connectionStatus === 'connected' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (pathname === '/api/opencode/upgrade-status' && method === 'GET') {
|
|
const data = await sendBridgeMessage('api:opencode/upgrade-status');
|
|
return jsonResponse(data);
|
|
}
|
|
|
|
if (pathname === '/api/opencode/upgrade' && method === 'POST') {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const result = await sendBridgeMessage<{ status: number; body: unknown }>('api:opencode/upgrade', body);
|
|
return jsonResponse(result.body, result.status);
|
|
}
|
|
|
|
if (pathname === '/api/zen/models' && method === 'GET') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:zen:models');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message, models: [] }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/openchamber/update-check')) {
|
|
try {
|
|
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
|
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
|
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
|
|
const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined;
|
|
const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined;
|
|
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
|
|
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
|
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
|
currentVersion,
|
|
instanceMode,
|
|
deviceClass,
|
|
platform,
|
|
arch,
|
|
reportUsage,
|
|
});
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ available: false, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/auth/session') {
|
|
// VS Code host is trusted; mirror web server shape to keep UI logic happy
|
|
const body = {
|
|
authenticated: true,
|
|
requireSetup: false,
|
|
authenticatedAt: Date.now(),
|
|
};
|
|
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/opencode/directory')) {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
|
|
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname === '/api/quota/providers') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:quota:providers');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(ollama-cloud|cursor)(?:\/(validate|import))?$/);
|
|
if (quotaCredentialMatch) {
|
|
try {
|
|
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
|
|
const bridgeMethod = quotaCredentialMatch[2]?.toUpperCase() || method;
|
|
const data = await sendBridgeMessage('api:quota:credentials', { providerId: quotaCredentialMatch[1], method: bridgeMethod, credential: body });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
|
|
if (quotaMatch && method === 'GET') {
|
|
const providerId = decodeURIComponent(quotaMatch[1]);
|
|
try {
|
|
const data = await sendBridgeMessage('api:quota:get', { providerId });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Handle provider auth deletion: DELETE /api/provider/:providerId/auth
|
|
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
|
|
if (providerAuthMatch && method === 'DELETE') {
|
|
const providerId = decodeURIComponent(providerAuthMatch[1]);
|
|
const scope = url.searchParams.get('scope') || 'auth';
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
try {
|
|
const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope, directory: queryDirectory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Handle provider source lookup: GET /api/provider/:providerId/source
|
|
const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/);
|
|
if (providerSourceMatch && method === 'GET') {
|
|
const providerId = decodeURIComponent(providerSourceMatch[1]);
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
try {
|
|
const data = await sendBridgeMessage('api:provider/source:get', { providerId, directory: queryDirectory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Handle custom provider upsert: PUT /api/provider
|
|
if (pathname === '/api/provider' && method === 'PUT') {
|
|
try {
|
|
const body = await extractJsonBody(input, init, method);
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
const data = await sendBridgeMessage('api:provider:upsert', {
|
|
...(body && typeof body === 'object' ? body : {}),
|
|
directory: queryDirectory
|
|
?? (body && typeof body === 'object' && typeof body.directory === 'string' ? body.directory : undefined),
|
|
});
|
|
if (data && typeof data === 'object' && 'success' in data && (data as { success?: boolean }).success === false) {
|
|
const message = (data as { error?: string }).error || 'Failed to save provider config';
|
|
return new Response(JSON.stringify({ error: message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify((data as { data?: unknown })?.data ?? data), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const originalFetch = window.fetch.bind(window);
|
|
let sseStreamCounter = 0;
|
|
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
|
|
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
|
|
const pathname = targetUrl?.pathname || '';
|
|
const normalizedPathname = pathname.replace(/\/{2,}/g, '/');
|
|
if (targetUrl && normalizedPathname === '/health') {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
|
const isReady = connectionStatus === 'connected';
|
|
const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
|
return new Response(JSON.stringify({
|
|
status: isReady ? 'ok' : 'connecting',
|
|
isOpenCodeReady: isReady,
|
|
cliAvailable,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (targetUrl && isLocalRuntimePath(normalizedPathname)) {
|
|
const localResponse = await handleLocalApiRequest(input, targetUrl, init, method);
|
|
if (localResponse) {
|
|
maybeHideLoadingOverlay();
|
|
return localResponse;
|
|
}
|
|
|
|
if (!isApiPath(normalizedPathname)) {
|
|
return originalFetch(input as RequestInfo, init);
|
|
}
|
|
|
|
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
|
|
|
|
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
|
|
const headersFromInit = headersToRecord(init?.headers);
|
|
const headers = { ...headersFromRequest, ...headersFromInit };
|
|
|
|
if (isSseApiPath(targetUrl.pathname)) {
|
|
// Install the listener before the extension opens the upstream stream. A
|
|
// reconnect can replay an event immediately, before the start response
|
|
// has crossed the VS Code bridge.
|
|
const streamId = `sse_webview_${Date.now()}_${++sseStreamCounter}`;
|
|
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
|
|
const encoder = new TextEncoder();
|
|
let unsubscribe: (() => void) | null = null;
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const onMessage = (event: MessageEvent) => {
|
|
const msg = event.data as { type?: string; streamId?: string; chunk?: string; error?: string };
|
|
if (!msg || msg.streamId !== streamId) return;
|
|
|
|
if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') {
|
|
vscodeStreamPerfCount('vscode.webview.sse_chunk');
|
|
vscodeStreamPerfObserve('vscode.webview.sse_chunk_bytes', msg.chunk.length);
|
|
controller.enqueue(encoder.encode(msg.chunk));
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'api:sse:end') {
|
|
vscodeStreamPerfCount('vscode.webview.sse_end');
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
if (typeof msg.error === 'string' && msg.error.length > 0) {
|
|
controller.error(new Error(msg.error));
|
|
} else {
|
|
controller.close();
|
|
}
|
|
void stopSseProxy({ streamId }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
window.addEventListener('message', onMessage);
|
|
unsubscribe = () => window.removeEventListener('message', onMessage);
|
|
|
|
if (signal) {
|
|
const onAbort = () => {
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
try {
|
|
controller.error(new DOMException('Aborted', 'AbortError'));
|
|
} catch {
|
|
controller.close();
|
|
}
|
|
void stopSseProxy({ streamId }).catch(() => {});
|
|
};
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
}
|
|
},
|
|
cancel() {
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
void stopSseProxy({ streamId }).catch(() => {});
|
|
},
|
|
});
|
|
|
|
let start;
|
|
try {
|
|
start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers, streamId }));
|
|
} catch (error) {
|
|
await stream.cancel();
|
|
throw error;
|
|
}
|
|
if (!start.streamId) {
|
|
void stream.cancel();
|
|
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
|
|
}
|
|
|
|
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
|
|
}
|
|
|
|
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
|
|
const bodyText = await extractBodyText(input, init, method);
|
|
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
|
|
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText, signal });
|
|
const response = buildProxiedResponse(proxied);
|
|
maybeHideLoadingOverlay();
|
|
return response;
|
|
}
|
|
|
|
const bodyBase64 = await extractBodyBase64(input, init, method);
|
|
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
|
|
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64, signal });
|
|
const response = buildProxiedResponse(proxied);
|
|
maybeHideLoadingOverlay();
|
|
return response;
|
|
}
|
|
|
|
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
|
|
try {
|
|
const data = await sendBridgeMessage('api:models/metadata');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error);
|
|
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
return originalFetch(input as RequestInfo, init);
|
|
};
|
|
|
|
onCommand('focusChatInput', () => {
|
|
focusChatInput();
|
|
});
|
|
|
|
onCommand('addContextSelection', (payload) => {
|
|
const { filePath, filename, text } = payload as { filePath?: unknown; filename?: unknown; text?: unknown };
|
|
if (typeof filePath !== 'string' || typeof filename !== 'string' || typeof text !== 'string') {
|
|
return;
|
|
}
|
|
|
|
const trimmedPath = filePath.trim();
|
|
const trimmedFilename = filename.trim();
|
|
if (!trimmedPath || !trimmedFilename || !text.trim()) {
|
|
return;
|
|
}
|
|
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
const file = new File([new Blob([text], { type: 'text/plain' })], trimmedFilename, { type: 'text/plain' });
|
|
void useInputStore.getState().addVSCodeSelectionAttachment(trimmedPath, file).finally(() => {
|
|
focusChatInput();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Comments dropped from their editor thread before the draft reached this
|
|
// store. See the module for why the window exists.
|
|
const removedComments = createRemovalTombstones();
|
|
|
|
onCommand('addLineComment', (payload) => {
|
|
// SAFETY: the payload crossed the extension boundary as JSON; every field is
|
|
// read as unknown here and trusted only after the checks below.
|
|
const record = payload as {
|
|
draftId?: unknown;
|
|
filePath?: unknown;
|
|
relativePath?: unknown;
|
|
source?: unknown;
|
|
side?: unknown;
|
|
startLine?: unknown;
|
|
endLine?: unknown;
|
|
code?: unknown;
|
|
language?: unknown;
|
|
comment?: unknown;
|
|
targetSessionId?: unknown;
|
|
};
|
|
|
|
// The editor thread mints the id so it can track its own draft without a
|
|
// round trip. Absent when the comment came from anywhere else.
|
|
const draftId = typeof record.draftId === 'string' && record.draftId ? record.draftId : undefined;
|
|
// A session panel is told which session the comment is for, so it can wait
|
|
// until it actually shows that session. The sidebar files wherever it is.
|
|
const targetSessionId = typeof record.targetSessionId === 'string' && record.targetSessionId ? record.targetSessionId : undefined;
|
|
const relativePath = typeof record.relativePath === 'string' ? record.relativePath : '';
|
|
const source = record.source === 'diff' ? 'diff' : 'file';
|
|
const side = record.side === 'original' || record.side === 'modified' ? record.side : undefined;
|
|
const startLine = typeof record.startLine === 'number' ? record.startLine : 1;
|
|
const endLine = typeof record.endLine === 'number' ? record.endLine : startLine;
|
|
const code = typeof record.code === 'string' ? record.code : '';
|
|
const language = typeof record.language === 'string' ? record.language : 'text';
|
|
const comment = typeof record.comment === 'string' ? record.comment.trim() : '';
|
|
|
|
if (!relativePath) {
|
|
console.warn('[openchamber] inline comment arrived without a path; dropping', record);
|
|
return;
|
|
}
|
|
|
|
void Promise.all([
|
|
import('@/sync/session-ui-store'),
|
|
import('@/stores/useDirectoryStore'),
|
|
import('@/stores/useInlineCommentDraftStore'),
|
|
]).then(async ([{ useSessionUIStore }, { useDirectoryStore }, { useInlineCommentDraftStore }]) => {
|
|
// Inline drafts are owned by runtime + directory + session. Both halves are
|
|
// read together, from one store snapshot: read apart, a session that
|
|
// finished loading between them would pair its key with the previous
|
|
// session's directory, and the draft would land under a key ChatInput never
|
|
// reads. Directory precedence matches the composer's own.
|
|
const resolveTarget = () => {
|
|
const sessionState = useSessionUIStore.getState();
|
|
const currentSessionId = sessionState.currentSessionId ?? null;
|
|
const draft = sessionState.newSessionDraft;
|
|
return resolveCommentTarget({
|
|
currentSessionId,
|
|
sessionDirectory: currentSessionId ? sessionState.getDirectoryForSession(currentSessionId) ?? null : null,
|
|
draftOpen: Boolean(draft?.open),
|
|
draftDirectory: draft?.open ? draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null : null,
|
|
currentDirectory: useDirectoryStore.getState().currentDirectory ?? null,
|
|
}, targetSessionId);
|
|
};
|
|
|
|
// A comment can arrive before the chat surface shows its session: a panel
|
|
// opened for the comment knows its directory long before the session list
|
|
// has loaded and the session is selected. Filing before that put the draft
|
|
// under a key this composer never reads. Wait for the surface to land on
|
|
// the session (or an open draft) instead, within the extension's own
|
|
// confirmation deadline.
|
|
let target = resolveTarget();
|
|
for (let attempt = 0; !target && attempt < 80; attempt += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
target = resolveTarget();
|
|
}
|
|
if (!target) {
|
|
console.warn('[openchamber] chat surface never showed the session; dropping inline comment', { relativePath, startLine, targetSessionId });
|
|
return;
|
|
}
|
|
|
|
// Checked after the wait, which is the window the removal can land in.
|
|
if (removedComments.consume(draftId)) {
|
|
return;
|
|
}
|
|
|
|
useInlineCommentDraftStore.getState().addDraft(target, {
|
|
id: draftId,
|
|
source,
|
|
fileLabel: relativePath,
|
|
startLine,
|
|
endLine,
|
|
side,
|
|
code,
|
|
language,
|
|
text: comment,
|
|
});
|
|
});
|
|
});
|
|
|
|
// The editor's comment threads mirror the composer's drafts, so every change to
|
|
// the draft store is reported as a whole snapshot. Sending the full list rather
|
|
// than add/remove events means a dropped notification cannot leave a thread
|
|
// anchored to a comment that is no longer attached; sending the message empties
|
|
// the list, which clears the threads through the same path.
|
|
void import('@/stores/useInlineCommentDraftStore').then(({ useInlineCommentDraftStore }) => {
|
|
let lastSignature = '';
|
|
|
|
const publish = (drafts: Record<string, Array<{ id: string; text: string }>>) => {
|
|
const flat = Object.values(drafts)
|
|
.flat()
|
|
.map((draft) => ({ id: draft.id, text: draft.text }));
|
|
const signature = JSON.stringify(flat);
|
|
if (signature === lastSignature) return;
|
|
lastSignature = signature;
|
|
postBridgeNotification('inlineComments:sync', { drafts: flat });
|
|
};
|
|
|
|
publish(useInlineCommentDraftStore.getState().drafts);
|
|
useInlineCommentDraftStore.subscribe((state) => publish(state.drafts));
|
|
});
|
|
|
|
onCommand('removeLineComment', (payload) => {
|
|
if (typeof payload !== 'object' || payload === null || !('draftId' in payload)) return;
|
|
const { draftId } = payload;
|
|
if (typeof draftId !== 'string' || !draftId) {
|
|
return;
|
|
}
|
|
|
|
// Recorded even when the draft is already here: the store removal below is
|
|
// the normal path, and this only matters when the draft has not landed yet.
|
|
removedComments.remember(draftId);
|
|
|
|
void Promise.all([
|
|
import('@/stores/useInlineCommentDraftStore'),
|
|
import('@/lib/runtime-switch'),
|
|
]).then(([{ useInlineCommentDraftStore }, { getRuntimeKey }]) => {
|
|
const state = useInlineCommentDraftStore.getState();
|
|
const runtimeKey = getRuntimeKey();
|
|
|
|
// The thread knows its draft id but not which target holds it. Search for
|
|
// the owning key, and only within the current runtime: `removeDraft`
|
|
// recomputes the key from the live runtime, so a target rebuilt from
|
|
// another runtime's key would delete from the wrong place.
|
|
for (const [key, drafts] of Object.entries(state.drafts)) {
|
|
if (!drafts.some((draft) => draft.id === draftId)) continue;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(key);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every((segment) => typeof segment === 'string')) continue;
|
|
const [keyRuntime, directory, sessionKey] = parsed;
|
|
if (keyRuntime !== runtimeKey) continue;
|
|
state.removeDraft({ directory, sessionKey }, draftId);
|
|
return;
|
|
}
|
|
});
|
|
});
|
|
|
|
onCommand('addFileMentions', (payload) => {
|
|
const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths)
|
|
? (payload as { paths: unknown[] }).paths
|
|
: [];
|
|
const paths = rawPaths
|
|
.filter((entry): entry is string => typeof entry === 'string')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
|
|
if (paths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' ');
|
|
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
useInputStore.getState().setPendingInputText(mentionText, 'append-inline');
|
|
});
|
|
});
|
|
|
|
onCommand('addFileAttachments', (payload) => {
|
|
const rawFiles = Array.isArray((payload as { files?: unknown[] })?.files)
|
|
? (payload as { files: unknown[] }).files
|
|
: [];
|
|
|
|
const files = rawFiles
|
|
.map((entry) => {
|
|
const record = entry as { filePath?: unknown; fileName?: unknown; fileSize?: unknown };
|
|
const filePath = typeof record.filePath === 'string' ? record.filePath.trim() : '';
|
|
const fileName = typeof record.fileName === 'string' ? record.fileName.trim() : '';
|
|
const fileSize = typeof record.fileSize === 'number' && Number.isFinite(record.fileSize) ? record.fileSize : null;
|
|
return filePath && fileName ? { filePath, fileName, fileSize } : null;
|
|
})
|
|
.filter((entry): entry is { filePath: string; fileName: string; fileSize: number | null } => entry !== null);
|
|
|
|
if (files.length === 0) {
|
|
return;
|
|
}
|
|
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
const inputStore = useInputStore.getState();
|
|
for (const file of files) {
|
|
inputStore.addVSCodeFileAttachment(file.filePath, file.fileName, file.fileSize);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
|
|
onCommand('createSessionWithPrompt', (payload) => {
|
|
const { prompt } = payload as { prompt: string };
|
|
|
|
Promise.all([
|
|
import('@/sync/session-ui-store'),
|
|
import('@/stores/useConfigStore'),
|
|
import('@/sync/input-store'),
|
|
]).then(([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => {
|
|
const sessionStore = useSessionUIStore.getState();
|
|
const configStore = useConfigStore.getState();
|
|
|
|
// Get current provider/model/agent configuration
|
|
const { currentProviderId, currentModelId, currentAgentName } = configStore;
|
|
|
|
if (currentProviderId && currentModelId) {
|
|
if (!sessionStore.currentSessionId) {
|
|
sessionStore.openNewSessionDraft();
|
|
}
|
|
|
|
// Send the message - this will create the session from the draft and send
|
|
sessionStore.sendMessage(
|
|
prompt,
|
|
currentProviderId,
|
|
currentModelId,
|
|
currentAgentName ?? undefined,
|
|
undefined, // attachments
|
|
undefined, // agentMentionName
|
|
undefined // additionalParts
|
|
).catch((error: unknown) => {
|
|
console.error('[OpenChamber] Failed to send prompt:', error);
|
|
});
|
|
} else {
|
|
// If no provider/model configured, just set the text and let user send manually
|
|
useInputStore.getState().setPendingInputText(prompt);
|
|
}
|
|
});
|
|
});
|
|
|
|
const normalizeWorkspaceFoldersPayload = (value: unknown): Array<{ name: string; path: string }> => {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return value
|
|
.map((entry) => {
|
|
const candidate = entry as { name?: unknown; path?: unknown };
|
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
|
const path = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
|
return path ? { name, path } : null;
|
|
})
|
|
.filter((entry): entry is { name: string; path: string } => entry !== null);
|
|
};
|
|
|
|
const syncVSCodeWorkspaceProjects = async (
|
|
workspaceFolders: Array<{ name: string; path: string }>,
|
|
activePath?: string,
|
|
) => {
|
|
if (window.__VSCODE_CONFIG__) {
|
|
window.__VSCODE_CONFIG__.workspaceFolders = workspaceFolders;
|
|
}
|
|
const { useProjectsStore } = await import('@/stores/useProjectsStore');
|
|
return useProjectsStore.getState().syncVSCodeWorkspaceFolders(workspaceFolders, activePath);
|
|
};
|
|
|
|
onCommand('workspaceFoldersChanged', (payload) => {
|
|
const record = payload as { workspaceFolders?: unknown } | undefined;
|
|
const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders);
|
|
void syncVSCodeWorkspaceProjects(workspaceFolders);
|
|
});
|
|
|
|
// Listen for newSession command from extension title bar button
|
|
onCommand('newSession', (payload) => {
|
|
const record = payload as { directory?: unknown; workspaceFolders?: unknown } | undefined;
|
|
const directory = record?.directory;
|
|
const directoryOverride = typeof directory === 'string' && directory.trim().length > 0 ? directory.trim() : undefined;
|
|
const workspaceFolders = normalizeWorkspaceFoldersPayload(record?.workspaceFolders);
|
|
|
|
Promise.all([
|
|
import('@/sync/session-ui-store'),
|
|
syncVSCodeWorkspaceProjects(workspaceFolders, directoryOverride),
|
|
]).then(([{ useSessionUIStore }, selectedProject]) => {
|
|
useSessionUIStore.getState().openNewSessionDraft(
|
|
directoryOverride
|
|
? { directoryOverride, selectedProjectId: selectedProject?.id ?? undefined }
|
|
: undefined
|
|
);
|
|
});
|
|
|
|
// Also dispatch event to navigate to chat view in VSCodeLayout
|
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
|
});
|
|
|
|
// Listen for showSettings command from extension title bar button
|
|
onCommand('showSettings', () => {
|
|
// Dispatch event to navigate to settings view in VSCodeLayout
|
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
|
|
});
|
|
|
|
// Run the same full OpenCode reload flow the app uses after an update: shows the
|
|
// reload overlay, restarts the managed OpenCode (via the bridge's /api/config/reload),
|
|
// and refreshes config/data. Triggered by the "Restart API Connection" command.
|
|
onCommand('reloadOpenCode', () => {
|
|
void import('@openchamber/ui/stores/useAgentsStore').then(({ reloadOpenCodeConfiguration }) => {
|
|
void reloadOpenCodeConfiguration().catch(() => undefined);
|
|
});
|
|
});
|
|
|
|
const getNotificationClaimKey = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown } | undefined): string => {
|
|
const tag = typeof payload?.tag === 'string' ? payload.tag.trim() : '';
|
|
if (tag) return tag;
|
|
return [payload?.sessionId, payload?.title, payload?.body]
|
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
|
.map((value) => value.trim())
|
|
.join('|');
|
|
};
|
|
|
|
const claimOpenChamberNotification = async (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown } | undefined): Promise<boolean> => {
|
|
const key = getNotificationClaimKey(payload);
|
|
if (!key) return true;
|
|
try {
|
|
const result = await sendBridgeMessage<{ claimed?: boolean }>('api:notifications:claim', { key });
|
|
return result?.claimed === true;
|
|
} catch {
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const showOpenChamberNotification = (payload: { title?: unknown; body?: unknown; sessionId?: unknown; tag?: unknown; requireHidden?: unknown } | undefined) => {
|
|
if (typeof Notification === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const show = async () => {
|
|
const isVSCodeWindowFocused = window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ ?? document.hasFocus();
|
|
if (payload?.requireHidden === true && isVSCodeWindowFocused) {
|
|
return false;
|
|
}
|
|
if (Notification.permission !== 'granted') {
|
|
return false;
|
|
}
|
|
|
|
const title = typeof payload?.title === 'string' && payload.title.trim().length > 0
|
|
? payload.title.trim()
|
|
: 'OpenChamber';
|
|
const body = typeof payload?.body === 'string' ? payload.body : '';
|
|
const sessionId = typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0
|
|
? payload.sessionId.trim()
|
|
: '';
|
|
if (!await claimOpenChamberNotification({ ...payload, title, body, sessionId })) {
|
|
return false;
|
|
}
|
|
|
|
const notification = new Notification(title, { body });
|
|
notification.onclick = () => {
|
|
if (sessionId) {
|
|
import('@/sync/session-ui-store').then(({ useSessionUIStore }) => {
|
|
useSessionUIStore.getState().setCurrentSession(sessionId);
|
|
});
|
|
}
|
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
|
};
|
|
return true;
|
|
};
|
|
|
|
if (Notification.permission === 'default') {
|
|
void Notification.requestPermission().then((permission) => {
|
|
if (permission === 'granted') {
|
|
void show();
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
void show();
|
|
return true;
|
|
};
|
|
|
|
onCommand('showNotification', (payload) => {
|
|
showOpenChamberNotification(payload as { title?: unknown; body?: unknown; sessionId?: unknown; requireHidden?: unknown } | undefined);
|
|
});
|
|
|
|
onCommand('windowFocusChanged', (payload) => {
|
|
if (typeof payload === 'object' && payload && typeof (payload as { focused?: unknown }).focused === 'boolean') {
|
|
window.__OPENCHAMBER_VSCODE_WINDOW_FOCUSED__ = (payload as { focused: boolean }).focused;
|
|
}
|
|
});
|
|
|
|
const readyNotificationCooldowns = new Map<string, number>();
|
|
const errorNotificationCooldowns = new Map<string, number>();
|
|
const READY_NOTIFICATION_COOLDOWN_MS = 5000;
|
|
const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250;
|
|
let notificationSettingsSyncPromise: Promise<void> | null = null;
|
|
|
|
const getPayloadString = (value: unknown): string => typeof value === 'string' ? value.trim() : '';
|
|
|
|
const normalizeNotificationPlainText = (text: string): string => text
|
|
.replace(/```[\s\S]*?```/g, ' ')
|
|
.replace(/`([^`]*)`/g, '$1')
|
|
.replace(/^[\t ]*[-*+]\s+/gm, '')
|
|
.replace(/^#{1,6}\s+/gm, '')
|
|
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
.replace(/__(.*?)__/g, '$1')
|
|
.replace(/\*(.*?)\*/g, '$1')
|
|
.replace(/_(.*?)_/g, '$1')
|
|
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
|
.replace(/\s*\n\s*/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
const truncateNotificationText = (text: string, maxLength: number): string => (
|
|
text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`
|
|
);
|
|
|
|
const resolvePositiveNotificationNumber = (value: unknown, fallback: number): number => (
|
|
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
|
|
);
|
|
|
|
const ensureNotificationSettingsSynced = async () => {
|
|
if (!notificationSettingsSyncPromise) {
|
|
notificationSettingsSyncPromise = import('@/lib/persistence')
|
|
.then(({ syncDesktopSettings }) => syncDesktopSettings())
|
|
.catch((error) => {
|
|
notificationSettingsSyncPromise = null;
|
|
console.warn('[OpenChamber] Failed to sync notification settings:', error);
|
|
});
|
|
}
|
|
await notificationSettingsSyncPromise;
|
|
};
|
|
|
|
const prepareNotificationLastMessage = (
|
|
message: string,
|
|
settings: { maxLastMessageLength: number },
|
|
): string => {
|
|
const maxLength = resolvePositiveNotificationNumber(settings.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH);
|
|
return truncateNotificationText(normalizeNotificationPlainText(message), maxLength);
|
|
};
|
|
|
|
const resolveTemplate = (template: string, variables: Record<string, string>): string => (
|
|
template.replace(/\{(\w+)\}/g, (_match, key: string) => variables[key] ?? '')
|
|
);
|
|
|
|
const shouldApplyTemplateMessage = (template: string, resolved: string, variables: Record<string, string>) => {
|
|
if (!resolved) return false;
|
|
if (template.includes('{last_message}')) {
|
|
return variables.last_message.trim().length > 0;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const formatNotificationLabel = (raw: string, fallback: string): string => {
|
|
if (!raw) return fallback;
|
|
return raw.split(/[-_\s]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' ');
|
|
};
|
|
|
|
const extractNotificationTextFromParts = (parts: unknown): string => {
|
|
if (!Array.isArray(parts)) return '';
|
|
return parts
|
|
.map((part) => {
|
|
if (!part || typeof part !== 'object') return '';
|
|
const entry = part as { type?: unknown; text?: unknown; content?: unknown };
|
|
if (entry.type === 'text') {
|
|
return typeof entry.text === 'string' ? entry.text : typeof entry.content === 'string' ? entry.content : '';
|
|
}
|
|
return '';
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.trim();
|
|
};
|
|
|
|
const extractNotificationLastMessage = (payload: Record<string, unknown>): string => {
|
|
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
|
const info = properties.info as Record<string, unknown> | undefined;
|
|
if (!info) return '';
|
|
return extractNotificationTextFromParts(info.parts ?? properties.parts) || extractNotificationTextFromParts(info.content);
|
|
};
|
|
|
|
const fetchLastAssistantMessageText = async (sessionId: string, messageId?: string): Promise<string> => {
|
|
if (!sessionId) return '';
|
|
|
|
try {
|
|
const messages = await opencodeClient.getSessionMessages(sessionId, 5);
|
|
if (!Array.isArray(messages)) return '';
|
|
|
|
let target = messageId
|
|
? messages.find((message) => {
|
|
const info = message && typeof message === 'object'
|
|
? (message as { info?: { id?: unknown; role?: unknown } }).info
|
|
: undefined;
|
|
return info?.id === messageId && info?.role === 'assistant';
|
|
})
|
|
: null;
|
|
|
|
if (!target) {
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index];
|
|
const info = message && typeof message === 'object'
|
|
? (message as { info?: { role?: unknown; finish?: unknown } }).info
|
|
: undefined;
|
|
if (info?.role === 'assistant' && info?.finish === 'stop') {
|
|
target = message;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!target || typeof target !== 'object') return '';
|
|
const message = target as { parts?: unknown; content?: unknown; info?: { parts?: unknown; content?: unknown } };
|
|
return extractNotificationTextFromParts(message.parts ?? message.info?.parts)
|
|
|| extractNotificationTextFromParts(message.content ?? message.info?.content);
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const getNotificationTemplate = (
|
|
settings: { notificationTemplates?: Record<string, { title?: string; message?: string }> },
|
|
key: 'completion' | 'subtask' | 'error' | 'question',
|
|
fallback: { title: string; message: string },
|
|
) => {
|
|
const candidate = settings.notificationTemplates?.[key];
|
|
return {
|
|
title: typeof candidate?.title === 'string' ? candidate.title : fallback.title,
|
|
message: typeof candidate?.message === 'string' ? candidate.message : fallback.message,
|
|
};
|
|
};
|
|
|
|
const buildNotificationVariables = (payload: Record<string, unknown>, sessionId: string, lastMessage: string): Record<string, string> => {
|
|
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
|
const info = properties.info as Record<string, unknown> | undefined;
|
|
const pathInfo = info?.path as { root?: unknown; cwd?: unknown } | undefined;
|
|
const worktree = getPayloadString(pathInfo?.root ?? pathInfo?.cwd);
|
|
const modelId = getPayloadString(info?.modelID ?? info?.modelId ?? (info?.model as { modelID?: unknown } | undefined)?.modelID);
|
|
return {
|
|
project_name: worktree.split(/[\\/]/).filter(Boolean).pop() || '',
|
|
worktree,
|
|
branch: '',
|
|
session_name: getPayloadString(properties.sessionTitle ?? (properties.session as { title?: unknown } | undefined)?.title ?? info?.sessionTitle),
|
|
agent_name: formatNotificationLabel(getPayloadString(info?.agent ?? info?.mode), 'Agent'),
|
|
model_name: formatNotificationLabel(modelId, 'Assistant'),
|
|
last_message: lastMessage,
|
|
session_id: sessionId,
|
|
};
|
|
};
|
|
|
|
const getNotificationSessionId = (payload: Record<string, unknown>): string => {
|
|
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
|
const info = properties.info as Record<string, unknown> | undefined;
|
|
return getPayloadString(info?.sessionID ?? info?.sessionId ?? properties.sessionID ?? properties.sessionId ?? properties.session);
|
|
};
|
|
|
|
const getNotificationDirectory = (payload: Record<string, unknown>): string | null => {
|
|
const properties = (payload.properties ?? payload) as Record<string, unknown>;
|
|
const info = properties.info as Record<string, unknown> | undefined;
|
|
return getPayloadString(properties.directory ?? info?.directory) || null;
|
|
};
|
|
|
|
window.addEventListener('openchamber:vscode-notification-event', (event) => {
|
|
const detail = (event as CustomEvent<{ directory?: string; payload?: unknown }>).detail;
|
|
const payload = detail?.payload;
|
|
if (!payload || typeof payload !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const record = payload as Record<string, unknown>;
|
|
const type = getPayloadString(record.type);
|
|
const properties = (record.properties ?? record) as Record<string, unknown>;
|
|
const info = properties.info as Record<string, unknown> | undefined;
|
|
const sessionId = getNotificationSessionId(record);
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
Promise.all([
|
|
import('@/stores/useUIStore'),
|
|
]).then(async ([{ useUIStore }]) => {
|
|
await ensureNotificationSettingsSynced();
|
|
const settings = useUIStore.getState();
|
|
if (!settings.nativeNotificationsEnabled) {
|
|
return;
|
|
}
|
|
const requireHidden = settings.notificationMode !== 'always';
|
|
const messageId = getPayloadString(info?.id);
|
|
const error = properties.error;
|
|
const errorMessage = getPayloadString(
|
|
typeof error === 'object' && error
|
|
? (error as { message?: unknown }).message
|
|
: error,
|
|
);
|
|
const rawLastMessage = extractNotificationLastMessage(record)
|
|
|| errorMessage
|
|
|| await fetchLastAssistantMessageText(sessionId, messageId);
|
|
const lastMessage = prepareNotificationLastMessage(
|
|
rawLastMessage,
|
|
settings,
|
|
);
|
|
const variables = buildNotificationVariables(record, sessionId, lastMessage);
|
|
|
|
const isAssistantMessage = type === 'message.updated' && getPayloadString(info?.role) === 'assistant';
|
|
const finish = isAssistantMessage ? getPayloadString(info?.finish) : '';
|
|
const isCompletion = type === 'session.idle' || finish === 'stop';
|
|
const isError = type === 'session.error' || finish === 'error';
|
|
|
|
if (isCompletion) {
|
|
const session = await opencodeClient.getSession(sessionId, getNotificationDirectory(record)).catch(() => undefined);
|
|
if (!session) return;
|
|
const isSubtask = Boolean(session?.parentID);
|
|
if (isSubtask ? !settings.notifyOnSubtasks : !settings.notifyOnCompletion) return;
|
|
const now = Date.now();
|
|
const lastAt = readyNotificationCooldowns.get(sessionId) ?? 0;
|
|
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
|
readyNotificationCooldowns.set(sessionId, now);
|
|
const template = getNotificationTemplate(settings, isSubtask ? 'subtask' : 'completion', { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
|
const title = resolveTemplate(template.title, variables) || 'Agent is ready';
|
|
const body = resolveTemplate(template.message, variables);
|
|
showOpenChamberNotification({
|
|
title,
|
|
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : `${variables.model_name} completed the task`,
|
|
sessionId,
|
|
requireHidden,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (isError) {
|
|
if (!settings.notifyOnError) return;
|
|
const now = Date.now();
|
|
const lastAt = errorNotificationCooldowns.get(sessionId) ?? 0;
|
|
if (now - lastAt < READY_NOTIFICATION_COOLDOWN_MS) return;
|
|
errorNotificationCooldowns.set(sessionId, now);
|
|
const template = getNotificationTemplate(settings, 'error', { title: 'Tool error', message: '{last_message}' });
|
|
const title = resolveTemplate(template.title, variables) || 'Tool error';
|
|
const body = resolveTemplate(template.message, variables);
|
|
showOpenChamberNotification({
|
|
title,
|
|
body: shouldApplyTemplateMessage(template.message, body, variables) ? body : 'An error occurred',
|
|
sessionId,
|
|
requireHidden,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (type === 'question.asked') {
|
|
if (!settings.notifyOnQuestion) return;
|
|
const questions = Array.isArray(properties.questions) ? properties.questions : [];
|
|
const firstQuestion = questions[0] as Record<string, unknown> | undefined;
|
|
const header = getPayloadString(firstQuestion?.header);
|
|
const questionText = getPayloadString(firstQuestion?.question);
|
|
const questionVariables = { ...variables, last_message: questionText || header };
|
|
const template = getNotificationTemplate(settings, 'question', { title: 'Input needed', message: '{last_message}' });
|
|
const title = resolveTemplate(template.title, questionVariables) || (/plan\s*mode/i.test(header) ? 'Switch to plan mode' : /build\s*agent/i.test(header) ? 'Switch to build mode' : header || 'Input needed');
|
|
const body = resolveTemplate(template.message, questionVariables);
|
|
showOpenChamberNotification({
|
|
title,
|
|
body: shouldApplyTemplateMessage(template.message, body, questionVariables) ? body : questionText || 'Agent is waiting for your response',
|
|
sessionId,
|
|
requireHidden,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (type === 'permission.asked') {
|
|
if (!settings.notifyOnQuestion) return;
|
|
const requestId = getPayloadString(properties.id);
|
|
if (requestId) {
|
|
const accepted = await processVSCodePermissionAutoAccept(
|
|
properties as unknown as PermissionRequest,
|
|
detail?.directory,
|
|
);
|
|
if (accepted) return;
|
|
}
|
|
const permission = getPayloadString(properties.permission);
|
|
const sessionTitle = getPayloadString(properties.sessionTitle);
|
|
const fallbackMessage = sessionTitle || permission || 'Agent is waiting for your approval';
|
|
const permissionVariables = { ...variables, last_message: fallbackMessage };
|
|
const template = getNotificationTemplate(settings, 'question', { title: 'Permission required', message: '{last_message}' });
|
|
const title = resolveTemplate(template.title, permissionVariables) || 'Permission required';
|
|
const body = resolveTemplate(template.message, permissionVariables);
|
|
showOpenChamberNotification({
|
|
title,
|
|
body: shouldApplyTemplateMessage(template.message, body, permissionVariables) ? body : fallbackMessage,
|
|
sessionId,
|
|
requireHidden,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
// Listen for settings sync command from extension (broadcast to all VS Code webviews)
|
|
onCommand('settingsSynced', () => {
|
|
import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => {
|
|
void syncDesktopSettings({ adoptTheme: false });
|
|
});
|
|
});
|
|
|
|
onCommand('permissionAutoAcceptSynced', (payload) => {
|
|
if (!payload || typeof payload !== 'object') return;
|
|
const snapshot = payload as { sessions?: unknown; revision?: unknown };
|
|
const sessions = snapshot.sessions;
|
|
if (!sessions || typeof sessions !== 'object') return;
|
|
usePermissionStore.getState().applySnapshot({
|
|
sessions: sessions as Record<string, boolean>,
|
|
revision: typeof snapshot.revision === 'number' ? snapshot.revision : undefined,
|
|
});
|
|
});
|
|
|
|
// Listen for active editor file changes from the extension
|
|
onCommand('activeEditorFile', (payload) => {
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
useInputStore.getState().setActiveEditorFile((payload as VSCodeActiveEditorFile | null) ?? null);
|
|
});
|
|
});
|
|
|
|
import('@openchamber/ui/apps/renderVSCodeApp')
|
|
.then(async ({ renderVSCodeApp }) => {
|
|
renderVSCodeApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createVSCodeAPIs());
|
|
await waitForUiMount();
|
|
uiMounted = true;
|
|
maybeHideLoadingOverlay();
|
|
})
|
|
.catch((error) => {
|
|
console.error('[OpenChamber] Failed to bootstrap UI:', error);
|
|
// If the UI bundle fails to load, remove the overlay so the user at least sees errors in the root.
|
|
uiMounted = true;
|
|
fadeOutLoadingScreen();
|
|
});
|