Archived
refactor: split client api modules
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# 03. Split client API into feature clients
|
||||
|
||||
Status: pending
|
||||
Status: completed
|
||||
|
||||
Decompose `src/client/src/api.ts` into composable feature clients, shared HTTP helper, parsers, URL builders, and socket helpers.
|
||||
|
||||
+2
-331
@@ -1,332 +1,3 @@
|
||||
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, Workspace } from "../../shared/apiTypes";
|
||||
|
||||
export { api, filesApi, gitApi, projectsApi, sessionsApi, workspacesApi } from "./api/clients";
|
||||
export { globalSessionEvents, sessionEvents } from "./api/sockets";
|
||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, Workspace } from "../../shared/apiTypes";
|
||||
|
||||
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined) headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
throw new Error(errorMessage(body) ?? response.statusText);
|
||||
}
|
||||
const body: unknown = await response.json();
|
||||
return parse(body);
|
||||
}
|
||||
|
||||
function errorMessage(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
return typeof value["error"] === "string" ? value["error"] : undefined;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
||||
addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
|
||||
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
|
||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||
};
|
||||
|
||||
export function sessionEvents(sessionId: string): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
|
||||
}
|
||||
|
||||
export function globalSessionEvents(): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
||||
}
|
||||
|
||||
function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.path !== undefined) params.set("path", options.path);
|
||||
if (options?.staged === true) params.set("staged", "true");
|
||||
const query = params.toString();
|
||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
const query = params.toString();
|
||||
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error("Expected object response");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") throw new Error(`Expected string field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string") throw new Error(`Expected optional string field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNumber(record: Record<string, unknown>, key: string): number {
|
||||
const value = record[key];
|
||||
if (typeof value !== "number") throw new Error(`Expected number field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireBoolean(record: Record<string, unknown>, key: string): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function arrayOf<T>(parse: (value: unknown) => T): (value: unknown) => T[] {
|
||||
return (value) => {
|
||||
if (!Array.isArray(value)) throw new Error("Expected array response");
|
||||
return value.map(parse);
|
||||
};
|
||||
}
|
||||
|
||||
function parseUnknownArray(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error("Expected array response");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseMessagePage(value: unknown): MessagePage {
|
||||
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
|
||||
const record = requireRecord(value);
|
||||
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
||||
}
|
||||
|
||||
function parseProject(value: unknown): Project {
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
||||
}
|
||||
|
||||
function parseWorkspace(value: unknown): Workspace {
|
||||
const record = requireRecord(value);
|
||||
const branch = optionalString(record, "branch");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
projectId: requireString(record, "projectId"),
|
||||
path: requireString(record, "path"),
|
||||
label: requireString(record, "label"),
|
||||
...(branch === undefined ? {} : { branch }),
|
||||
isMain: requireBoolean(record, "isMain"),
|
||||
isGitWorktree: requireBoolean(record, "isGitWorktree"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionInfo(value: unknown): SessionInfo {
|
||||
const record = requireRecord(value);
|
||||
const name = optionalString(record, "name");
|
||||
const archivedAt = optionalString(record, "archivedAt");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
path: requireString(record, "path"),
|
||||
cwd: requireString(record, "cwd"),
|
||||
...(name === undefined ? {} : { name }),
|
||||
created: requireString(record, "created"),
|
||||
modified: requireString(record, "modified"),
|
||||
messageCount: requireNumber(record, "messageCount"),
|
||||
firstMessage: requireString(record, "firstMessage"),
|
||||
...(record["archived"] === true ? { archived: true } : {}),
|
||||
...(archivedAt === undefined ? {} : { archivedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionStatus(value: unknown): SessionStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
sessionId: requireString(record, "sessionId"),
|
||||
isStreaming: requireBoolean(record, "isStreaming"),
|
||||
isCompacting: requireBoolean(record, "isCompacting"),
|
||||
isBashRunning: requireBoolean(record, "isBashRunning"),
|
||||
pendingMessageCount: requireNumber(record, "pendingMessageCount"),
|
||||
tokens: parseTokens(record["tokens"]),
|
||||
cost: requireNumber(record, "cost"),
|
||||
...optionalModel(record["model"]),
|
||||
...optionalContextUsage(record["contextUsage"]),
|
||||
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokens(value: unknown): SessionStatus["tokens"] {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
input: requireNumber(record, "input"),
|
||||
output: requireNumber(record, "output"),
|
||||
cacheRead: requireNumber(record, "cacheRead"),
|
||||
cacheWrite: requireNumber(record, "cacheWrite"),
|
||||
total: requireNumber(record, "total"),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
|
||||
if (value === undefined) return {};
|
||||
const record = requireRecord(value);
|
||||
return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } };
|
||||
}
|
||||
|
||||
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
|
||||
if (value === undefined) return {};
|
||||
const record = requireRecord(value);
|
||||
return { contextUsage: { tokens: numberOrNull(record, "tokens"), contextWindow: requireNumber(record, "contextWindow"), percent: numberOrNull(record, "percent") } };
|
||||
}
|
||||
|
||||
function parseSlashCommand(value: unknown): SlashCommand {
|
||||
const record = requireRecord(value);
|
||||
const source = requireString(record, "source");
|
||||
if (source !== "extension" && source !== "prompt" && source !== "skill" && source !== "builtin") throw new Error("Invalid command source");
|
||||
return { name: requireString(record, "name"), source, ...optionalField("description", optionalString(record, "description")) };
|
||||
}
|
||||
|
||||
function parseFileSuggestion(value: unknown): FileSuggestion {
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
if (kind !== "tracked" && kind !== "untracked" && kind !== "other") throw new Error("Invalid file kind");
|
||||
return { path: requireString(record, "path"), kind };
|
||||
}
|
||||
|
||||
function parseFileTreeResponse(value: unknown): FileTreeResponse {
|
||||
const record = requireRecord(value);
|
||||
return { path: requireString(record, "path"), entries: arrayOf(parseFileTreeEntry)(record["entries"]), scannedAt: requireString(record, "scannedAt"), truncated: requireBoolean(record, "truncated") };
|
||||
}
|
||||
|
||||
function parseFileTreeEntry(value: unknown): FileTreeEntry {
|
||||
const record = requireRecord(value);
|
||||
const type = requireString(record, "type");
|
||||
if (type !== "file" && type !== "directory" && type !== "symlink") throw new Error("Invalid file tree entry type");
|
||||
return { name: requireString(record, "name"), path: requireString(record, "path"), type, ...optionalField("size", optionalNumber(record, "size")), ...optionalField("modifiedAt", optionalString(record, "modifiedAt")) };
|
||||
}
|
||||
|
||||
function parseFileContentResponse(value: unknown): FileContentResponse {
|
||||
const record = requireRecord(value);
|
||||
const encoding = requireString(record, "encoding");
|
||||
if (encoding !== "utf8") throw new Error("Invalid file encoding");
|
||||
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
||||
}
|
||||
|
||||
function parseGitStatusResponse(value: unknown): GitStatusResponse {
|
||||
const record = requireRecord(value);
|
||||
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) };
|
||||
}
|
||||
|
||||
function parseGitStatusFile(value: unknown): GitStatusFile {
|
||||
const record = requireRecord(value);
|
||||
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) };
|
||||
}
|
||||
|
||||
function parseGitFileState(value: unknown): GitFileState {
|
||||
switch (value) {
|
||||
case "unmodified":
|
||||
case "modified":
|
||||
case "added":
|
||||
case "deleted":
|
||||
case "renamed":
|
||||
case "copied":
|
||||
case "untracked":
|
||||
case "ignored":
|
||||
case "conflicted":
|
||||
return value;
|
||||
default:
|
||||
throw new Error("Invalid git file state");
|
||||
}
|
||||
}
|
||||
|
||||
function parseGitDiffResponse(value: unknown): GitDiffResponse {
|
||||
const record = requireRecord(value);
|
||||
return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") };
|
||||
}
|
||||
|
||||
function parseCommandResult(value: unknown): CommandResult {
|
||||
const record = requireRecord(value);
|
||||
const type = requireString(record, "type");
|
||||
if (type === "unsupported") return { type, message: requireString(record, "message") };
|
||||
if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) };
|
||||
if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]) };
|
||||
throw new Error("Invalid command result type");
|
||||
}
|
||||
|
||||
function parseCommandOption(value: unknown): CommandOption {
|
||||
const record = requireRecord(value);
|
||||
return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) };
|
||||
}
|
||||
|
||||
function optionalSession(value: unknown): Pick<Extract<CommandResult, { type: "done" }>, "session"> | object {
|
||||
return value === undefined ? {} : { session: parseSessionInfo(value) };
|
||||
}
|
||||
|
||||
function parseAccepted(value: unknown): { accepted: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["accepted"] !== true) throw new Error("Expected accepted response");
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
function parseAborted(value: unknown): { aborted: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["aborted"] !== true) throw new Error("Expected aborted response");
|
||||
return { aborted: true };
|
||||
}
|
||||
|
||||
function parseStopped(value: unknown): { stopped: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["stopped"] !== true) throw new Error("Expected stopped response");
|
||||
return { stopped: true };
|
||||
}
|
||||
|
||||
function parseArchived(value: unknown): { archived: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["archived"] !== true) throw new Error("Expected archived response");
|
||||
return { archived: true };
|
||||
}
|
||||
|
||||
function parseRestored(value: unknown): { restored: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["restored"] !== true) throw new Error("Expected restored response");
|
||||
return { restored: true };
|
||||
}
|
||||
|
||||
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number") throw new Error(`Expected optional number field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberOrNull(record: Record<string, unknown>, key: string): number | null {
|
||||
const value = record[key];
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "number") throw new Error(`Expected number|null field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalField(key: string, value: unknown): object {
|
||||
return value === undefined ? {} : { [key]: value };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FileSuggestion } from "../../../shared/apiTypes";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
parseAborted,
|
||||
parseAccepted,
|
||||
parseArchived,
|
||||
parseCommandResult,
|
||||
parseFileContentResponse,
|
||||
parseFileSuggestion,
|
||||
parseFileTreeResponse,
|
||||
parseGitDiffResponse,
|
||||
parseGitStatusResponse,
|
||||
parseMessagePage,
|
||||
parseProject,
|
||||
parseRestored,
|
||||
parseSessionInfo,
|
||||
parseSessionStatus,
|
||||
parseSlashCommand,
|
||||
parseStopped,
|
||||
parseWorkspace,
|
||||
} from "./parsers";
|
||||
import { gitDiffUrl, messageUrl } from "./urls";
|
||||
|
||||
export const projectsApi = {
|
||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
||||
addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
|
||||
};
|
||||
|
||||
export const workspacesApi = {
|
||||
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||
};
|
||||
|
||||
export const sessionsApi = {
|
||||
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||
};
|
||||
|
||||
export const filesApi = {
|
||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||
};
|
||||
|
||||
export const gitApi = {
|
||||
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
|
||||
};
|
||||
|
||||
export const api = {
|
||||
...projectsApi,
|
||||
...workspacesApi,
|
||||
...sessionsApi,
|
||||
...filesApi,
|
||||
...gitApi,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined) headers.set("content-type", "application/json");
|
||||
const response = await fetch(url, { ...init, headers });
|
||||
if (!response.ok) {
|
||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||
throw new Error(errorMessage(body) ?? response.statusText);
|
||||
}
|
||||
const body: unknown = await response.json();
|
||||
return parse(body);
|
||||
}
|
||||
|
||||
function errorMessage(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
return typeof value["error"] === "string" ? value["error"] : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, Workspace } from "../../../shared/apiTypes";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error("Expected object response");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireString(record: Record<string, unknown>, key: string): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") throw new Error(`Expected string field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string") throw new Error(`Expected optional string field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireNumber(record: Record<string, unknown>, key: string): number {
|
||||
const value = record[key];
|
||||
if (typeof value !== "number") throw new Error(`Expected number field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireBoolean(record: Record<string, unknown>, key: string): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function arrayOf<T>(parse: (value: unknown) => T): (value: unknown) => T[] {
|
||||
return (value) => {
|
||||
if (!Array.isArray(value)) throw new Error("Expected array response");
|
||||
return value.map(parse);
|
||||
};
|
||||
}
|
||||
|
||||
function parseUnknownArray(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) throw new Error("Expected array response");
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseMessagePage(value: unknown): MessagePage {
|
||||
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
|
||||
const record = requireRecord(value);
|
||||
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
||||
}
|
||||
|
||||
export function parseProject(value: unknown): Project {
|
||||
const record = requireRecord(value);
|
||||
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
||||
}
|
||||
|
||||
export function parseWorkspace(value: unknown): Workspace {
|
||||
const record = requireRecord(value);
|
||||
const branch = optionalString(record, "branch");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
projectId: requireString(record, "projectId"),
|
||||
path: requireString(record, "path"),
|
||||
label: requireString(record, "label"),
|
||||
...(branch === undefined ? {} : { branch }),
|
||||
isMain: requireBoolean(record, "isMain"),
|
||||
isGitWorktree: requireBoolean(record, "isGitWorktree"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionInfo(value: unknown): SessionInfo {
|
||||
const record = requireRecord(value);
|
||||
const name = optionalString(record, "name");
|
||||
const archivedAt = optionalString(record, "archivedAt");
|
||||
return {
|
||||
id: requireString(record, "id"),
|
||||
path: requireString(record, "path"),
|
||||
cwd: requireString(record, "cwd"),
|
||||
...(name === undefined ? {} : { name }),
|
||||
created: requireString(record, "created"),
|
||||
modified: requireString(record, "modified"),
|
||||
messageCount: requireNumber(record, "messageCount"),
|
||||
firstMessage: requireString(record, "firstMessage"),
|
||||
...(record["archived"] === true ? { archived: true } : {}),
|
||||
...(archivedAt === undefined ? {} : { archivedAt }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionStatus(value: unknown): SessionStatus {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
sessionId: requireString(record, "sessionId"),
|
||||
isStreaming: requireBoolean(record, "isStreaming"),
|
||||
isCompacting: requireBoolean(record, "isCompacting"),
|
||||
isBashRunning: requireBoolean(record, "isBashRunning"),
|
||||
pendingMessageCount: requireNumber(record, "pendingMessageCount"),
|
||||
tokens: parseTokens(record["tokens"]),
|
||||
cost: requireNumber(record, "cost"),
|
||||
...optionalModel(record["model"]),
|
||||
...optionalContextUsage(record["contextUsage"]),
|
||||
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokens(value: unknown): SessionStatus["tokens"] {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
input: requireNumber(record, "input"),
|
||||
output: requireNumber(record, "output"),
|
||||
cacheRead: requireNumber(record, "cacheRead"),
|
||||
cacheWrite: requireNumber(record, "cacheWrite"),
|
||||
total: requireNumber(record, "total"),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
|
||||
if (value === undefined) return {};
|
||||
const record = requireRecord(value);
|
||||
return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } };
|
||||
}
|
||||
|
||||
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
|
||||
if (value === undefined) return {};
|
||||
const record = requireRecord(value);
|
||||
return { contextUsage: { tokens: numberOrNull(record, "tokens"), contextWindow: requireNumber(record, "contextWindow"), percent: numberOrNull(record, "percent") } };
|
||||
}
|
||||
|
||||
export function parseSlashCommand(value: unknown): SlashCommand {
|
||||
const record = requireRecord(value);
|
||||
const source = requireString(record, "source");
|
||||
if (source !== "extension" && source !== "prompt" && source !== "skill" && source !== "builtin") throw new Error("Invalid command source");
|
||||
return { name: requireString(record, "name"), source, ...optionalField("description", optionalString(record, "description")) };
|
||||
}
|
||||
|
||||
export function parseFileSuggestion(value: unknown): FileSuggestion {
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "kind");
|
||||
if (kind !== "tracked" && kind !== "untracked" && kind !== "other") throw new Error("Invalid file kind");
|
||||
return { path: requireString(record, "path"), kind };
|
||||
}
|
||||
|
||||
export function parseFileTreeResponse(value: unknown): FileTreeResponse {
|
||||
const record = requireRecord(value);
|
||||
return { path: requireString(record, "path"), entries: arrayOf(parseFileTreeEntry)(record["entries"]), scannedAt: requireString(record, "scannedAt"), truncated: requireBoolean(record, "truncated") };
|
||||
}
|
||||
|
||||
function parseFileTreeEntry(value: unknown): FileTreeEntry {
|
||||
const record = requireRecord(value);
|
||||
const type = requireString(record, "type");
|
||||
if (type !== "file" && type !== "directory" && type !== "symlink") throw new Error("Invalid file tree entry type");
|
||||
return { name: requireString(record, "name"), path: requireString(record, "path"), type, ...optionalField("size", optionalNumber(record, "size")), ...optionalField("modifiedAt", optionalString(record, "modifiedAt")) };
|
||||
}
|
||||
|
||||
export function parseFileContentResponse(value: unknown): FileContentResponse {
|
||||
const record = requireRecord(value);
|
||||
const encoding = requireString(record, "encoding");
|
||||
if (encoding !== "utf8") throw new Error("Invalid file encoding");
|
||||
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
||||
}
|
||||
|
||||
export function parseGitStatusResponse(value: unknown): GitStatusResponse {
|
||||
const record = requireRecord(value);
|
||||
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) };
|
||||
}
|
||||
|
||||
function parseGitStatusFile(value: unknown): GitStatusFile {
|
||||
const record = requireRecord(value);
|
||||
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) };
|
||||
}
|
||||
|
||||
function parseGitFileState(value: unknown): GitFileState {
|
||||
switch (value) {
|
||||
case "unmodified":
|
||||
case "modified":
|
||||
case "added":
|
||||
case "deleted":
|
||||
case "renamed":
|
||||
case "copied":
|
||||
case "untracked":
|
||||
case "ignored":
|
||||
case "conflicted":
|
||||
return value;
|
||||
default:
|
||||
throw new Error("Invalid git file state");
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitDiffResponse(value: unknown): GitDiffResponse {
|
||||
const record = requireRecord(value);
|
||||
return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") };
|
||||
}
|
||||
|
||||
export function parseCommandResult(value: unknown): CommandResult {
|
||||
const record = requireRecord(value);
|
||||
const type = requireString(record, "type");
|
||||
if (type === "unsupported") return { type, message: requireString(record, "message") };
|
||||
if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) };
|
||||
if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]) };
|
||||
throw new Error("Invalid command result type");
|
||||
}
|
||||
|
||||
function parseCommandOption(value: unknown): CommandOption {
|
||||
const record = requireRecord(value);
|
||||
return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) };
|
||||
}
|
||||
|
||||
function optionalSession(value: unknown): Pick<Extract<CommandResult, { type: "done" }>, "session"> | object {
|
||||
return value === undefined ? {} : { session: parseSessionInfo(value) };
|
||||
}
|
||||
|
||||
export function parseAccepted(value: unknown): { accepted: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["accepted"] !== true) throw new Error("Expected accepted response");
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
export function parseAborted(value: unknown): { aborted: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["aborted"] !== true) throw new Error("Expected aborted response");
|
||||
return { aborted: true };
|
||||
}
|
||||
|
||||
export function parseStopped(value: unknown): { stopped: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["stopped"] !== true) throw new Error("Expected stopped response");
|
||||
return { stopped: true };
|
||||
}
|
||||
|
||||
export function parseArchived(value: unknown): { archived: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["archived"] !== true) throw new Error("Expected archived response");
|
||||
return { archived: true };
|
||||
}
|
||||
|
||||
export function parseRestored(value: unknown): { restored: true } {
|
||||
const record = requireRecord(value);
|
||||
if (record["restored"] !== true) throw new Error("Expected restored response");
|
||||
return { restored: true };
|
||||
}
|
||||
|
||||
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number") throw new Error(`Expected optional number field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberOrNull(record: Record<string, unknown>, key: string): number | null {
|
||||
const value = record[key];
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "number") throw new Error(`Expected number|null field: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalField(key: string, value: unknown): object {
|
||||
return value === undefined ? {} : { [key]: value };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function sessionEvents(sessionId: string): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
|
||||
}
|
||||
|
||||
export function globalSessionEvents(): WebSocket {
|
||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
||||
}
|
||||
|
||||
function webSocketBaseUrl(): string {
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${location.host}`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.path !== undefined) params.set("path", options.path);
|
||||
if (options?.staged === true) params.set("staged", "true");
|
||||
const query = params.toString();
|
||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||
const query = params.toString();
|
||||
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||
}
|
||||
Reference in New Issue
Block a user