Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

197
web/src/api/files.ts Normal file
View File

@@ -0,0 +1,197 @@
import type {
DirectoryListResult,
FileSearchQuery,
FileSearchResult,
FilesAPI,
} from '@openchamber/ui/lib/api/types';
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
type WebDirectoryEntry = {
name?: string;
path?: string;
isDirectory?: boolean;
isFile?: boolean;
isSymbolicLink?: boolean;
};
type WebDirectoryListResponse = {
directory?: string;
path?: string;
entries?: WebDirectoryEntry[];
};
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
const directory = normalizePath(payload?.directory || payload?.path || fallbackDirectory);
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
return {
directory,
entries: entries
.filter((entry): entry is Required<Pick<WebDirectoryEntry, 'name' | 'path'>> & { isDirectory?: boolean } =>
Boolean(entry && typeof entry.name === 'string' && typeof entry.path === 'string')
)
.map((entry) => ({
name: entry.name,
path: normalizePath(entry.path),
isDirectory: Boolean(entry.isDirectory),
})),
};
};
export const createWebFilesAPI = (): FilesAPI => ({
async listDirectory(path: string): Promise<DirectoryListResult> {
const target = normalizePath(path);
const params = new URLSearchParams();
if (target) {
params.set('path', target);
}
const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to list directory');
}
const result = (await response.json()) as WebDirectoryListResponse;
return toDirectoryListResult(target, result);
},
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
const params = new URLSearchParams();
const directory = normalizePath(payload.directory);
if (directory) {
params.set('directory', directory);
}
params.set('query', payload.query);
params.set('dirs', 'false');
params.set('type', 'file');
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
params.set('limit', String(payload.maxResults));
}
const response = await fetch(`/api/find/file?${params.toString()}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to search files');
}
const result = (await response.json()) as string[];
const files = Array.isArray(result) ? result : [];
return files.map((relativePath) => ({
path: normalizePath(`${directory}/${relativePath}`),
preview: [normalizePath(relativePath)],
}));
},
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
const target = normalizePath(path);
const response = await fetch('/api/fs/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to create directory');
}
const result = await response.json();
return {
success: Boolean(result?.success),
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
};
},
async readFile(path: string): Promise<{ content: string; path: string }> {
const target = normalizePath(path);
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to read file');
}
const content = await response.text();
return { content, path: target };
},
async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> {
const target = normalizePath(path);
const response = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target, content }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to write file');
}
const result = await response.json().catch(() => ({}));
return {
success: Boolean((result as { success?: boolean }).success),
path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : target,
};
},
async delete(path: string): Promise<{ success: boolean }> {
const target = normalizePath(path);
const response = await fetch('/api/fs/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to delete file');
}
const result = await response.json().catch(() => ({}));
return { success: Boolean((result as { success?: boolean }).success) };
},
async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> {
const response = await fetch('/api/fs/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath, newPath }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to rename file');
}
const result = await response.json().catch(() => ({}));
return {
success: Boolean((result as { success?: boolean }).success),
path: typeof (result as { path?: string }).path === 'string' ? normalizePath((result as { path: string }).path) : newPath,
};
},
async revealPath(targetPath: string): Promise<{ success: boolean }> {
const response = await fetch('/api/fs/reveal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: normalizePath(targetPath) }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((error as { error?: string }).error || 'Failed to reveal path');
}
const result = await response.json().catch(() => ({}));
return { success: Boolean((result as { success?: boolean }).success) };
},
});

60
web/src/api/git.ts Normal file
View File

@@ -0,0 +1,60 @@
import * as gitApiHttp from '@openchamber/ui/lib/gitApiHttp';
import type {
GitAPI,
CreateGitCommitOptions,
GitLogOptions,
} from '@openchamber/ui/lib/api/types';
export const createWebGitAPI = (): GitAPI => ({
checkIsGitRepository: gitApiHttp.checkIsGitRepository,
getGitStatus: gitApiHttp.getGitStatus,
getGitDiff: gitApiHttp.getGitDiff,
getGitFileDiff: gitApiHttp.getGitFileDiff,
revertGitFile: gitApiHttp.revertGitFile,
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
getGitBranches: gitApiHttp.getGitBranches,
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'],
generateCommitMessage: gitApiHttp.generateCommitMessage,
generatePullRequestDescription: gitApiHttp.generatePullRequestDescription,
listGitWorktrees: gitApiHttp.listGitWorktrees,
validateGitWorktree: gitApiHttp.validateGitWorktree,
createGitWorktree: gitApiHttp.createGitWorktree,
deleteGitWorktree: gitApiHttp.deleteGitWorktree,
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions) {
return gitApiHttp.createGitCommit(directory, message, options);
},
gitPush: gitApiHttp.gitPush,
gitPull: gitApiHttp.gitPull,
gitFetch: gitApiHttp.gitFetch,
checkoutBranch: gitApiHttp.checkoutBranch,
createBranch: gitApiHttp.createBranch,
renameBranch: gitApiHttp.renameBranch,
getGitLog(directory: string, options?: GitLogOptions) {
return gitApiHttp.getGitLog(directory, options);
},
getCommitFiles: gitApiHttp.getCommitFiles,
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
hasLocalIdentity: gitApiHttp.hasLocalIdentity,
setGitIdentity: gitApiHttp.setGitIdentity,
getGitIdentities: gitApiHttp.getGitIdentities,
createGitIdentity: gitApiHttp.createGitIdentity,
updateGitIdentity: gitApiHttp.updateGitIdentity,
deleteGitIdentity: gitApiHttp.deleteGitIdentity,
getRemotes: gitApiHttp.getRemotes,
rebase: gitApiHttp.rebase,
abortRebase: gitApiHttp.abortRebase,
continueRebase: gitApiHttp.continueRebase,
merge: gitApiHttp.merge,
abortMerge: gitApiHttp.abortMerge,
continueMerge: gitApiHttp.continueMerge,
stash: gitApiHttp.stash,
stashPop: gitApiHttp.stashPop,
getConflictDetails: gitApiHttp.getConflictDetails,
worktree: {
list: gitApiHttp.listGitWorktrees,
validate: gitApiHttp.validateGitWorktree,
create: gitApiHttp.createGitWorktree,
remove: gitApiHttp.deleteGitWorktree,
},
});

233
web/src/api/github.ts Normal file
View File

@@ -0,0 +1,233 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequestContextResult,
GitHubPullRequestsListResult,
GitHubPullRequest,
GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput,
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestUpdateInput,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
} from '@openchamber/ui/lib/api/types';
const jsonOrNull = async <T>(response: Response): Promise<T | null> => {
return (await response.json().catch(() => null)) as T | null;
};
export const createWebGitHubAPI = (): GitHubAPI => ({
async authStatus(): Promise<GitHubAuthStatus> {
const response = await fetch('/api/github/auth/status', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
}
return payload;
},
async authStart(): Promise<GitHubDeviceFlowStart> {
const response = await fetch('/api/github/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
});
const payload = await jsonOrNull<GitHubDeviceFlowStart & { error?: string }>(response);
if (!response.ok || !payload || !('deviceCode' in payload)) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to start GitHub auth');
}
return payload;
},
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
const response = await fetch('/api/github/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ deviceCode }),
});
const payload = await jsonOrNull<GitHubDeviceFlowComplete & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error((payload as { error?: string } | null)?.error || response.statusText || 'Failed to complete GitHub auth');
}
return payload;
},
async authDisconnect(): Promise<{ removed: boolean }> {
const response = await fetch('/api/github/auth', { method: 'DELETE', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<{ removed?: boolean; error?: string }>(response);
if (!response.ok) {
throw new Error(payload?.error || response.statusText || 'Failed to disconnect GitHub');
}
return { removed: Boolean(payload?.removed) };
},
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
const response = await fetch('/api/github/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accountId }),
});
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to activate GitHub account');
}
return payload;
},
async me(): Promise<GitHubUserSummary> {
const response = await fetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to fetch GitHub user');
}
return payload;
},
async prStatus(directory: string, branch: string, remote?: string): Promise<GitHubPullRequestStatus> {
const params = new URLSearchParams({
directory,
branch,
...(remote ? { remote } : {}),
});
const response = await fetch(
`/api/github/pr/status?${params.toString()}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubPullRequestStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load PR status');
}
return payload;
},
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
const response = await fetch('/api/github/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to create PR');
}
return body;
},
async prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest> {
const response = await fetch('/api/github/pr/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequest & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to update PR');
}
return body;
},
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
const response = await fetch('/api/github/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequestMergeResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to merge PR');
}
return body;
},
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
const response = await fetch('/api/github/pr/ready', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const body = await jsonOrNull<GitHubPullRequestReadyResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText || 'Failed to mark PR ready');
}
return body;
},
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
const page = options?.page ?? 1;
const response = await fetch(
`/api/github/pulls/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GitHubPullRequestsListResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to load pull requests');
}
return body;
},
async prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
): Promise<GitHubPullRequestContextResult> {
const url = new URL('/api/github/pulls/context', window.location.origin);
url.searchParams.set('directory', directory);
url.searchParams.set('number', String(number));
if (options?.includeDiff) {
url.searchParams.set('diff', '1');
}
if (options?.includeCheckDetails) {
url.searchParams.set('checkDetails', '1');
}
const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } });
const body = await jsonOrNull<GitHubPullRequestContextResult & { error?: string }>(response);
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText || 'Failed to load pull request context');
}
return body;
},
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
const page = options?.page ?? 1;
const response = await fetch(
`/api/github/issues/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issues');
}
return payload;
},
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
const response = await fetch(
`/api/github/issues/get?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssueGetResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue');
}
return payload;
},
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
const response = await fetch(
`/api/github/issues/comments?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const payload = await jsonOrNull<GitHubIssueCommentsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');
}
return payload;
},
});

23
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import { createWebTerminalAPI } from './terminal';
import { createWebGitAPI } from './git';
import { createWebFilesAPI } from './files';
import { createWebSettingsAPI } from './settings';
import { createWebPermissionsAPI } from './permissions';
import { createWebNotificationsAPI } from './notifications';
import { createWebToolsAPI } from './tools';
import { createWebPushAPI } from './push';
import { createWebGitHubAPI } from './github';
export const createWebAPIs = (): RuntimeAPIs => ({
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
terminal: createWebTerminalAPI(),
git: createWebGitAPI(),
files: createWebFilesAPI(),
settings: createWebSettingsAPI(),
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI(),
push: createWebPushAPI(),
tools: createWebToolsAPI(),
});

View File

@@ -0,0 +1,77 @@
import type { NotificationPayload, NotificationsAPI } from '@openchamber/ui/lib/api/types';
const notifyWithWebAPI = async (payload?: NotificationPayload): Promise<boolean> => {
if (typeof Notification === 'undefined') {
console.info('Notifications not supported in this environment', payload);
return false;
}
if (Notification.permission === 'default') {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission not granted');
return false;
}
}
if (Notification.permission !== 'granted') {
console.warn('Notification permission not granted');
return false;
}
try {
new Notification(payload?.title ?? 'OpenChamber', {
body: payload?.body,
tag: payload?.tag,
});
return true;
} catch (error) {
console.warn('Failed to send notification', error);
return false;
}
};
const notifyWithTauri = async (payload?: NotificationPayload): Promise<boolean> => {
if (typeof window === 'undefined') {
return false;
}
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
if (!tauri?.core?.invoke) {
return false;
}
try {
await tauri.core.invoke('desktop_notify', {
payload: {
title: payload?.title,
body: payload?.body,
tag: payload?.tag,
},
});
return true;
} catch (error) {
console.warn('Failed to send native notification (tauri)', error);
return false;
}
};
export const createWebNotificationsAPI = (): NotificationsAPI => ({
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
return (await notifyWithTauri(payload)) || notifyWithWebAPI(payload);
},
canNotify: () => {
if (typeof window !== 'undefined') {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
if (tauri?.core?.invoke) {
return true;
}
}
return typeof Notification !== 'undefined' ? Notification.permission === 'granted' : false;
},
});
type TauriGlobal = {
core?: {
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
};
};

View File

@@ -0,0 +1,15 @@
import type { DirectoryPermissionRequest, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
export const createWebPermissionsAPI = (): PermissionsAPI => ({
async requestDirectoryAccess(request: DirectoryPermissionRequest) {
return { success: true, path: request.path };
},
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
void path;
return { success: true };
},
});

59
web/src/api/push.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { PushAPI, PushSubscribePayload, PushUnsubscribePayload } from '@openchamber/ui/lib/api/types';
const fetchJson = async <T>(input: RequestInfo | URL, init?: RequestInit): Promise<T | null> => {
try {
const res = await fetch(input, {
...init,
credentials: 'include',
headers: {
Accept: 'application/json',
...(init?.headers ?? {}),
},
});
if (!res.ok) {
return null;
}
return (await res.json()) as T;
} catch {
return null;
}
};
export const createWebPushAPI = (): PushAPI => ({
async getVapidPublicKey() {
return fetchJson<{ publicKey: string }>('/api/push/vapid-public-key');
},
async subscribe(payload: PushSubscribePayload) {
return fetchJson<{ ok: true }>('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
},
async unsubscribe(payload: PushUnsubscribePayload) {
return fetchJson<{ ok: true }>('/api/push/subscribe', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
},
async setVisibility(payload: { visible: boolean }) {
return fetchJson<{ ok: true }>('/api/push/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
keepalive: true,
});
},
});

58
web/src/api/settings.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
const SETTINGS_ENDPOINT = '/api/config/settings';
const RELOAD_ENDPOINT = '/api/config/reload';
const sanitizePayload = (data: unknown): SettingsPayload => {
if (!data || typeof data !== 'object') {
return {};
}
return data as SettingsPayload;
};
export const createWebSettingsAPI = (): SettingsAPI => ({
async load(): Promise<SettingsLoadResult> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error(`Failed to load settings: ${response.statusText}`);
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return {
settings: payload,
source: 'web',
};
},
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
const response = await fetch(SETTINGS_ENDPOINT, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(changes),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to save settings');
}
const payload = sanitizePayload(await response.json().catch(() => ({})));
return payload;
},
async restartOpenCode(): Promise<{ restarted: boolean }> {
const response = await fetch(RELOAD_ENDPOINT, { method: 'POST' });
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'Failed to restart OpenCode');
}
return { restarted: true };
},
});

74
web/src/api/terminal.ts Normal file
View File

@@ -0,0 +1,74 @@
import {
connectTerminalStream,
createTerminalSession,
resizeTerminal,
sendTerminalInput,
closeTerminal,
restartTerminalSession,
forceKillTerminal,
} from '@openchamber/ui/lib/terminalApi';
import type {
TerminalAPI,
TerminalHandlers,
TerminalStreamOptions,
CreateTerminalOptions,
ResizeTerminalPayload,
TerminalSession,
ForceKillOptions,
} from '@openchamber/ui/lib/api/types';
const getRetryPolicy = (options?: TerminalStreamOptions) => {
const retry = options?.retry;
return {
maxRetries: retry?.maxRetries ?? 3,
initialRetryDelay: retry?.initialDelayMs ?? 1000,
maxRetryDelay: retry?.maxDelayMs ?? 8000,
connectionTimeout: options?.connectionTimeoutMs ?? 10000,
};
};
export const createWebTerminalAPI = (): TerminalAPI => ({
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
return createTerminalSession(options);
},
connect(sessionId: string, handlers: TerminalHandlers, options?: TerminalStreamOptions) {
const unsubscribe = connectTerminalStream(
sessionId,
handlers.onEvent,
handlers.onError,
getRetryPolicy(options)
);
return {
close: () => unsubscribe(),
};
},
async sendInput(sessionId: string, input: string): Promise<void> {
await sendTerminalInput(sessionId, input);
},
async resize(payload: ResizeTerminalPayload): Promise<void> {
await resizeTerminal(payload.sessionId, payload.cols, payload.rows);
},
async close(sessionId: string): Promise<void> {
await closeTerminal(sessionId);
},
async restartSession(
currentSessionId: string,
options: CreateTerminalOptions
): Promise<TerminalSession> {
return restartTerminalSession(currentSessionId, {
cwd: options.cwd ?? '',
cols: options.cols,
rows: options.rows,
});
},
async forceKill(options: ForceKillOptions): Promise<void> {
await forceKillTerminal(options);
},
});

22
web/src/api/tools.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
export const createWebToolsAPI = (): ToolsAPI => ({
async getAvailableTools(): Promise<string[]> {
const response = await fetch('/api/experimental/tool/ids');
if (!response.ok) {
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('Tools API returned invalid data format');
}
return data
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
.sort();
},
});

15
web/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { createWebAPIs } from './api';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import '@openchamber/ui/index.css';
import '@openchamber/ui/styles/fonts';
declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
}
}
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
import('@openchamber/ui/main');