diff --git a/apps/web/README.md b/apps/web/README.md index db206591..2868073d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -20,8 +20,8 @@ serves the built `dist/` on its own origin. - `src/pages/`: sign-in, the project library, settings and providers; `pages/workspace/` is the open project (conversation, documents, design concepts, live app). -- `src/client.ts` and `src/api/`: the `api` object the screens call, built on - the typed client and the hub's routes. +- `src/api/`: the calls the screens make, built on the typed client and the + hub's routes. - `src/queries/`: TanStack Query keys and the shared query client. - `public/mockup-frame.html`: the sandboxed page design mockups render in. - [`DESIGN.md`][design]: the workspace layout and styling contract. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3edfb4b1..6a71825f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -23,7 +23,9 @@ import { useState, useSyncExternalStore, } from "react"; -import { api, ApiFailure, type ProjectSummary } from "./client.js"; +import { listProjects } from "./api/projects.ts"; +import { ApiFailure, type ProjectSummary } from "./api/types.ts"; +import { singleFlight } from "./single-flight.ts"; import type { ModelProvider } from "@corbits-builder/schemas/models"; import { ArrowLeft, @@ -433,7 +435,7 @@ export function App() { }); const projectsRead = useQuery({ queryKey: keys.projects, - queryFn: () => api.projects(), + queryFn: readProjects, refetchInterval: FOLD_POLL_MS, }); const projects = projectsRead.data?.projects ?? NO_PROJECTS; @@ -777,5 +779,9 @@ export function App() { const NO_PROJECTS: ProjectSummary[] = []; const NO_PROVIDERS: ModelProvider[] = []; +const readProjects = singleFlight(async () => ({ + projects: await listProjects("all"), +})); + /** What the home fold rests on; `refresh` re-reads all of it at once. */ const FOLD_READS = [keys.modelCatalog, keys.projects, keys.workspaces] as const; diff --git a/apps/web/src/client.ts b/apps/web/src/client.ts deleted file mode 100644 index 7c96d29b..00000000 --- a/apps/web/src/client.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * The API client the screens call. - * - * The exported `api` object exposes the hub's operations. Packages are - * installed before project execution; the UI renders product facts and uses - * native deployment rather than assembling workflow source. - * - * - `createProject` sends the opening problem as the first chat message, not - * an artifact. Creating the project is one hub call: the hub deploys and - * attaches the coordinator and mails it the opening problem before that - * call returns. - * - `deleteProject` archives; the hub has soft archive/restore only. - * - `decide` resolves a native approval. - */ -import type { ExportSection } from "@corbits-builder/schemas/project"; -import { singleFlight } from "./single-flight.ts"; -import { listCredentials as apiListCredentials } from "./api/credentials.ts"; -import { - archiveProject as apiArchiveProject, - createProject as apiCreateProject, - exportProject as apiExportProject, - exportSections as apiExportSections, - getProject as apiGetProject, - importProject as apiImportProject, - readImportFile as apiReadImportFile, - renameProject as apiRenameProject, - restoreProject as apiRestoreProject, - shipProject as apiShipProject, - listProjects as apiListProjects, -} from "./api/projects.ts"; -import { - latestQuestion as apiLatestQuestion, - readConversationEvents, - sendMessage as apiSendMessage, -} from "./api/conversation.ts"; -import { decideApproval } from "./api/decisions.ts"; -import { ApiFailure, type ConversationEvent } from "./api/types.ts"; - -export { ApiFailure, type ProjectSummary } from "./api/types.ts"; - -export const api = { - // --- Credentials: the bring-your-own-key path. --------------------------- - providers: async () => ({ providers: await apiListCredentials() }), - - // --- Decisions ---------------------------------------------------------- - /** - * Decides one wait. A native approval (`decision.approvalId`) is resolved - * through the hub's decide endpoint; a workflow question is answered in the - * conversation. - */ - decide: async ( - projectId: string, - decision: Record, - ): Promise<{ ok: true }> => { - const approvalId = decision["approvalId"]; - if (typeof approvalId !== "string" || approvalId.length === 0) { - throw new ApiFailure({ - code: "native_approval_required", - message: - "A native approvalId is required. Answer workflow questions in the conversation.", - correlationId: "-", - retryable: false, - }); - } - const choice = - decision["decision"] === "reject" || decision["decision"] === "refuse" - ? ("reject" as const) - : ("approve" as const); - const message = decision["message"]; - const { tenantId } = await apiGetProject(projectId); - await decideApproval({ - tenantId, - approvalId, - decision: choice, - ...(typeof message === "string" ? { message } : {}), - }); - return { ok: true }; - }, - - // --- Projects ----------------------------------------------------------- - projects: singleFlight(async () => ({ - projects: await apiListProjects("all"), - })), - /** - * Creates the project with one `create_project` mutation. The hub deploys - * and attaches the coordinator, then mails it `problemStatement` as the - * first chat message, before that call returns. - */ - createProject: async (payload: { - title?: string; - problemStatement?: string; - profile?: string; - }): Promise<{ projectId: string }> => { - const created = await apiCreateProject({ - ...(payload.title === undefined ? {} : { title: payload.title }), - ...(payload.problemStatement === undefined - ? {} - : { problemStatement: payload.problemStatement }), - ...(payload.profile === undefined ? {} : { profile: payload.profile }), - }); - return { projectId: created.projectId }; - }, - updateProject: async ( - projectId: string, - payload: { title?: string; archived?: boolean; shipped?: boolean }, - ): Promise<{ ok: true }> => { - if (payload.title !== undefined) - await apiRenameProject(projectId, payload.title); - if (payload.archived === true) await apiArchiveProject(projectId); - if (payload.archived === false) await apiRestoreProject(projectId); - if (payload.shipped !== undefined) - await apiShipProject(projectId, payload.shipped); - return { ok: true }; - }, - exportProject: (projectId: string, sections?: readonly ExportSection[]) => - apiExportProject(projectId, sections), - exportSections: (projectId: string) => apiExportSections(projectId), - /** A new project from an exported file; resolves once its coordinator is attached. */ - importProject: (file: unknown) => apiImportProject(file), - readImportFile: (file: Blob) => apiReadImportFile(file), - /** The hub has soft archive/restore only, so this archives. */ - deleteProject: async (projectId: string): Promise<{ ok: true }> => { - await apiArchiveProject(projectId); - return { ok: true }; - }, - - // --- Conversation (native run mail) --------------------------------------- - /** - * The project's committed conversation events, or null while the project - * has no bound coordinator (the hub answers 409). - */ - conversationEvents: (projectId: string) => readConversationEvents(projectId), - /** - * Sends one person message. The coordinator is already attached by project - * creation; this call does not deploy, bind, grant, or store a credential. - */ - sendMessage: (projectId: string, content: string): Promise => - apiSendMessage(projectId, content), - /** The latest question to the person, over the conversation's input parks. */ - latestQuestion: ( - events: ConversationEvent[], - yourAnswerMessageIds: readonly string[], - ) => apiLatestQuestion(events, yourAnswerMessageIds), -}; - -/** What `api.sendMessage` resolves with: the hub's 202 receipt and the chat turn it composes. */ -export type SendReceiptChat = Awaited>; diff --git a/apps/web/src/markdown.tsx b/apps/web/src/markdown.tsx index 54a66aff..1f3b8307 100644 --- a/apps/web/src/markdown.tsx +++ b/apps/web/src/markdown.tsx @@ -12,7 +12,7 @@ * sanitiser to keep up to date. What is not handled below renders as text. */ import type { JSX, ReactNode } from "react"; -import { DEL, END, INS } from "./revisions.js"; +import { DEL, END, INS } from "./revisions.ts"; /** * Inline spans: code, bold, italic, links, with tracked changes threaded through them. diff --git a/apps/web/src/pages/project-library.tsx b/apps/web/src/pages/project-library.tsx index b73abe4c..ace16d59 100644 --- a/apps/web/src/pages/project-library.tsx +++ b/apps/web/src/pages/project-library.tsx @@ -16,7 +16,7 @@ import { import { Ellipsis } from "lucide-react"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import type { ProjectStage } from "@corbits-builder/schemas/project"; -import type { ProjectSummary } from "../client.js"; +import type { ProjectSummary } from "../api/types.ts"; import type { NoticeAction } from "../components.jsx"; import { ProjectMenu } from "./project-menu.jsx"; import "./project-library.css"; diff --git a/apps/web/src/pages/project-menu.tsx b/apps/web/src/pages/project-menu.tsx index 2ca8188e..bb82b4d2 100644 --- a/apps/web/src/pages/project-menu.tsx +++ b/apps/web/src/pages/project-menu.tsx @@ -27,7 +27,14 @@ import { MenuTrigger, } from "@corbits/react-ui/ui/menu"; import { useEffect, useState, type ReactElement } from "react"; -import { api } from "../client.js"; +import { + archiveProject, + exportProject as exportProjectArchive, + exportSections, + renameProject, + restoreProject, + shipProject, +} from "../api/projects.ts"; import { Banner, Button, type NoticeAction } from "../components.jsx"; import { Dictated } from "../dictation.jsx"; @@ -48,7 +55,7 @@ async function exportProject( project: MenuProject, sections: readonly ExportSection[], ) { - const { filename, archive, note } = await api.exportProject( + const { filename, archive, note } = await exportProjectArchive( project.id, sections, ); @@ -123,11 +130,7 @@ export function ProjectMenu({ {project.archivedAt ? null : ( - void act(() => - api.updateProject(project.id, { - shipped: !project.shippedAt, - }), - ) + void act(() => shipProject(project.id, !project.shippedAt)) } > {project.shippedAt @@ -139,9 +142,9 @@ export function ProjectMenu({ void act(() => - api.updateProject(project.id, { - archived: !project.archivedAt, - }), + project.archivedAt + ? restoreProject(project.id) + : archiveProject(project.id), ) } > @@ -155,7 +158,7 @@ export function ProjectMenu({ className="menu-danger" onSelect={() => void act(async () => { - await api.deleteProject(project.id); + await archiveProject(project.id); onDeleted?.(); }) } @@ -233,13 +236,13 @@ function ExportDialog({ const [chosen, setChosen] = useState>(() => new Set()); useEffect(() => { let current = true; - api.exportSections(projectId).then( - (sections) => { + async function loadSections() { + try { + const sections = await exportSections(projectId); if (!current) return; setAvailable(sections); setChosen(new Set(sections)); - }, - (cause: unknown) => { + } catch (cause) { if (!current) return; const planning = new Set(["planning"]); setFailure( @@ -249,8 +252,9 @@ function ExportDialog({ ); setAvailable(planning); setChosen(new Set(planning)); - }, - ); + } + } + void loadSections(); return () => { current = false; }; @@ -340,10 +344,18 @@ export function ProjectInfoDialog({ const save = async () => { if (!dirty || saving) return; setSaving(true); + const ok = await act(() => renameProject(project.id, nextTitle)); + setSaving(false); + if (ok) onClose(); + }; + const toggleArchived = async () => { + setArchiving(true); const ok = await act(() => - api.updateProject(project.id, { title: nextTitle }), + project.archivedAt + ? restoreProject(project.id) + : archiveProject(project.id), ); - setSaving(false); + setArchiving(false); if (ok) onClose(); }; @@ -394,17 +406,7 @@ export function ProjectInfoDialog({