diff --git a/.ai-work/03-split-client-api.md b/.ai-work/03-split-client-api.md index a1fe8fc..18cab13 100644 --- a/.ai-work/03-split-client-api.md +++ b/.ai-work/03-split-client-api.md @@ -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. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index bbe01c4..031927f 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { - 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 { - return typeof value === "object" && value !== null; -} - -function requireRecord(value: unknown): Record { - if (!isRecord(value)) throw new Error("Expected object response"); - return value; -} - -function requireString(record: Record, key: string): string { - const value = record[key]; - if (typeof value !== "string") throw new Error(`Expected string field: ${key}`); - return value; -} - -function optionalString(record: Record, 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, key: string): number { - const value = record[key]; - if (typeof value !== "number") throw new Error(`Expected number field: ${key}`); - return value; -} - -function requireBoolean(record: Record, key: string): boolean { - const value = record[key]; - if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`); - return value; -} - -function arrayOf(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 | 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 | 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, "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, 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, 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 }; -} diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts new file mode 100644 index 0000000..3a6b3a6 --- /dev/null +++ b/src/client/src/api/clients.ts @@ -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, +}; diff --git a/src/client/src/api/http.ts b/src/client/src/api/http.ts new file mode 100644 index 0000000..f1c0da9 --- /dev/null +++ b/src/client/src/api/http.ts @@ -0,0 +1,20 @@ +export async function request(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise { + 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 { + return typeof value === "object" && value !== null; +} diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts new file mode 100644 index 0000000..9bc3220 --- /dev/null +++ b/src/client/src/api/parsers.ts @@ -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 { + return typeof value === "object" && value !== null; +} + +function requireRecord(value: unknown): Record { + if (!isRecord(value)) throw new Error("Expected object response"); + return value; +} + +function requireString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string") throw new Error(`Expected string field: ${key}`); + return value; +} + +function optionalString(record: Record, 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, key: string): number { + const value = record[key]; + if (typeof value !== "number") throw new Error(`Expected number field: ${key}`); + return value; +} + +function requireBoolean(record: Record, key: string): boolean { + const value = record[key]; + if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`); + return value; +} + +export function arrayOf(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 | 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 | 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, "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, 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, 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 }; +} diff --git a/src/client/src/api/sockets.ts b/src/client/src/api/sockets.ts new file mode 100644 index 0000000..762a106 --- /dev/null +++ b/src/client/src/api/sockets.ts @@ -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}`; +} diff --git a/src/client/src/api/urls.ts b/src/client/src/api/urls.ts new file mode 100644 index 0000000..a7ac7ed --- /dev/null +++ b/src/client/src/api/urls.ts @@ -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}` : ""}`; +}