feat: add copy diagnostics button
Add copy diagnostics button in About dialog. Button copies report with OpenChamber state, OpenCode health, directories, and projects. Show success or error toasts after copying attempt.
This commit is contained in:
@@ -138,6 +138,7 @@ export const AboutSettings: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Desktop layout (unchanged)
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
@@ -200,6 +201,7 @@ export const AboutSettings: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{/* Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
|
||||
@@ -122,6 +122,7 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
||||
<AboutSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { RiDiscordFill, RiGithubFill, RiTwitterXFill } from '@remixicon/react';
|
||||
import { debugUtils } from '@/lib/debug';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
declare const __APP_VERSION__: string | undefined;
|
||||
|
||||
@@ -18,6 +21,28 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const [version, setVersion] = React.useState<string | null>(null);
|
||||
const [isCopyingDiagnostics, setIsCopyingDiagnostics] = React.useState(false);
|
||||
const [copiedDiagnostics, setCopiedDiagnostics] = React.useState(false);
|
||||
|
||||
const handleCopyDiagnostics = React.useCallback(async () => {
|
||||
if (isCopyingDiagnostics) return;
|
||||
setIsCopyingDiagnostics(true);
|
||||
setCopiedDiagnostics(false);
|
||||
try {
|
||||
const result = await debugUtils.copyDiagnosticsReport();
|
||||
if (result.ok) {
|
||||
setCopiedDiagnostics(true);
|
||||
toast.success('Diagnostics copied');
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Copy failed');
|
||||
console.error('Failed to copy diagnostics:', error);
|
||||
} finally {
|
||||
setIsCopyingDiagnostics(false);
|
||||
}
|
||||
}, [isCopyingDiagnostics]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -70,6 +95,23 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
|
||||
agent
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleCopyDiagnostics}
|
||||
disabled={isCopyingDiagnostics}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground hover:text-foreground',
|
||||
'underline-offset-2 hover:underline',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{copiedDiagnostics ? 'Diagnostics copied' : 'Copy diagnostics'}
|
||||
</button>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Includes OpenChamber state, OpenCode health, directories, and projects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<a
|
||||
href="https://github.com/btriapitsyn/openchamber"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
@@ -176,6 +177,7 @@ export const debugUtils = {
|
||||
async getAppStatus() {
|
||||
const directoryState = useDirectoryStore.getState();
|
||||
const sessionState = useSessionStore.getState();
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const currentDirectory = directoryState.currentDirectory || null;
|
||||
const opencodeDirectory = opencodeClient.getDirectory() ?? null;
|
||||
|
||||
@@ -291,9 +293,17 @@ export const debugUtils = {
|
||||
}
|
||||
}
|
||||
|
||||
const projectSamples = projectsState.projects.map((project) => ({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
label: project.label,
|
||||
}));
|
||||
|
||||
const report = {
|
||||
runtime: {
|
||||
platform: runtimeApis?.runtime?.platform ?? null,
|
||||
isDesktop: isDesktopRuntime,
|
||||
isVSCode: Boolean(runtimeApis?.runtime?.isVSCode),
|
||||
hasRuntimeApis: Boolean(runtimeApis),
|
||||
desktopServerOrigin: desktopServer?.origin ?? null,
|
||||
},
|
||||
@@ -313,6 +323,11 @@ export const debugUtils = {
|
||||
hasPersistedDirectory: directoryState.hasPersistedDirectory,
|
||||
isSwitchingDirectory: directoryState.isSwitchingDirectory,
|
||||
},
|
||||
projects: {
|
||||
total: projectsState.projects.length,
|
||||
activeProjectId: projectsState.activeProjectId,
|
||||
samples: projectSamples,
|
||||
},
|
||||
sessions: {
|
||||
total: sessions.length,
|
||||
currentSessionId: sessionState.currentSessionId,
|
||||
@@ -341,6 +356,20 @@ export const debugUtils = {
|
||||
return report;
|
||||
},
|
||||
|
||||
async buildDiagnosticsReport() {
|
||||
const report = await this.getAppStatus();
|
||||
return JSON.stringify(report, null, 2);
|
||||
},
|
||||
|
||||
async copyDiagnosticsReport() {
|
||||
const report = await this.buildDiagnosticsReport();
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(report);
|
||||
return { ok: true, report } as const;
|
||||
}
|
||||
return { ok: false, report } as const;
|
||||
},
|
||||
|
||||
checkLastMessage() {
|
||||
const info = this.getLastAssistantMessage();
|
||||
if (!info) return false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
export const applyPersistedDirectoryPreferences = async (): Promise<void> => {
|
||||
@@ -21,7 +22,7 @@ export const applyPersistedDirectoryPreferences = async (): Promise<void> => {
|
||||
directoryStore.synchronizeHomeDirectory(savedHome);
|
||||
}
|
||||
|
||||
if (savedDirectory) {
|
||||
if (savedDirectory && !isVSCodeRuntime()) {
|
||||
directoryStore.setDirectory(savedDirectory, { showOverlay: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { getDesktopHomeDirectory } from '@/lib/desktop';
|
||||
import { getDesktopHomeDirectory, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||
@@ -76,7 +76,7 @@ const getHomeDirectory = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedHome = safeStorage.getItem('homeDirectory') || cachedHomeDirectory || null;
|
||||
const saved = safeStorage.getItem('lastDirectory');
|
||||
if (saved) {
|
||||
if (saved && !isVSCodeRuntime()) {
|
||||
return resolveDirectoryPath(saved, storedHome);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ const getHomeDirectory = () => {
|
||||
return desktopHome;
|
||||
}
|
||||
|
||||
if (storedHome) {
|
||||
if (storedHome && !isVSCodeRuntime()) {
|
||||
cachedHomeDirectory = storedHome;
|
||||
return storedHome;
|
||||
}
|
||||
@@ -187,7 +187,19 @@ const initializeHomeDirectory = async () => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const initialHomeDirectory = getHomeDirectory();
|
||||
const getVsCodeWorkspaceFolder = (): string | null => {
|
||||
if (!isVSCodeRuntime()) {
|
||||
return null;
|
||||
}
|
||||
const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
|
||||
if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeDirectoryPath(workspaceFolder);
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
};
|
||||
|
||||
const initialHomeDirectory = getVsCodeWorkspaceFolder() || getHomeDirectory();
|
||||
if (initialHomeDirectory) {
|
||||
opencodeClient.setDirectory(initialHomeDirectory);
|
||||
}
|
||||
|
||||
@@ -342,12 +342,22 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const extensionVersion = String(context.extension?.packageJSON?.version || '');
|
||||
const workspaceFolders = (vscode.workspace.workspaceFolders || []).map((folder) => folder.uri.fsPath);
|
||||
const primaryWorkspace = workspaceFolders[0] || '';
|
||||
|
||||
const debug = openCodeManager?.getDebugInfo();
|
||||
const resolvedApiUrl = openCodeManager?.getApiUrl();
|
||||
const workingDirectory = openCodeManager?.getWorkingDirectory() ?? '';
|
||||
const workingDirectoryMatchesWorkspace = Boolean(primaryWorkspace && workingDirectory === primaryWorkspace);
|
||||
let resolvedApiPath = '';
|
||||
if (resolvedApiUrl) {
|
||||
try {
|
||||
resolvedApiPath = new URL(resolvedApiUrl).pathname || '/';
|
||||
} catch {
|
||||
resolvedApiPath = '(invalid url)';
|
||||
}
|
||||
}
|
||||
|
||||
const safeFetch = async (input: string, timeoutMs = 2500) => {
|
||||
const safeFetch = async (input: string, timeoutMs = 6000) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
@@ -407,12 +417,12 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
{ label: 'config', path: '/config', includeDirectory: true },
|
||||
{ label: 'providers', path: '/config/providers', includeDirectory: true },
|
||||
// Can be slower on large configs; keep the probe from producing false negatives.
|
||||
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 8000 },
|
||||
{ label: 'commands', path: '/command', includeDirectory: true },
|
||||
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
|
||||
{ label: 'commands', path: '/command', includeDirectory: true, timeoutMs: 10000 },
|
||||
{ label: 'project', path: '/project/current', includeDirectory: true },
|
||||
{ label: 'path', path: '/path', includeDirectory: true },
|
||||
// Session listing is what powers the sidebar. This helps diagnose "no sessions shown" bugs.
|
||||
{ label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 8000 },
|
||||
{ label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 12000 },
|
||||
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
|
||||
];
|
||||
|
||||
@@ -439,9 +449,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
`Platform: ${process.platform} ${process.arch}`,
|
||||
`Workspace folders: ${workspaceFolders.length}${workspaceFolders.length ? ` (${workspaceFolders.join(', ')})` : ''}`,
|
||||
`Status: ${openCodeManager?.getStatus() ?? 'unknown'}`,
|
||||
`Working directory: ${openCodeManager?.getWorkingDirectory() ?? ''}`,
|
||||
`Working directory: ${workingDirectory}`,
|
||||
`Working dir matches workspace: ${workingDirectoryMatchesWorkspace ? 'yes' : 'no'}`,
|
||||
`API URL (configured): ${configuredApiUrl || '(none)'}`,
|
||||
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
|
||||
`API URL path: ${resolvedApiPath || '(none)'}`,
|
||||
debug
|
||||
? `OpenCode server URL: ${debug.serverUrl ?? '(none)'}`
|
||||
: `OpenCode server URL: (unknown)`,
|
||||
@@ -460,6 +472,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
debug
|
||||
? `Last start: ${formatIso(debug.lastStartAt)}`
|
||||
: `Last start: (unknown)`,
|
||||
debug
|
||||
? `Last ready: ${debug.lastReadyElapsedMs !== null ? `${debug.lastReadyElapsedMs}ms` : '(unknown)'}`
|
||||
: `Last ready: (unknown)`,
|
||||
debug
|
||||
? `Ready attempts: ${debug.lastReadyAttempts ?? '(unknown)'}`
|
||||
: `Ready attempts: (unknown)`,
|
||||
debug
|
||||
? `Start attempts: ${debug.lastStartAttempts ?? '(unknown)'}`
|
||||
: `Start attempts: (unknown)`,
|
||||
debug
|
||||
? `Last connected: ${formatIso(debug.lastConnectedAt)}`
|
||||
: `Last connected: (unknown)`,
|
||||
|
||||
@@ -25,6 +25,9 @@ export type OpenCodeDebugInfo = {
|
||||
lastConnectedAt: number | null;
|
||||
lastExitCode: number | null;
|
||||
serverUrl: string | null;
|
||||
lastReadyElapsedMs: number | null;
|
||||
lastReadyAttempts: number | null;
|
||||
lastStartAttempts: number | null;
|
||||
};
|
||||
|
||||
export interface OpenCodeManager {
|
||||
@@ -49,7 +52,9 @@ function resolvePortFromUrl(url: string): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
type ReadyResult = { ok: true; baseUrl: string } | { ok: false };
|
||||
type ReadyResult =
|
||||
| { ok: true; baseUrl: string; elapsedMs: number; attempts: number }
|
||||
| { ok: false; elapsedMs: number; attempts: number };
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
@@ -83,9 +88,11 @@ function getCandidateBaseUrls(serverUrl: string): string[] {
|
||||
async function waitForReady(serverUrl: string, timeoutMs = 5000, workingDirectory = ''): Promise<ReadyResult> {
|
||||
const start = Date.now();
|
||||
const candidates = getCandidateBaseUrls(serverUrl);
|
||||
let attempts = 0;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
for (const baseUrl of candidates) {
|
||||
attempts += 1;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
@@ -102,7 +109,9 @@ async function waitForReady(serverUrl: string, timeoutMs = 5000, workingDirector
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
if (res.ok) return { ok: true, baseUrl };
|
||||
if (res.ok) {
|
||||
return { ok: true, baseUrl, elapsedMs: Date.now() - start, attempts };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -111,20 +120,11 @@ async function waitForReady(serverUrl: string, timeoutMs = 5000, workingDirector
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
return { ok: false };
|
||||
return { ok: false, elapsedMs: Date.now() - start, attempts };
|
||||
}
|
||||
|
||||
function inferApiPrefixFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const pathname = parsed.pathname;
|
||||
if (pathname === '/' || pathname === '') {
|
||||
return '';
|
||||
}
|
||||
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
function inferApiPrefixFromUrl(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
||||
@@ -135,16 +135,19 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
let lastError: string | undefined;
|
||||
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
||||
let workingDirectory: string = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const workspaceDirectory = (): string =>
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
let workingDirectory: string = workspaceDirectory();
|
||||
let startCount = 0;
|
||||
let restartCount = 0;
|
||||
let lastStartAt: number | null = null;
|
||||
let lastConnectedAt: number | null = null;
|
||||
let lastExitCode: number | null = null;
|
||||
let lastReadyElapsedMs: number | null = null;
|
||||
let lastReadyAttempts: number | null = null;
|
||||
let lastStartAttempts: number | null = null;
|
||||
|
||||
let detectedPort: number | null = null;
|
||||
let apiPrefix: string = '';
|
||||
let apiPrefixDetected = false;
|
||||
let cliMissing = false;
|
||||
|
||||
let pendingOperation: Promise<void> | null = null;
|
||||
@@ -187,17 +190,21 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
return server.url.replace(/\/+$/, '');
|
||||
}
|
||||
if (detectedPort) {
|
||||
return `http://127.0.0.1:${detectedPort}${apiPrefix}`;
|
||||
return `http://127.0.0.1:${detectedPort}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
async function startInternal(workdir?: string): Promise<void> {
|
||||
startCount += 1;
|
||||
setStatus('connecting');
|
||||
lastStartAt = Date.now();
|
||||
lastStartAttempts = startCount;
|
||||
|
||||
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
||||
workingDirectory = workdir.trim();
|
||||
} else {
|
||||
workingDirectory = workspaceDirectory();
|
||||
}
|
||||
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
@@ -218,8 +225,6 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
cliMissing = false;
|
||||
|
||||
detectedPort = null;
|
||||
apiPrefix = '';
|
||||
apiPrefixDetected = false;
|
||||
lastExitCode = null;
|
||||
managedApiUrlOverride = null;
|
||||
|
||||
@@ -247,11 +252,11 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
if (server && server.url) {
|
||||
// Validate readiness for the current workspace context.
|
||||
const ready = await waitForReady(server.url, 10000, workingDirectory);
|
||||
lastReadyElapsedMs = ready.elapsedMs;
|
||||
lastReadyAttempts = ready.attempts;
|
||||
if (ready.ok) {
|
||||
managedApiUrlOverride = ready.baseUrl;
|
||||
detectedPort = resolvePortFromUrl(ready.baseUrl);
|
||||
apiPrefix = inferApiPrefixFromUrl(ready.baseUrl);
|
||||
apiPrefixDetected = apiPrefix.length > 0;
|
||||
setStatus('connected');
|
||||
} else {
|
||||
try {
|
||||
@@ -341,6 +346,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
return;
|
||||
}
|
||||
}
|
||||
lastStartAttempts = 1;
|
||||
pendingOperation = startInternal(workdir);
|
||||
try {
|
||||
await pendingOperation;
|
||||
@@ -369,6 +375,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
if (pendingOperation) {
|
||||
await pendingOperation;
|
||||
}
|
||||
lastStartAttempts = 1;
|
||||
pendingOperation = restartInternal();
|
||||
try {
|
||||
await pendingOperation;
|
||||
@@ -378,18 +385,21 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
}
|
||||
|
||||
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
||||
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workingDirectory;
|
||||
if (target === workingDirectory) {
|
||||
return { success: true, restarted: false, path: target };
|
||||
const target = typeof newPath === 'string' && newPath.trim().length > 0 ? newPath.trim() : workspaceDirectory();
|
||||
const workspacePath = workspaceDirectory();
|
||||
const nextDirectory = workspacePath;
|
||||
|
||||
if (workingDirectory === nextDirectory) {
|
||||
return { success: true, restarted: false, path: nextDirectory };
|
||||
}
|
||||
|
||||
workingDirectory = target;
|
||||
workingDirectory = nextDirectory;
|
||||
|
||||
if (useConfiguredUrl && configuredApiUrl) {
|
||||
return { success: true, restarted: false, path: target };
|
||||
return { success: true, restarted: false, path: nextDirectory };
|
||||
}
|
||||
|
||||
return { success: true, restarted: false, path: target };
|
||||
return { success: true, restarted: false, path: nextDirectory };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -411,14 +421,17 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
|
||||
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
||||
configuredPort,
|
||||
detectedPort,
|
||||
apiPrefix,
|
||||
apiPrefixDetected,
|
||||
apiPrefix: '',
|
||||
apiPrefixDetected: true,
|
||||
startCount,
|
||||
restartCount,
|
||||
lastStartAt,
|
||||
lastConnectedAt,
|
||||
lastExitCode,
|
||||
serverUrl: getApiUrl(),
|
||||
lastReadyElapsedMs,
|
||||
lastReadyAttempts,
|
||||
lastStartAttempts,
|
||||
}),
|
||||
onStatusChange(callback) {
|
||||
listeners.add(callback);
|
||||
|
||||
+73
-292
@@ -875,10 +875,8 @@ let expressApp = null;
|
||||
let currentRestartPromise = null;
|
||||
let isRestartingOpenCode = false;
|
||||
let openCodeApiPrefix = '';
|
||||
let openCodeApiPrefixDetected = false;
|
||||
let openCodeApiPrefixDetected = true;
|
||||
let openCodeApiDetectionTimer = null;
|
||||
let isDetectingApiPrefix = false;
|
||||
let openCodeApiDetectionPromise = null;
|
||||
let lastOpenCodeError = null;
|
||||
let isOpenCodeReady = false;
|
||||
let openCodeNotReadySince = 0;
|
||||
@@ -949,10 +947,8 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix(
|
||||
process.env.OPENCODE_API_PREFIX || process.env.OPENCHAMBER_API_PREFIX || ''
|
||||
);
|
||||
|
||||
if (ENV_CONFIGURED_API_PREFIX) {
|
||||
openCodeApiPrefix = ENV_CONFIGURED_API_PREFIX;
|
||||
openCodeApiPrefixDetected = true;
|
||||
console.log(`Using OpenCode API prefix from environment: ${openCodeApiPrefix}`);
|
||||
if (ENV_CONFIGURED_API_PREFIX && ENV_CONFIGURED_API_PREFIX !== '') {
|
||||
console.warn('Ignoring configured OpenCode API prefix; API runs at root.');
|
||||
}
|
||||
|
||||
function setOpenCodePort(port) {
|
||||
@@ -1041,7 +1037,7 @@ function buildAugmentedPath() {
|
||||
return Array.from(augmented).join(path.delimiter);
|
||||
}
|
||||
|
||||
const API_PREFIX_CANDIDATES = ['', '/api']; // Simplified - only check root and /api
|
||||
const API_PREFIX_CANDIDATES = [''];
|
||||
|
||||
async function waitForReady(url, timeoutMs = 10000) {
|
||||
const start = Date.now();
|
||||
@@ -1086,36 +1082,17 @@ function normalizeApiPrefix(prefix) {
|
||||
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
|
||||
}
|
||||
|
||||
function setDetectedOpenCodeApiPrefix(prefix) {
|
||||
const normalized = normalizeApiPrefix(prefix);
|
||||
if (!openCodeApiPrefixDetected || openCodeApiPrefix !== normalized) {
|
||||
openCodeApiPrefix = normalized;
|
||||
openCodeApiPrefixDetected = true;
|
||||
if (openCodeApiDetectionTimer) {
|
||||
clearTimeout(openCodeApiDetectionTimer);
|
||||
openCodeApiDetectionTimer = null;
|
||||
}
|
||||
console.log(`Detected OpenCode API prefix: ${normalized || '(root)'}`);
|
||||
function setDetectedOpenCodeApiPrefix() {
|
||||
openCodeApiPrefix = '';
|
||||
openCodeApiPrefixDetected = true;
|
||||
if (openCodeApiDetectionTimer) {
|
||||
clearTimeout(openCodeApiDetectionTimer);
|
||||
openCodeApiDetectionTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCandidateApiPrefixes() {
|
||||
if (openCodeApiPrefixDetected) {
|
||||
return [openCodeApiPrefix];
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
if (openCodeApiPrefix && !candidates.includes(openCodeApiPrefix)) {
|
||||
candidates.push(openCodeApiPrefix);
|
||||
}
|
||||
|
||||
for (const candidate of API_PREFIX_CANDIDATES) {
|
||||
if (!candidates.includes(candidate)) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
return API_PREFIX_CANDIDATES;
|
||||
}
|
||||
|
||||
function buildOpenCodeUrl(path, prefixOverride) {
|
||||
@@ -1123,9 +1100,7 @@ function buildOpenCodeUrl(path, prefixOverride) {
|
||||
throw new Error('OpenCode port is not available');
|
||||
}
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const prefix = normalizeApiPrefix(
|
||||
prefixOverride !== undefined ? prefixOverride : openCodeApiPrefixDetected ? openCodeApiPrefix : ''
|
||||
);
|
||||
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
|
||||
const fullPath = `${prefix}${normalizedPath}`;
|
||||
return `http://localhost:${openCodePort}${fullPath}`;
|
||||
}
|
||||
@@ -1214,165 +1189,22 @@ function writeSseEvent(res, payload) {
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function extractApiPrefixFromUrl(urlString, expectedSuffix) {
|
||||
if (!urlString) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
const pathname = parsed.pathname || '';
|
||||
if (expectedSuffix && pathname.endsWith(expectedSuffix)) {
|
||||
const prefix = pathname.slice(0, pathname.length - expectedSuffix.length);
|
||||
return normalizeApiPrefix(prefix);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse OpenCode URL "${urlString}": ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
function extractApiPrefixFromUrl() {
|
||||
return '';
|
||||
}
|
||||
|
||||
async function tryDetectOpenCodeApiPrefix() {
|
||||
if (!openCodePort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const docPrefix = await detectPrefixFromDocumentation();
|
||||
if (docPrefix !== null) {
|
||||
setDetectedOpenCodeApiPrefix(docPrefix);
|
||||
return true;
|
||||
}
|
||||
|
||||
const candidates = getCandidateApiPrefixes();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const response = await fetch(buildOpenCodeUrl('/config', candidate), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await response.json().catch(() => null);
|
||||
setDetectedOpenCodeApiPrefix(candidate);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
function detectOpenCodeApiPrefix() {
|
||||
openCodeApiPrefixDetected = true;
|
||||
openCodeApiPrefix = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function detectOpenCodeApiPrefix() {
|
||||
if (openCodeApiPrefixDetected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!openCodePort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDetectingApiPrefix) {
|
||||
try {
|
||||
await openCodeApiDetectionPromise;
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
return openCodeApiPrefixDetected;
|
||||
}
|
||||
|
||||
isDetectingApiPrefix = true;
|
||||
openCodeApiDetectionPromise = (async () => {
|
||||
const success = await tryDetectOpenCodeApiPrefix();
|
||||
if (!success) {
|
||||
console.warn('Failed to detect OpenCode API prefix via documentation or known candidates');
|
||||
}
|
||||
return success;
|
||||
})();
|
||||
|
||||
try {
|
||||
const result = await openCodeApiDetectionPromise;
|
||||
return result;
|
||||
} finally {
|
||||
isDetectingApiPrefix = false;
|
||||
openCodeApiDetectionPromise = null;
|
||||
}
|
||||
function ensureOpenCodeApiPrefix() {
|
||||
return detectOpenCodeApiPrefix();
|
||||
}
|
||||
|
||||
async function ensureOpenCodeApiPrefix() {
|
||||
if (openCodeApiPrefixDetected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await detectOpenCodeApiPrefix();
|
||||
if (!result) {
|
||||
scheduleOpenCodeApiDetection();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function scheduleOpenCodeApiDetection(delayMs = 500) {
|
||||
if (openCodeApiPrefixDetected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openCodeApiDetectionTimer) {
|
||||
clearTimeout(openCodeApiDetectionTimer);
|
||||
}
|
||||
|
||||
openCodeApiDetectionTimer = setTimeout(async () => {
|
||||
openCodeApiDetectionTimer = null;
|
||||
const success = await detectOpenCodeApiPrefix();
|
||||
if (!success) {
|
||||
const nextDelay = Math.min(delayMs * 2, 8000);
|
||||
scheduleOpenCodeApiDetection(nextDelay);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
const OPENAPI_DOC_PATHS = ['/doc'];
|
||||
|
||||
function extractPrefixFromOpenApiDocument(content) {
|
||||
|
||||
const globalMatch = content.match(/__OPENCODE_API_BASE__\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (globalMatch && globalMatch[1]) {
|
||||
return normalizeApiPrefix(globalMatch[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detectPrefixFromDocumentation() {
|
||||
if (!openCodePort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefixesToTry = [...new Set(['', ...API_PREFIX_CANDIDATES])];
|
||||
|
||||
for (const prefix of prefixesToTry) {
|
||||
for (const docPath of OPENAPI_DOC_PATHS) {
|
||||
try {
|
||||
const response = await fetch(buildOpenCodeUrl(docPath, prefix), {
|
||||
method: 'GET',
|
||||
headers: { Accept: '*/*' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const extracted = extractPrefixFromOpenApiDocument(text);
|
||||
if (extracted !== null) {
|
||||
return extracted;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
function scheduleOpenCodeApiDetection() {
|
||||
return;
|
||||
}
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
@@ -1545,13 +1377,12 @@ async function restartOpenCode() {
|
||||
syncToHmrState();
|
||||
}
|
||||
|
||||
// Reset detection state
|
||||
openCodeApiPrefixDetected = false;
|
||||
openCodeApiPrefixDetected = true;
|
||||
openCodeApiPrefix = '';
|
||||
if (openCodeApiDetectionTimer) {
|
||||
clearTimeout(openCodeApiDetectionTimer);
|
||||
openCodeApiDetectionTimer = null;
|
||||
}
|
||||
openCodeApiDetectionPromise = null;
|
||||
|
||||
lastOpenCodeError = null;
|
||||
openCodeProcess = await startOpenCode();
|
||||
@@ -1573,7 +1404,8 @@ async function restartOpenCode() {
|
||||
openCodePort = null;
|
||||
syncToHmrState();
|
||||
}
|
||||
openCodeApiPrefixDetected = false;
|
||||
openCodeApiPrefixDetected = true;
|
||||
openCodeApiPrefix = '';
|
||||
throw error;
|
||||
} finally {
|
||||
currentRestartPromise = null;
|
||||
@@ -1590,74 +1422,51 @@ async function waitForOpenCodeReady(timeoutMs = 20000, intervalMs = 400) {
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const prefixes = getCandidateApiPrefixes();
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
try {
|
||||
const normalizedPrefix = normalizeApiPrefix(prefix);
|
||||
|
||||
const configPromise = fetch(buildOpenCodeUrl('/config', normalizedPrefix), {
|
||||
try {
|
||||
const [configResult, agentResult] = await Promise.all([
|
||||
fetch(buildOpenCodeUrl('/config', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
}).catch((error) => error);
|
||||
|
||||
const agentPromise = fetch(buildOpenCodeUrl('/agent', normalizedPrefix), {
|
||||
}).catch((error) => error),
|
||||
fetch(buildOpenCodeUrl('/agent', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
}).catch((error) => error);
|
||||
}).catch((error) => error)
|
||||
]);
|
||||
|
||||
const [configResult, agentResult] = await Promise.all([configPromise, agentPromise]);
|
||||
|
||||
if (configResult instanceof Error) {
|
||||
lastError = configResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!configResult.ok) {
|
||||
if (configResult.status === 404 && !openCodeApiPrefixDetected && normalizedPrefix === '') {
|
||||
lastError = new Error('OpenCode config endpoint returned 404 on root prefix');
|
||||
} else {
|
||||
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await configResult.json().catch(() => null);
|
||||
const detectedPrefix = extractApiPrefixFromUrl(configResult.url, '/config');
|
||||
if (detectedPrefix !== null) {
|
||||
setDetectedOpenCodeApiPrefix(detectedPrefix);
|
||||
} else if (normalizedPrefix) {
|
||||
setDetectedOpenCodeApiPrefix(normalizedPrefix);
|
||||
}
|
||||
|
||||
if (agentResult instanceof Error) {
|
||||
lastError = agentResult;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentResult.ok) {
|
||||
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await agentResult.json().catch(() => []);
|
||||
|
||||
const effectivePrefix = detectedPrefix !== null ? detectedPrefix : normalizedPrefix;
|
||||
if (detectedPrefix === null) {
|
||||
const agentPrefix = extractApiPrefixFromUrl(agentResult.url, '/agent');
|
||||
if (agentPrefix !== null) {
|
||||
setDetectedOpenCodeApiPrefix(agentPrefix);
|
||||
} else if (normalizedPrefix) {
|
||||
setDetectedOpenCodeApiPrefix(normalizedPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
isOpenCodeReady = true;
|
||||
lastOpenCodeError = null;
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (configResult instanceof Error) {
|
||||
lastError = configResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!configResult.ok) {
|
||||
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
await configResult.json().catch(() => null);
|
||||
|
||||
if (agentResult instanceof Error) {
|
||||
lastError = agentResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentResult.ok) {
|
||||
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
await agentResult.json().catch(() => []);
|
||||
|
||||
isOpenCodeReady = true;
|
||||
lastOpenCodeError = null;
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
@@ -1829,12 +1638,8 @@ function setupProxy(app) {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api', async (req, res, next) => {
|
||||
try {
|
||||
await ensureOpenCodeApiPrefix();
|
||||
} catch (error) {
|
||||
console.warn(`OpenCode API prefix detection failed for ${req.method} ${req.path}: ${error.message}`);
|
||||
}
|
||||
app.use('/api', (_req, _res, next) => {
|
||||
ensureOpenCodeApiPrefix();
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -1867,11 +1672,7 @@ function setupProxy(app) {
|
||||
|
||||
const suffix = path.slice(4) || '/';
|
||||
|
||||
if (!openCodeApiPrefixDetected || openCodeApiPrefix === '') {
|
||||
return suffix;
|
||||
}
|
||||
|
||||
return `${openCodeApiPrefix}${suffix}`;
|
||||
return suffix;
|
||||
},
|
||||
ws: true,
|
||||
onError: (err, req, res) => {
|
||||
@@ -1903,9 +1704,7 @@ function setupProxy(app) {
|
||||
proxyRes.headers['X-Content-Type-Options'] = 'nosniff';
|
||||
}
|
||||
|
||||
if (proxyRes.statusCode === 404 && !openCodeApiPrefixDetected) {
|
||||
scheduleOpenCodeApiDetection();
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2013,8 +1812,8 @@ async function main(options = {}) {
|
||||
timestamp: new Date().toISOString(),
|
||||
openCodePort: openCodePort,
|
||||
openCodeRunning: Boolean(openCodePort && isOpenCodeReady && !isRestartingOpenCode),
|
||||
openCodeApiPrefix,
|
||||
openCodeApiPrefixDetected,
|
||||
openCodeApiPrefix: '',
|
||||
openCodeApiPrefixDetected: true,
|
||||
isOpenCodeReady,
|
||||
lastOpenCodeError
|
||||
});
|
||||
@@ -2213,18 +2012,9 @@ async function main(options = {}) {
|
||||
});
|
||||
|
||||
app.get('/api/global/event', async (req, res) => {
|
||||
if (!openCodeApiPrefixDetected) {
|
||||
try {
|
||||
await detectOpenCodeApiPrefix();
|
||||
} catch {
|
||||
// ignore detection failures
|
||||
}
|
||||
}
|
||||
|
||||
let targetUrl;
|
||||
try {
|
||||
const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
|
||||
targetUrl = new URL(buildOpenCodeUrl('/global/event', prefix));
|
||||
targetUrl = new URL(buildOpenCodeUrl('/global/event', ''));
|
||||
} catch (error) {
|
||||
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||
}
|
||||
@@ -2330,18 +2120,9 @@ async function main(options = {}) {
|
||||
});
|
||||
|
||||
app.get('/api/event', async (req, res) => {
|
||||
if (!openCodeApiPrefixDetected) {
|
||||
try {
|
||||
await detectOpenCodeApiPrefix();
|
||||
} catch {
|
||||
// ignore detection failures
|
||||
}
|
||||
}
|
||||
|
||||
let targetUrl;
|
||||
try {
|
||||
const prefix = openCodeApiPrefixDetected ? openCodeApiPrefix : '';
|
||||
targetUrl = new URL(buildOpenCodeUrl('/event', prefix));
|
||||
targetUrl = new URL(buildOpenCodeUrl('/event', ''));
|
||||
} catch (error) {
|
||||
return res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user