Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
This commit is contained in:
committed by
GitHub
parent
a4314c189b
commit
2031e3b4a8
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { devtools, persist, createJSONStorage } from "zustand/middleware";
|
||||
import type { AttachedFile } from "./types/sessionTypes";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
interface FileState {
|
||||
attachedFiles: AttachedFile[];
|
||||
@@ -103,7 +104,7 @@ const toFileUrl = (inputPath: string): string => {
|
||||
};
|
||||
|
||||
const readRawFileAsDataUrl = async (absolutePath: string): Promise<string> => {
|
||||
const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(absolutePath)}`);
|
||||
const response = await runtimeFetch("/api/fs/raw", { query: { path: absolutePath } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read raw file: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import { respondToPermission } from "@/sync/session-actions";
|
||||
import { useSessionUIStore } from "@/sync/session-ui-store";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
interface PermissionState {
|
||||
autoAccept: PermissionAutoAcceptMap;
|
||||
@@ -237,7 +238,7 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
// round-trip. Send known descendants too; server-side
|
||||
// ancestry lookup can lag OpenCode session indexing.
|
||||
for (const scopedSessionId of sessionScope) {
|
||||
void fetch('/api/notifications/auto-accept', {
|
||||
void runtimeFetch('/api/notifications/auto-accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: scopedSessionId, enabled }),
|
||||
@@ -356,7 +357,7 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
// survives page reloads / server restarts.
|
||||
for (const [sid, enabled] of Object.entries(state.autoAccept || {})) {
|
||||
if (enabled === true) {
|
||||
void fetch('/api/notifications/auto-accept', {
|
||||
void runtimeFetch('/api/notifications/auto-accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: sid, enabled: true }),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCommandsStore } from "@/stores/useCommandsStore";
|
||||
import { useProjectsStore } from "@/stores/useProjectsStore";
|
||||
import { useSkillsCatalogStore } from "@/stores/useSkillsCatalogStore";
|
||||
import { useSkillsStore } from "@/stores/useSkillsStore";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
// Note: useDirectoryStore cannot be imported at top level to avoid circular dependency
|
||||
// useDirectoryStore -> useAgentsStore (for refreshAfterOpenCodeRestart)
|
||||
@@ -239,7 +240,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
agents.map(async (agent) => {
|
||||
try {
|
||||
// Force no-cache to ensure we get the latest scope info
|
||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agent.name)}${queryParams}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}),
|
||||
@@ -328,7 +329,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -389,7 +390,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -439,7 +440,7 @@ export const useAgentsStore = create<AgentsStore>()(
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE',
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
@@ -667,7 +668,7 @@ export async function reloadOpenCodeConfiguration(options?: {
|
||||
|
||||
try {
|
||||
|
||||
const response = await fetch('/api/config/reload', {
|
||||
const response = await runtimeFetch('/api/config/reload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
const activeProjectPath = '/workspace/project';
|
||||
|
||||
let listCommandsWithDetailsCalls = 0;
|
||||
let listCommandsWithDetailsImpl: () => Promise<unknown[]> = async () => [];
|
||||
let withDirectoryImpl: (_directory: string | null, callback: () => Promise<unknown>) => Promise<unknown> = async (_directory, callback) => callback();
|
||||
let getDirectoryImpl: () => string = () => '/fallback/project';
|
||||
let runtimeFetchImpl: () => Promise<Response> = async () => new Response(JSON.stringify({ scope: 'project' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const listCommandsWithDetailsMock = async () => {
|
||||
listCommandsWithDetailsCalls += 1;
|
||||
return listCommandsWithDetailsImpl();
|
||||
};
|
||||
|
||||
const withDirectoryMock = async (directory: string | null, callback: () => Promise<unknown>) => withDirectoryImpl(directory, callback);
|
||||
const getDirectoryMock = () => getDirectoryImpl();
|
||||
const runtimeFetchMock = async () => runtimeFetchImpl();
|
||||
|
||||
mock.module('@/lib/opencode/client', () => ({
|
||||
opencodeClient: {
|
||||
getDirectory: getDirectoryMock,
|
||||
listCommandsWithDetails: listCommandsWithDetailsMock,
|
||||
withDirectory: withDirectoryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/stores/useProjectsStore', () => ({
|
||||
useProjectsStore: {
|
||||
getState: () => ({
|
||||
getActiveProject: () => ({ path: activeProjectPath }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
runtimeFetch: runtimeFetchMock,
|
||||
}));
|
||||
|
||||
mock.module('@/lib/configUpdate', () => ({
|
||||
startConfigUpdate: mock(() => undefined),
|
||||
finishConfigUpdate: mock(() => undefined),
|
||||
updateConfigUpdateMessage: mock(() => undefined),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/configSync', () => ({
|
||||
emitConfigChange: mock(() => undefined),
|
||||
scopeMatches: mock(() => false),
|
||||
subscribeToConfigChanges: mock(() => () => undefined),
|
||||
}));
|
||||
|
||||
const { useCommandsStore } = await import('./useCommandsStore');
|
||||
|
||||
describe('useCommandsStore', () => {
|
||||
beforeEach(() => {
|
||||
listCommandsWithDetailsCalls = 0;
|
||||
listCommandsWithDetailsImpl = async () => [];
|
||||
withDirectoryImpl = async (_directory, callback) => callback();
|
||||
getDirectoryImpl = () => '/fallback/project';
|
||||
runtimeFetchImpl = async () => new Response(JSON.stringify({ scope: 'project' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
useCommandsStore.setState({
|
||||
selectedCommandName: null,
|
||||
commands: [],
|
||||
isLoading: false,
|
||||
commandDraft: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('loadCommands preserves previous commands when the command list fails', async () => {
|
||||
const previousCommands = [{
|
||||
name: 'existing',
|
||||
description: 'Existing command',
|
||||
template: 'do the previous thing',
|
||||
scope: 'project' as const,
|
||||
}];
|
||||
useCommandsStore.setState({ commands: previousCommands });
|
||||
listCommandsWithDetailsImpl = async () => {
|
||||
throw new Error('network down');
|
||||
};
|
||||
|
||||
const result = await useCommandsStore.getState().loadCommands();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(listCommandsWithDetailsCalls).toBe(3);
|
||||
expect(useCommandsStore.getState().commands).toEqual(previousCommands);
|
||||
expect(useCommandsStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import { useProjectsStore } from "@/stores/useProjectsStore";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
|
||||
export type CommandScope = 'user' | 'project';
|
||||
@@ -174,7 +175,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
configurableCommands.map(async (cmd) => {
|
||||
try {
|
||||
// Force no-cache
|
||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
...(directory ? { 'x-opencode-directory': directory } : {}),
|
||||
@@ -258,7 +259,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -319,7 +320,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -369,7 +370,7 @@ export const useCommandsStore = create<CommandsStore>()(
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE',
|
||||
headers: directory ? { 'x-opencode-directory': directory } : undefined,
|
||||
});
|
||||
@@ -510,7 +511,7 @@ export async function reloadOpenCodeConfiguration(options?: { message?: string;
|
||||
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/reload', {
|
||||
const response = await runtimeFetch('/api/config/reload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { updateDesktopSettings } from "@/lib/persistence";
|
||||
import { useDirectoryStore } from "@/stores/useDirectoryStore";
|
||||
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
||||
import { parseModelIdentifier } from "@/lib/modelIdentifier";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
||||
@@ -108,7 +109,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
|
||||
}
|
||||
|
||||
// 2. Fetch API (Web/server)
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -416,7 +417,9 @@ const fetchModelsDevMetadata = async (): Promise<Map<string, ModelMetadata>> =>
|
||||
requestInit.credentials = 'same-origin';
|
||||
}
|
||||
|
||||
const response = await fetch(source, requestInit);
|
||||
const response = isAbsoluteUrl
|
||||
? await fetch(source, requestInit)
|
||||
: await runtimeFetch(source, requestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Metadata request to ${source} returned status ${response.status}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GitHubAuthStatus, RuntimeAPIs } from '@/lib/api/types';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type GitHubAuthStatusWithError = GitHubAuthStatus & { error?: string };
|
||||
|
||||
@@ -22,7 +23,7 @@ const fetchStatus = async (
|
||||
return payload as GitHubAuthStatus;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/github/auth/status', {
|
||||
const response = await runtimeFetch('/api/github/auth/status', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/lib/gitApi";
|
||||
import { updateDesktopSettings } from "@/lib/persistence";
|
||||
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
export type GitIdentityAuthType = 'ssh' | 'token';
|
||||
|
||||
@@ -159,7 +160,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
|
||||
|
||||
if (defaultId === null) {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type McpScope = 'user' | 'project';
|
||||
|
||||
@@ -165,7 +166,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/mcp${queryParams}`, {
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -197,7 +198,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
const body = buildMcpBody(config);
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -251,7 +252,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
const body = buildMcpBody(config);
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -304,7 +305,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
|
||||
const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE',
|
||||
headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined,
|
||||
});
|
||||
|
||||
@@ -113,6 +113,11 @@ const requestBody = (callIndex: number): unknown => {
|
||||
return init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
};
|
||||
|
||||
const flushPluginFollowUps = async (): Promise<void> => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
describe('usePluginsStore', () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
@@ -125,6 +130,7 @@ describe('usePluginsStore', () => {
|
||||
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
|
||||
|
||||
const result = await usePluginsStore.getState().loadPlugins();
|
||||
await flushPluginFollowUps();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fetchCalls).toHaveLength(2);
|
||||
@@ -139,6 +145,7 @@ describe('usePluginsStore', () => {
|
||||
|
||||
await usePluginsStore.getState().loadPlugins();
|
||||
await usePluginsStore.getState().loadPlugins();
|
||||
await flushPluginFollowUps();
|
||||
|
||||
expect(fetchCalls).toHaveLength(2);
|
||||
});
|
||||
@@ -279,6 +286,7 @@ describe('usePluginsStore', () => {
|
||||
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
|
||||
|
||||
const result = await usePluginsStore.getState().loadPlugins();
|
||||
await flushPluginFollowUps();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fetchCalls[0]?.input).toBe('/api/config/plugins?directory=%2Fworkspace%2Fproject');
|
||||
@@ -289,6 +297,7 @@ describe('usePluginsStore', () => {
|
||||
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
|
||||
|
||||
const result = await usePluginsStore.getState().createEntry({ spec: 'new-plugin@1', scope: 'user' });
|
||||
await flushPluginFollowUps();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(registryCalls()).toHaveLength(1);
|
||||
@@ -301,6 +310,7 @@ describe('usePluginsStore', () => {
|
||||
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
|
||||
|
||||
const result = await usePluginsStore.getState().updateEntry(entry.id, { spec: 'plugin-b@2' });
|
||||
await flushPluginFollowUps();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(registryCalls()).toHaveLength(1);
|
||||
@@ -313,6 +323,7 @@ describe('usePluginsStore', () => {
|
||||
queueFetchResponses([jsonResponse(okMutationPayload), jsonResponse(pluginListPayload), jsonResponse({ results: [] })]);
|
||||
|
||||
const result = await usePluginsStore.getState().updateEntry(entry.id, { options: { enabled: true } });
|
||||
await flushPluginFollowUps();
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(String(registryCalls()[0]?.input)).toContain('specs=plugin-a');
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type PluginScope = 'user' | 'project';
|
||||
export type PluginParsedKind = 'npm' | 'path';
|
||||
@@ -171,7 +172,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const response = await fetch(buildPluginsUrl('/api/config/plugins', configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins', configDirectory), {
|
||||
headers: buildDirectoryHeaders(configDirectory),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -211,7 +212,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
const configDirectory = getConfigDirectory();
|
||||
const nextRegistryInfo: Record<string, RegistryResult> = { ...get().registryInfo };
|
||||
for (const chunk of chunkSpecs(specs)) {
|
||||
const response = await fetch(buildRegistryUrl(chunk, opts?.force === true, configDirectory), {
|
||||
const response = await runtimeFetch(buildRegistryUrl(chunk, opts?.force === true, configDirectory), {
|
||||
headers: buildDirectoryHeaders(configDirectory),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -241,7 +242,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
|
||||
createEntry: async (input) => {
|
||||
const result = await runPluginMutation('Creating plugin entry…', async (configDirectory) => {
|
||||
const response = await fetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins/entry', configDirectory), {
|
||||
method: 'POST',
|
||||
headers: buildJsonHeaders(configDirectory),
|
||||
body: JSON.stringify(buildEntryBody(input)),
|
||||
@@ -258,7 +259,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
const existingSpec = get().entries.find((plugin) => plugin.id === id)?.spec;
|
||||
const nextSpec = input.spec ?? existingSpec;
|
||||
const result = await runPluginMutation('Updating plugin entry…', async (configDirectory) => {
|
||||
const response = await fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
|
||||
method: 'PATCH',
|
||||
headers: buildJsonHeaders(configDirectory),
|
||||
body: JSON.stringify(buildEntryBody(input)),
|
||||
@@ -274,7 +275,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
deleteEntry: async (id) => {
|
||||
const entryToDelete = get().entries.find((plugin) => plugin.id === id);
|
||||
const result = await runPluginMutation('Deleting plugin entry…', async (configDirectory) => {
|
||||
const response = await fetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/entry/${encodeURIComponent(id)}`, configDirectory), {
|
||||
method: 'DELETE',
|
||||
headers: buildDirectoryHeaders(configDirectory),
|
||||
});
|
||||
@@ -295,7 +296,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
readFile: async (id) => {
|
||||
try {
|
||||
const configDirectory = getConfigDirectory();
|
||||
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
||||
headers: buildDirectoryHeaders(configDirectory),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -310,7 +311,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
|
||||
createFile: async (input) => {
|
||||
return runPluginMutation('Creating plugin file…', async (configDirectory) => {
|
||||
const response = await fetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl('/api/config/plugins/file', configDirectory), {
|
||||
method: 'POST',
|
||||
headers: buildJsonHeaders(configDirectory),
|
||||
body: JSON.stringify(input),
|
||||
@@ -321,7 +322,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
|
||||
updateFile: async (id, input) => {
|
||||
return runPluginMutation('Updating plugin file…', async (configDirectory) => {
|
||||
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
||||
method: 'PUT',
|
||||
headers: buildJsonHeaders(configDirectory),
|
||||
body: JSON.stringify(input),
|
||||
@@ -332,7 +333,7 @@ export const usePluginsStore = create<PluginsStore>()(
|
||||
|
||||
deleteFile: async (id) => {
|
||||
const result = await runPluginMutation('Deleting plugin file…', async (configDirectory) => {
|
||||
const response = await fetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
||||
const response = await runtimeFetch(buildPluginsUrl(`/api/config/plugins/file/${encodeURIComponent(id)}`, configDirectory), {
|
||||
method: 'DELETE',
|
||||
headers: buildDirectoryHeaders(configDirectory),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
@@ -10,6 +11,8 @@ import { useDirectoryStore } from './useDirectoryStore';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
import { PROJECT_COLORS } from '@/lib/projectMeta';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
|
||||
/** Pick a color key that's least used among existing projects */
|
||||
const pickAutoColor = (projects: ProjectEntry[]): string => {
|
||||
@@ -49,6 +52,7 @@ interface ProjectsStore {
|
||||
removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>;
|
||||
discoverProjectIcon: (id: string, options?: { force?: boolean }) => Promise<{ ok: boolean; skipped?: boolean; reason?: string; error?: string }>;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
resetForRuntimeSwitch: () => void;
|
||||
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
||||
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
||||
getActiveProject: () => ProjectEntry | null;
|
||||
@@ -58,6 +62,35 @@ const safeStorage = getSafeStorage();
|
||||
const PROJECTS_STORAGE_KEY = 'projects';
|
||||
const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId';
|
||||
|
||||
const getLocalRuntimeOrigin = (): string => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
const value = (window as typeof window & { __OPENCHAMBER_LOCAL_ORIGIN__?: string }).__OPENCHAMBER_LOCAL_ORIGIN__;
|
||||
return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '';
|
||||
};
|
||||
|
||||
const getProjectsStorageNamespace = (): string => {
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl().trim().replace(/\/+$/, '');
|
||||
if (!apiBaseUrl) return '';
|
||||
return apiBaseUrl;
|
||||
};
|
||||
|
||||
const getProjectsStorageKey = (): string => {
|
||||
const namespace = getProjectsStorageNamespace();
|
||||
return namespace ? `${PROJECTS_STORAGE_KEY}:${encodeURIComponent(namespace)}` : PROJECTS_STORAGE_KEY;
|
||||
};
|
||||
|
||||
const getActiveProjectStorageKey = (): string => {
|
||||
const namespace = getProjectsStorageNamespace();
|
||||
return namespace ? `${ACTIVE_PROJECT_STORAGE_KEY}:${encodeURIComponent(namespace)}` : ACTIVE_PROJECT_STORAGE_KEY;
|
||||
};
|
||||
|
||||
const shouldReadLegacyProjectsCache = (): boolean => {
|
||||
const namespace = getProjectsStorageNamespace();
|
||||
if (!namespace) return true;
|
||||
const localOrigin = getLocalRuntimeOrigin();
|
||||
return Boolean(localOrigin && namespace === localOrigin);
|
||||
};
|
||||
|
||||
const resolveTildePath = (value: string, homeDir?: string | null): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith('~')) {
|
||||
@@ -240,7 +273,8 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
||||
|
||||
const readPersistedProjects = (): ProjectEntry[] => {
|
||||
try {
|
||||
const raw = safeStorage.getItem(PROJECTS_STORAGE_KEY);
|
||||
const raw = safeStorage.getItem(getProjectsStorageKey())
|
||||
|| (shouldReadLegacyProjectsCache() ? safeStorage.getItem(PROJECTS_STORAGE_KEY) : null);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
@@ -252,7 +286,8 @@ const readPersistedProjects = (): ProjectEntry[] => {
|
||||
|
||||
const readPersistedActiveProjectId = (): string | null => {
|
||||
try {
|
||||
const raw = safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY);
|
||||
const raw = safeStorage.getItem(getActiveProjectStorageKey())
|
||||
|| (shouldReadLegacyProjectsCache() ? safeStorage.getItem(ACTIVE_PROJECT_STORAGE_KEY) : null);
|
||||
if (typeof raw === 'string' && raw.trim().length > 0) {
|
||||
return raw.trim();
|
||||
}
|
||||
@@ -264,16 +299,17 @@ const readPersistedActiveProjectId = (): string | null => {
|
||||
|
||||
const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
|
||||
try {
|
||||
safeStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects));
|
||||
safeStorage.setItem(getProjectsStorageKey(), JSON.stringify(projects));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
|
||||
try {
|
||||
const activeProjectStorageKey = getActiveProjectStorageKey();
|
||||
if (activeProjectId) {
|
||||
safeStorage.setItem(ACTIVE_PROJECT_STORAGE_KEY, activeProjectId);
|
||||
safeStorage.setItem(activeProjectStorageKey, activeProjectId);
|
||||
} else {
|
||||
safeStorage.removeItem(ACTIVE_PROJECT_STORAGE_KEY);
|
||||
safeStorage.removeItem(activeProjectStorageKey);
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
@@ -291,8 +327,7 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeApis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } })
|
||||
.__OPENCHAMBER_RUNTIME_APIS__;
|
||||
const runtimeApis = getRegisteredRuntimeAPIs();
|
||||
if (!runtimeApis?.runtime?.isVSCode) {
|
||||
return null;
|
||||
}
|
||||
@@ -539,7 +574,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
const normalizedDataUrl = dataUrl.replace(/^data:[^;]+;/i, `data:${mime};`);
|
||||
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon`, {
|
||||
const response = await runtimeFetch(`/api/projects/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -570,7 +605,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon`, {
|
||||
const response = await runtimeFetch(`/api/projects/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -599,7 +634,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${encodeURIComponent(id)}/icon/discover`, {
|
||||
const response = await runtimeFetch(`/api/projects/${encodeURIComponent(id)}/icon/discover`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -657,6 +692,18 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
persistProjects(nextProjects, activeProjectId);
|
||||
},
|
||||
|
||||
resetForRuntimeSwitch: () => {
|
||||
if (vscodeWorkspace) {
|
||||
return;
|
||||
}
|
||||
const projects = readPersistedProjects();
|
||||
const activeProjectId = readPersistedActiveProjectId();
|
||||
const nextActiveProjectId = projects.some((project) => project.id === activeProjectId)
|
||||
? activeProjectId
|
||||
: projects[0]?.id ?? null;
|
||||
set({ projects, activeProjectId: nextActiveProjectId });
|
||||
},
|
||||
|
||||
synchronizeFromSettings: (settings: DesktopSettings) => {
|
||||
if (vscodeWorkspace) {
|
||||
return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getDefaultModels } from '@/lib/quota/model-families';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 60000;
|
||||
|
||||
@@ -113,7 +114,7 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||
}
|
||||
|
||||
if (!isVSCodeRuntime()) {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
const response = await runtimeFetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
@@ -182,7 +183,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
|
||||
}));
|
||||
try {
|
||||
const response = await fetch(`/api/quota/${encodeURIComponent(providerId)}`);
|
||||
const response = await runtimeFetch(`/api/quota/${encodeURIComponent(providerId)}`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to fetch quota');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -90,7 +91,7 @@ const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds
|
||||
collapsedFolderIds: collapsedSnapshot,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
void fetch(SESSION_FOLDERS_API_PATH, {
|
||||
void runtimeFetch(SESSION_FOLDERS_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -502,7 +503,7 @@ const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
|
||||
diskHydrationInFlight = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(SESSION_FOLDERS_API_PATH).catch(() => null);
|
||||
const response = await runtimeFetch(SESSION_FOLDERS_API_PATH).catch(() => null);
|
||||
if (!response || !response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
|
||||
{
|
||||
@@ -150,7 +151,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config/skills/catalog${refresh}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog${refresh}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
@@ -227,7 +228,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
|
||||
: `?sourceId=${encodeURIComponent(sourceId)}${refresh}`;
|
||||
|
||||
const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -235,7 +236,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const payload = (await response.json().catch(() => null)) as SkillsCatalogSourceResponse | null;
|
||||
const hasItems = Array.isArray((payload as SkillsCatalogSourceResponse | null)?.items);
|
||||
if (!response.ok || (!payload?.ok && !hasItems)) {
|
||||
const fallback = await fetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
const fallback = await runtimeFetch(`/api/config/skills/catalog${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -302,7 +303,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
}
|
||||
const queryParams = `?${parts.join('&')}`;
|
||||
|
||||
const response = await fetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/catalog/source${queryParams}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -357,7 +358,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/scan${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/scan${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
@@ -393,7 +394,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
|
||||
const currentDirectory = directoryOverride ?? getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/install${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
updateConfigUpdateMessage,
|
||||
} from "@/lib/configUpdate";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
@@ -216,7 +217,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
try {
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills${queryParams}`);
|
||||
const response = await runtimeFetch(`/api/config/skills${queryParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list skills: ${response.status}`);
|
||||
}
|
||||
@@ -260,7 +261,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`);
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
@@ -288,7 +289,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(skillConfig)
|
||||
@@ -338,7 +339,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(skillConfig)
|
||||
@@ -381,7 +382,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
@@ -430,7 +431,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `&directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -449,7 +450,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -469,7 +470,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
const currentDirectory = getCurrentDirectory();
|
||||
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
|
||||
|
||||
const response = await fetch(
|
||||
const response = await runtimeFetch(
|
||||
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type { Snippet } from '@/types/snippet';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
|
||||
export type SnippetScope = 'global' | 'project';
|
||||
@@ -67,7 +68,7 @@ export const useSnippetsStore = create<SnippetsStore>()(
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await fetch(`/api/config/snippets${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/snippets${queryParams}`, {
|
||||
headers: { 'Cache-Control': 'no-cache', ...(directory ? { 'x-opencode-directory': directory } : {}) },
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to load snippets');
|
||||
@@ -94,7 +95,7 @@ export const useSnippetsStore = create<SnippetsStore>()(
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) },
|
||||
body: JSON.stringify({ content, aliases: options.aliases, description: options.description, scope: options.scope }),
|
||||
@@ -119,7 +120,7 @@ export const useSnippetsStore = create<SnippetsStore>()(
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) },
|
||||
body: JSON.stringify(updates),
|
||||
@@ -138,7 +139,7 @@ export const useSnippetsStore = create<SnippetsStore>()(
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await fetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/snippets/${encodeURIComponent(name)}${queryParams}`, {
|
||||
method: 'DELETE',
|
||||
headers: directory ? { 'x-opencode-directory': directory } : undefined,
|
||||
});
|
||||
@@ -157,7 +158,7 @@ export const useSnippetsStore = create<SnippetsStore>()(
|
||||
if (!/#[a-z0-9_-]+/i.test(text)) return text;
|
||||
const directory = getRequestDirectory();
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
const response = await fetch(`/api/config/snippets/expand${queryParams}`, {
|
||||
const response = await runtimeFetch(`/api/config/snippets/expand${queryParams}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(directory ? { 'x-opencode-directory': directory } : {}) },
|
||||
body: JSON.stringify({ text }),
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ShortcutCombo } from '@/lib/shortcuts';
|
||||
import type { DraftStarterRef } from '@/lib/draftStarters';
|
||||
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
|
||||
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context';
|
||||
export type RightSidebarTab = 'git' | 'files' | 'context';
|
||||
@@ -106,6 +107,12 @@ const CONTEXT_PANEL_MAX_TABS = 12;
|
||||
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
|
||||
const LEFT_SIDEBAR_MIN_WIDTH = 280;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 360;
|
||||
const activeMainTabByRuntime = new Map<string, MainTab>();
|
||||
|
||||
const runtimeMemoryKey = (value?: string | null): string => {
|
||||
const key = (value ?? getRuntimeKey()).trim();
|
||||
return key || 'default';
|
||||
};
|
||||
|
||||
const normalizeDirectoryPath = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -608,6 +615,8 @@ interface UIStore {
|
||||
showSplitAssistantMessageActions: boolean;
|
||||
showMobileSessionStatusBar: boolean;
|
||||
isMobileSessionStatusBarCollapsed: boolean;
|
||||
mobileSessionPanelOpen: boolean;
|
||||
mobileSessionFilterProjectId: string | null;
|
||||
isExpandedInput: boolean;
|
||||
reportUsage: boolean;
|
||||
shortcutOverrides: Record<string, ShortcutCombo>;
|
||||
@@ -644,6 +653,8 @@ interface UIStore {
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setSessionDropdownOpen: (open: boolean) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
prepareForRuntimeSwitch: (runtimeKey?: string | null) => void;
|
||||
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
|
||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
|
||||
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
||||
@@ -742,6 +753,8 @@ interface UIStore {
|
||||
setShowSplitAssistantMessageActions: (value: boolean) => void;
|
||||
setShowMobileSessionStatusBar: (value: boolean) => void;
|
||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||
setMobileSessionPanelOpen: (value: boolean) => void;
|
||||
setMobileSessionFilterProjectId: (value: string | null) => void;
|
||||
viewPagerPage: 'left' | 'center' | 'right';
|
||||
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
|
||||
toggleExpandedInput: () => void;
|
||||
@@ -873,6 +886,8 @@ export const useUIStore = create<UIStore>()(
|
||||
showSplitAssistantMessageActions: false,
|
||||
showMobileSessionStatusBar: false,
|
||||
isMobileSessionStatusBarCollapsed: false,
|
||||
mobileSessionPanelOpen: false,
|
||||
mobileSessionFilterProjectId: null,
|
||||
isExpandedInput: false,
|
||||
reportUsage: true,
|
||||
shortcutOverrides: {},
|
||||
@@ -1348,9 +1363,19 @@ export const useUIStore = create<UIStore>()(
|
||||
if (guard && !guard(tab)) {
|
||||
return;
|
||||
}
|
||||
activeMainTabByRuntime.set(runtimeMemoryKey(), tab);
|
||||
set({ activeMainTab: tab });
|
||||
},
|
||||
|
||||
prepareForRuntimeSwitch: (runtimeKey?: string | null) => {
|
||||
activeMainTabByRuntime.set(runtimeMemoryKey(runtimeKey), get().activeMainTab);
|
||||
},
|
||||
|
||||
restoreForRuntimeSwitch: (runtimeKey?: string | null) => {
|
||||
const restored = activeMainTabByRuntime.get(runtimeMemoryKey(runtimeKey)) ?? 'chat';
|
||||
set({ activeMainTab: restored });
|
||||
},
|
||||
|
||||
setPendingDiffFile: (filePath, staged = false) => {
|
||||
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
|
||||
},
|
||||
@@ -1949,6 +1974,12 @@ export const useUIStore = create<UIStore>()(
|
||||
setIsMobileSessionStatusBarCollapsed: (value) => {
|
||||
set({ isMobileSessionStatusBarCollapsed: value });
|
||||
},
|
||||
setMobileSessionPanelOpen: (value) => {
|
||||
set({ mobileSessionPanelOpen: value });
|
||||
},
|
||||
setMobileSessionFilterProjectId: (value) => {
|
||||
set({ mobileSessionFilterProjectId: value });
|
||||
},
|
||||
setReportUsage: (value) => {
|
||||
set({ reportUsage: value });
|
||||
},
|
||||
@@ -2162,6 +2193,7 @@ export const useUIStore = create<UIStore>()(
|
||||
showSplitAssistantMessageActions: state.showSplitAssistantMessageActions,
|
||||
showMobileSessionStatusBar: state.showMobileSessionStatusBar,
|
||||
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||
mobileSessionFilterProjectId: state.mobileSessionFilterProjectId,
|
||||
shortcutOverrides: state.shortcutOverrides,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isVSCodeRuntime,
|
||||
isWebRuntime,
|
||||
} from '@/lib/desktop';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type UpdateState = {
|
||||
checking: boolean;
|
||||
@@ -106,7 +107,7 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin
|
||||
: undefined;
|
||||
if (currentVersion) params.set('currentVersion', currentVersion);
|
||||
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
|
||||
const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, {
|
||||
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
@@ -136,9 +137,7 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin
|
||||
|
||||
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
|
||||
if (isTauriShell()) {
|
||||
// Only use Tauri updater when we're on the local instance.
|
||||
// When viewing a remote host inside the desktop shell, treat update as web update.
|
||||
return isDesktopLocalOriginActive() ? 'desktop' : 'web';
|
||||
return 'desktop';
|
||||
}
|
||||
if (isVSCodeRuntime()) return 'vscode';
|
||||
if (isWebRuntime()) return 'web';
|
||||
@@ -172,7 +171,7 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
|
||||
let suggestedSec: number | null = null;
|
||||
|
||||
if (runtime === 'desktop') {
|
||||
let desktopInfo = await checkForDesktopUpdates();
|
||||
const desktopInfo = await checkForDesktopUpdates();
|
||||
set({
|
||||
checking: false,
|
||||
available: desktopInfo?.available ?? false,
|
||||
@@ -181,33 +180,6 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
|
||||
nextCheckInSec: null,
|
||||
});
|
||||
|
||||
const sidecarInfo = await checkForWebUpdates('desktop', desktopInfo?.currentVersion);
|
||||
suggestedSec = sidecarInfo?.nextSuggestedCheckInSec ?? null;
|
||||
|
||||
if (sidecarInfo?.available && !desktopInfo?.available) {
|
||||
const forcedDesktopInfo = await checkForDesktopUpdates();
|
||||
if (forcedDesktopInfo) {
|
||||
desktopInfo = forcedDesktopInfo;
|
||||
}
|
||||
}
|
||||
|
||||
if (sidecarInfo) {
|
||||
const mergedInfo: UpdateInfo = {
|
||||
...(desktopInfo ?? { available: false, currentVersion: sidecarInfo.currentVersion ?? 'unknown' }),
|
||||
...sidecarInfo,
|
||||
currentVersion: desktopInfo?.currentVersion ?? sidecarInfo.currentVersion ?? 'unknown',
|
||||
available: sidecarInfo.available,
|
||||
};
|
||||
|
||||
set({
|
||||
available: mergedInfo.available,
|
||||
info: mergedInfo,
|
||||
nextCheckInSec: suggestedSec,
|
||||
});
|
||||
} else {
|
||||
set({ nextCheckInSec: suggestedSec });
|
||||
}
|
||||
|
||||
return suggestedSec;
|
||||
} else if (runtime === 'web') {
|
||||
info = await checkForWebUpdates('web');
|
||||
|
||||
Reference in New Issue
Block a user