From eac14ead15b2e021ad7396d6a7119597a75b1115 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 15:47:33 -0700 Subject: [PATCH] chat-ui owns the chat wire modules; drop not-carried chat features from web (CL-8175) --- apps/web/src/block-response-actions.test.ts | 55 -- apps/web/src/block-response-actions.ts | 72 --- apps/web/src/insights-stats.test.ts | 17 +- apps/web/src/insights-stats.ts | 21 +- apps/web/src/pages/chat-page.tsx | 17 - packages/chat-ui/src/api.ts | 131 ---- .../chat-ui/src/blocks/block-responses.ts | 90 --- .../src/blocks/connect-github-actions.ts | 94 --- .../blocks/connect-github-block-container.tsx | 253 -------- .../src/blocks/connect-github-block.tsx | 479 --------------- packages/chat-ui/src/blocks/form-block.tsx | 272 --------- packages/chat-ui/src/blocks/poll-block.tsx | 140 ----- .../chat-ui/src/blocks/question-block.tsx | 249 -------- packages/chat-ui/src/blocks/registry.tsx | 64 +- packages/chat-ui/src/chat-workspace.test.ts | 27 +- packages/chat-ui/src/chat-workspace.tsx | 233 +------ packages/chat-ui/src/composer.tsx | 330 ++-------- packages/chat-ui/src/index.ts | 47 +- packages/chat-ui/src/invite-agent-dialog.tsx | 156 ----- packages/chat-ui/src/mentions.ts | 185 ------ packages/chat-ui/src/slash-commands.test.ts | 53 -- packages/chat-ui/src/slash-commands.ts | 73 --- packages/chat-ui/src/timeline.tsx | 24 - packages/chat-ui/src/wire/blocks.ts | 143 ----- .../chat-ui/src/wire/workbench-host-naming.ts | 48 -- .../src/workbench-settings/agents-section.tsx | 8 +- .../workbench-settings/members-section.tsx | 15 +- .../src/workbench-settings/surface.tsx | 4 - packages/chat-ui/test/agents-section.test.tsx | 4 +- packages/chat-ui/test/blocks.test.tsx | 66 -- packages/chat-ui/test/chat-workspace.test.tsx | 145 ----- .../composer-synchronous-double-send.test.tsx | 3 - packages/chat-ui/test/composer.test.tsx | 314 +--------- .../connect-github-block-container.test.tsx | 340 ----------- .../test/connect-github-block.test.tsx | 578 ------------------ .../chat-ui/test/connect-github-flow.test.tsx | 258 -------- packages/chat-ui/test/form-block.test.tsx | 179 ------ .../chat-ui/test/invite-agent-dialog.test.tsx | 91 --- packages/chat-ui/test/mentions.test.ts | 166 ----- .../test/onboarding-scene-card.test.tsx | 358 ----------- packages/chat-ui/test/poll-block.test.tsx | 192 ------ packages/chat-ui/test/question-block.test.tsx | 347 ----------- .../chat-ui/test/refresh-and-send.test.ts | 99 +-- 43 files changed, 63 insertions(+), 6377 deletions(-) delete mode 100644 apps/web/src/block-response-actions.test.ts delete mode 100644 apps/web/src/block-response-actions.ts delete mode 100644 packages/chat-ui/src/blocks/block-responses.ts delete mode 100644 packages/chat-ui/src/blocks/connect-github-actions.ts delete mode 100644 packages/chat-ui/src/blocks/connect-github-block-container.tsx delete mode 100644 packages/chat-ui/src/blocks/connect-github-block.tsx delete mode 100644 packages/chat-ui/src/blocks/form-block.tsx delete mode 100644 packages/chat-ui/src/blocks/poll-block.tsx delete mode 100644 packages/chat-ui/src/blocks/question-block.tsx delete mode 100644 packages/chat-ui/src/invite-agent-dialog.tsx delete mode 100644 packages/chat-ui/src/slash-commands.test.ts delete mode 100644 packages/chat-ui/src/slash-commands.ts delete mode 100644 packages/chat-ui/src/wire/workbench-host-naming.ts delete mode 100644 packages/chat-ui/test/connect-github-block-container.test.tsx delete mode 100644 packages/chat-ui/test/connect-github-block.test.tsx delete mode 100644 packages/chat-ui/test/connect-github-flow.test.tsx delete mode 100644 packages/chat-ui/test/form-block.test.tsx delete mode 100644 packages/chat-ui/test/invite-agent-dialog.test.tsx delete mode 100644 packages/chat-ui/test/onboarding-scene-card.test.tsx delete mode 100644 packages/chat-ui/test/poll-block.test.tsx delete mode 100644 packages/chat-ui/test/question-block.test.tsx diff --git a/apps/web/src/block-response-actions.test.ts b/apps/web/src/block-response-actions.test.ts deleted file mode 100644 index 2cefcc9170..0000000000 --- a/apps/web/src/block-response-actions.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { CHAT_STRINGS } from "@corbits/chat-ui"; - -import { createChatBlockResponseActions } from "./block-response-actions"; - -describe("createChatBlockResponseActions copy", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - }); - - function stubStatus(status: number): void { - globalThis.fetch = (() => - Promise.resolve( - new Response(JSON.stringify({ message: "nope" }), { - status, - headers: { "content-type": "application/json" }, - }), - )) as unknown as typeof fetch; - } - - function actions() { - return createChatBlockResponseActions("tenant-1", "wb_1"); - } - - test("form 403 uses form-submit forbidden, not approve copy", async () => { - stubStatus(403); - const result = await actions().submitForm("msg_1", "form_1", { - name: "Ada", - }); - expect(result).toEqual({ - kind: "forbidden", - message: CHAT_STRINGS.blockFormSubmitForbidden, - }); - expect(CHAT_STRINGS.blockFormSubmitForbidden).not.toBe( - CHAT_STRINGS.blockApproveActionForbidden, - ); - }); - - test("poll and question 403 share form-submit forbidden copy", async () => { - stubStatus(403); - const ports = actions(); - const poll = await ports.submitPoll("msg_1", "poll_1", ["choice_1"]); - const question = await ports.submitQuestion("msg_1", "q_1", "yes", 0); - expect(poll).toEqual({ - kind: "forbidden", - message: CHAT_STRINGS.blockFormSubmitForbidden, - }); - expect(question).toEqual({ - kind: "forbidden", - message: CHAT_STRINGS.blockFormSubmitForbidden, - }); - }); -}); diff --git a/apps/web/src/block-response-actions.ts b/apps/web/src/block-response-actions.ts deleted file mode 100644 index 26e1e383f8..0000000000 --- a/apps/web/src/block-response-actions.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Builds the `BlockResponseActions` port `ChatWorkspace` (`@corbits/chat-ui`) -// calls for its in-chat poll/form cards, mirroring `createChatApprovalActions` -// in `./approval-actions.ts`: `@corbits/chat-ui` owns no session, so the host -// supplies the concrete fetches against the hub's chat response -// routes (`getBlockResponses`/`submitPollResponse`/`submitFormResponse` in -// `api.ts`). - -import type { BlockResponseActions, BlockResponseSubmitResult } from "@corbits/chat-ui"; -import { - CHAT_STRINGS, - ChatApiError, - getBlockResponses, - submitFormResponse, - submitPollResponse, - submitQuestionResponse, -} from "@corbits/chat-ui"; - -export function createChatBlockResponseActions( - tenantId: string, - workbenchId: string, -): BlockResponseActions { - async function submit(call: () => Promise): Promise { - try { - await call(); - return { kind: "submitted" }; - } catch (cause) { - if (cause instanceof ChatApiError && cause.status === 403) { - return { - kind: "forbidden", - message: CHAT_STRINGS.blockFormSubmitForbidden, - }; - } - return { - kind: "error", - message: cause instanceof Error ? cause.message : CHAT_STRINGS.blockFormSubmitError, - }; - } - } - - return { - async getResponses(messageId, blockId) { - try { - const result = await getBlockResponses(tenantId, workbenchId, messageId, blockId); - return { - kind: "ready", - tally: result.tally, - total: result.total, - own: result.own, - }; - } catch (cause) { - if (cause instanceof ChatApiError && cause.status === 403) { - return { kind: "forbidden" }; - } - return { - kind: "error", - message: cause instanceof Error ? cause.message : "Couldn't load responses.", - }; - } - }, - submitPoll(messageId, blockId, choiceIds) { - return submit(() => submitPollResponse(tenantId, workbenchId, messageId, blockId, choiceIds)); - }, - submitForm(messageId, blockId, values) { - return submit(() => submitFormResponse(tenantId, workbenchId, messageId, blockId, values)); - }, - submitQuestion(messageId, blockId, answer, optionIndex) { - return submit(() => - submitQuestionResponse(tenantId, workbenchId, messageId, blockId, answer, optionIndex), - ); - }, - }; -} diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index a749fa1740..7e426ade51 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -42,7 +42,7 @@ function scheduled( } describe("computeInsightsStats", () => { - test("counts purposeful runs by status and drops workbench hosts", () => { + test("counts purposeful runs by status", () => { const stats = computeInsightsStats( [ run({ @@ -60,12 +60,6 @@ describe("computeInsightsStats", () => { status: "stopped", createdAt: "2026-01-01T00:00:00.000Z", }), - run({ - id: "host", - status: "running", - definitionName: "ins-0f1e2d3c4b5a69788796a5b4c3d2e1f0", - createdAt: "2026-01-04T00:00:00.000Z", - }), ], [ scheduled({ definitionId: "r1", status: "deployed" }), @@ -128,17 +122,8 @@ describe("computeInsightsStats", () => { }); describe("purposeRunsForInsights", () => { - const workbenchHost = run({ - id: "host", - status: "running", - definitionName: "ins-0f1e2d3c4b5a69788796a5b4c3d2e1f0", - }); const deployment = run({ id: "ins_deployed", status: "running" }); - test("drops a workbench-host run by its definition-name pattern", () => { - expect(purposeRunsForInsights([deployment, workbenchHost])).toEqual([deployment]); - }); - test("leaves an ordinary top-level deployment run alone", () => { expect(purposeRunsForInsights([deployment])).toEqual([deployment]); }); diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index f52ff25fe5..a5aeceaadb 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -2,7 +2,6 @@ // routines). No new analytics backend — I1 is an honest live surface on // existing endpoints. -import { isWorkbenchHostDefinitionName } from "@corbits/chat-ui/wire/workbench-host-naming"; import { runOutcomeStatus, withListingAbandoned } from "@corbits/workflows/client"; import type { InsightsRun } from "./insights-api"; @@ -38,21 +37,15 @@ export type InsightsStats = { export const INSIGHTS_RECENT_LIMIT = 12; /** - * Purpose runs only — drop workbench-host anchors the same way Home does. - * `insights-page.tsx` sources `runs` from `insightsTopLevelRunsPath` (see - * `./insights-api.ts`), the native `GET /workflows/runs` top-level - * listing (CL-8087) whose own predicate already excludes every - * non-top-level run (workbench host, invited agent). This filter is a - * client-side belt-and-suspenders pass against the workbench-host naming - * pattern alone, not a second scoping layer — a caller no longer needs - * to (and cannot) hand this a non-top-level run id set. CL-6062 replaced - * the dead `/me/workflows/runs` feed (its `anchorRunId IS NULL` filter - * never matched anything, since every addressed run self-anchors at - * creation) with a scoped feed; CL-8087 repointed that scoped feed from - * the deleted `feed=fires` route to the native listing. + * Purpose runs only. `insights-page.tsx` sources `runs` from + * `insightsTopLevelRunsPath` (see `./insights-api.ts`), the native `GET + * /workflows/runs` top-level listing (CL-8087) whose own predicate already + * excludes every non-top-level run — no workbench-host anchors exist to + * filter post-cutover (CL-8175), so this is now an identity pass kept for + * callers that still name it explicitly. */ export function purposeRunsForInsights(runs: readonly InsightsRun[]): readonly InsightsRun[] { - return runs.filter((run) => !isWorkbenchHostDefinitionName(run.definitionName)); + return runs; } /** diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index fc7be989c0..0d5e73fc01 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -5,7 +5,6 @@ import { libraryArtifactPath } from "@corbits/artifact-ui"; import { describeApiError } from "@corbits/api-query"; -import { listPrincipals } from "@corbits/settings-ui"; import { ChatWorkspace, fetchWorkbenchBlob, type Part } from "@corbits/chat-ui"; import { getResolvedCatalog, @@ -16,7 +15,6 @@ import { useCallback, useEffect, useMemo } from "react"; import { fetchArtifactDetail } from "../api"; import { createChatApprovalActions } from "../approval-actions"; -import { createChatBlockResponseActions } from "../block-response-actions"; import { createChatConnectServiceActions } from "../connect-service-actions"; import { useBench } from "../bench-context"; import { useSignOut, useSessionUser } from "../navigation"; @@ -108,13 +106,6 @@ export function ChatPage({ () => (tenantId === null ? undefined : createChatApprovalActions(tenantId, queryClient)), [tenantId, queryClient], ); - const blockResponses = useMemo( - () => - tenantId === null || workbenchId === null - ? undefined - : createChatBlockResponseActions(tenantId, workbenchId), - [tenantId, workbenchId], - ); const connectServiceActions = useMemo( () => (tenantId === null ? undefined : createChatConnectServiceActions(tenantId, path)), [tenantId, path], @@ -131,12 +122,6 @@ export function ChatPage({ // the click). const providerHealthBanner = useProviderHealthBanner(); const requestPluginsConnect = useRequestPluginsConnect(); - const listMembers = useCallback(async (memberTenantId: string) => { - const principals = await listPrincipals(memberTenantId); - return principals - .filter((p) => p.kind === "user" && p.status === "active") - .map((p) => ({ id: p.id, displayName: p.displayName })); - }, []); const handleFixConnection = useCallback(() => { if (providerHealthBanner === null) { @@ -264,9 +249,7 @@ export function ChatPage({ {...(hasUsableModel !== undefined ? { hasUsableModel } : {})} onConnectModel={handleConnectModel} {...(approvalActions !== undefined ? { approvalActions } : {})} - {...(blockResponses !== undefined ? { blockResponses } : {})} {...(connectServiceActions !== undefined ? { connectServiceActions } : {})} - listMembers={listMembers} onWorkbenchNotFound={reportWorkbenchNotFound} onGoToMissionControl={() => navigate(MISSION_CONTROL_PATH)} onNewWorkbench={() => navigate(NEW_WORKBENCH_PATH)} diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 7e179c1d3f..342985851e 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -14,7 +14,6 @@ import type { ArkErrors } from "arktype"; import { Part } from "./wire/parts"; import { parseParticipants } from "./wire/participants"; import type { ParticipantRecord } from "./wire/participants"; -import type { WorkbenchOnboardingStep } from "./wire/blocks"; import { UnauthenticatedError } from "@corbits/api-query"; import { InferenceSettingsApiError } from "@corbits/inference-settings"; import { CHAT_STRINGS } from "./strings"; @@ -29,7 +28,6 @@ export { Part, } from "./wire/parts"; export type { ParticipantRecord } from "./wire/participants"; -export type { OnboardingStepLabel, WorkbenchOnboardingStep } from "./wire/blocks"; export const WorkbenchKind = type("'workbench' | 'chat'"); export type WorkbenchKind = typeof WorkbenchKind.infer; @@ -575,26 +573,6 @@ export function inviteAgent( }); } -const PostedOnboardingStep = type({ id: "string" }); - -/** - * Posts one onboarding step into a room - * (`POST /workbenches/:id/onboarding`): the walkthrough card lands as a - * system row, with no agent launched or woken, so an empty channel can - * run its onboarding with nobody in the room yet. - */ -export function postWorkbenchOnboardingStep( - tenantId: string, - workbenchId: string, - step: WorkbenchOnboardingStep, -): Promise<{ readonly id: string }> { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/onboarding`, - PostedOnboardingStep, - { method: "POST", body: JSON.stringify(step) }, - ); -} - // `DELETE /workbenches/:id/participants/:address` (see // `packages/chat/src/routes.ts`): the removal counterpart to // `inviteAgent`/workbench creation's own join — drops the participant and, @@ -849,115 +827,6 @@ export function pingWorkbenchPresence(tenantId: string, workbenchId: string): Pr .catch(() => undefined); } -// `POST`/`GET .../blocks/:blockId/responses` (see -// `packages/chat/src/routes.ts`): the poll/form round-trip. `own` is only -// ever this signed-in principal's own response — a poll's `tally` is the -// one place another principal's participation shows up at all, and only as -// an anonymous count, never whose it was. -const BlockResponsePayloadWire = type({ - kind: "'poll'", - choiceIds: "string[]", -}) - .or(type({ kind: "'form'", values: "Record" })) - .or( - type({ - kind: "'question'", - answer: "string", - "optionIndex?": "number", - notifiedAt: "string | null", - }), - ); -export type BlockResponsePayload = typeof BlockResponsePayloadWire.infer; - -const BlockResponsesWire = type({ - tally: "Record", - total: "number", - own: BlockResponsePayloadWire.or("null"), -}); -export type BlockResponses = typeof BlockResponsesWire.infer; - -const SubmittedBlockResponse = type({ - blockId: "string", - updatedAt: "string", -}); - -function blockResponsesPath( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, -): string { - return ( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages/` + - `${messageId}/blocks/${blockId}/responses` - ); -} - -export function getBlockResponses( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, -): Promise { - return request(blockResponsesPath(tenantId, workbenchId, messageId, blockId), BlockResponsesWire); -} - -export function submitPollResponse( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, - choiceIds: readonly string[], -): Promise { - return request( - blockResponsesPath(tenantId, workbenchId, messageId, blockId), - SubmittedBlockResponse, - { - method: "POST", - body: JSON.stringify({ kind: "poll", choiceIds }), - }, - ).then(() => undefined); -} - -export function submitFormResponse( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, - values: Readonly>, -): Promise { - return request( - blockResponsesPath(tenantId, workbenchId, messageId, blockId), - SubmittedBlockResponse, - { - method: "POST", - body: JSON.stringify({ kind: "form", values }), - }, - ).then(() => undefined); -} - -export function submitQuestionResponse( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, - answer: string, - optionIndex?: number, -): Promise { - return request( - blockResponsesPath(tenantId, workbenchId, messageId, blockId), - SubmittedBlockResponse, - { - method: "POST", - body: JSON.stringify( - optionIndex !== undefined - ? { kind: "question", answer, optionIndex } - : { kind: "question", answer }, - ), - }, - ).then(() => undefined); -} - // `chat/contextWindow`'s two-way "inherit vs override" resolution — see // `resolveContextWindow` in `packages/chat/src/workbench-settings.ts`, whose // server-side output this wire shape mirrors. `source` is what the settings diff --git a/packages/chat-ui/src/blocks/block-responses.ts b/packages/chat-ui/src/blocks/block-responses.ts deleted file mode 100644 index 36119ac1f7..0000000000 --- a/packages/chat-ui/src/blocks/block-responses.ts +++ /dev/null @@ -1,90 +0,0 @@ -// The in-chat poll/form cards' one seam to the hub's chat response -// routes. `@corbits/chat-ui` owns no session, so it never fetches or -// mutates responses itself -- the host supplies this port, mirroring how -// `ApprovalActions` threads the approve card's round-trip through -// `ChatWorkspace`/`WorkbenchTimeline` down to the card. -// -// A poll's live tally and a form's "already submitted" state are always -// re-read from this port after every vote/submit -- never assumed from the -// optimistic UI update or, worse, from the block's own agent-authored data -// (the same anti-spoof rule the approve card follows: tallies and "you -// voted" state are facts about stored responses, never facts an agent gets -// to assert in the message it wrote). - -export type PollResponsePayload = { - readonly kind: "poll"; - readonly choiceIds: readonly string[]; -}; - -export type FormResponsePayload = { - readonly kind: "form"; - readonly values: Readonly>; -}; - -export type QuestionResponsePayload = { - readonly kind: "question"; - readonly answer: string; - readonly optionIndex?: number; - /** - * ISO timestamp once the asking agent was notified, or `null` when - * the answer is on file but notify never landed. Omitted only in - * tests that are not exercising the retry path — treat missing as - * already notified. - */ - readonly notifiedAt?: string | null; -}; - -export type BlockResponsePayload = - | PollResponsePayload - | FormResponsePayload - | QuestionResponsePayload; - -/** - * The live read behind a poll/form card. `own` is this signed-in - * principal's own response and nothing else's -- a poll's `tally` is the - * one place another principal's participation is visible at all, and only - * as an anonymous count. - */ -export type BlockResponseQuery = - | { readonly kind: "loading" } - | { - readonly kind: "ready"; - readonly tally: Readonly>; - readonly total: number; - readonly own: BlockResponsePayload | null; - } - | { readonly kind: "forbidden" } - | { readonly kind: "error"; readonly message: string }; - -export type BlockResponseSubmitResult = - | { readonly kind: "submitted" } - | { readonly kind: "forbidden"; readonly message: string } - | { readonly kind: "error"; readonly message: string }; - -export type BlockResponseActions = { - /** The live tally/own-response read for one block instance, addressed by - * the message it lives in plus its own `pollId`/`formId` -- the same - * (messageId, blockId) scope the server keys responses by, so a vote can - * never be read back against the wrong message's block. */ - readonly getResponses: (messageId: string, blockId: string) => Promise; - /** Casts (or changes) this principal's vote. */ - readonly submitPoll: ( - messageId: string, - blockId: string, - choiceIds: readonly string[], - ) => Promise; - /** Submits (or resubmits) this principal's form response. */ - readonly submitForm: ( - messageId: string, - blockId: string, - values: Readonly>, - ) => Promise; - /** Submits this principal's answer to a question -- the chosen option's - * label (or free text) plus, for a lettered option, its index. */ - readonly submitQuestion: ( - messageId: string, - blockId: string, - answer: string, - optionIndex?: number, - ) => Promise; -}; diff --git a/packages/chat-ui/src/blocks/connect-github-actions.ts b/packages/chat-ui/src/blocks/connect-github-actions.ts deleted file mode 100644 index bf41f838cf..0000000000 --- a/packages/chat-ui/src/blocks/connect-github-actions.ts +++ /dev/null @@ -1,94 +0,0 @@ -// The connect-github card's one seam to the platform connection it needs -// (CL-6345) — mirroring `ApprovalActions`/`BlockResponseActions`: -// `@corbits/chat-ui` owns no session, no credential, and no query cache, -// so it never resolves a GitHub connection or lists repos itself. The -// host supplies this port, and is expected to bind it against -// `@corbits/connections`' generic `/:connectorId/complete` route -// (`github`'s PAT test-and-store — already fully generic, no bespoke -// GitHub route needed), `@corbits/github-tools`' `listRepos`, and -// `@corbits/connections`'s `startReviewingRepos`. -// -// Hub-zero T3 (CL-8114): no host binds this port right now — the hub's -// workbench-scoped state/start-reviewing routes are deleted and no native -// equivalent exists yet, so the room card renders its no-port disabled -// framing. The shape stays so a connections follow-up can rebind it -// without redesigning the card. -import type { ConnectGithubRepo } from "./connect-github-block"; -export type { ConnectGithubRepo }; - -export type ConnectGithubQuery = - | { readonly kind: "loading" } - | { readonly kind: "disconnected" } - | { - readonly kind: "connected"; - readonly orgName: string; - readonly repos: readonly ConnectGithubRepo[]; - /** Repos already recorded as selected — the room's own - * `template/selectedRepos` setting, never a client guess. */ - readonly selectedRepoIds: readonly string[]; - } - | { readonly kind: "error"; readonly message: string }; - -export type ConnectGithubActions = { - /** The live read behind the card, resolved against the real - * connection and the room's own settings — never derived from the - * message's own `ConnectGithubBlockData`. */ - readonly getConnectState: (messageId: string) => Promise; - /** - * Registers for this card's state updates — the host fans an update - * out to every subscriber after its own actions (`submitAccessToken`, - * `startReviewing`, `skip`) change something, re-reading - * `getConnectState`. A credential completed elsewhere (the Plugins - * page, another tab) settles this connector's entry on - * `@corbits/connections/template-settings`'s `template/pendingConnections` - * (CL-6463's `settleConnectedService`) and publishes `chat.settings`; - * ChatWorkspace parses that event and calls `notifySettingsChanged` - * so a card already mounted flips without remounting (CL-6476). - * Returns an unsubscribe. - */ - readonly subscribeConnectState: ( - messageId: string, - onUpdate: (state: ConnectGithubQuery) => void, - ) => () => void; - /** - * Re-reads live connect state and fans it to every - * `subscribeConnectState` listener. ChatWorkspace calls this when a - * parsed `chat.settings` event lands — `settleConnectedService` - * already publishes that event when a credential completes out of - * band, and a card already mounted must flip without remounting - * (CL-6476). - */ - readonly notifySettingsChanged: () => Promise; - /** - * Both the card's "Connect GitHub" and "Use an access token instead" - * actions funnel here — this repo's connect cards are PAT-first - * today (CL-6345); a GitHub App/OAuth `onConnect` path is CL-6343, - * explicitly out of scope. The host opens whatever PAT-entry surface - * it already presents for other connectors; this call itself returns - * nothing, and a successful connect is expected to arrive back - * through the same `subscribeConnectState` channel this card already - * holds open — never a return value the card would have to poll. - */ - readonly requestConnect: () => void; - /** - * Submits a pasted personal access token — the actual PAT-first - * connect path (CL-6345/CL-6344's follow-up slice): the host tests - * and stores it through `@corbits/connections`' generic - * `github/complete` route, then clears this room's own - * `template/pendingConnections` entry for `"github"` so the same - * `subscribeConnectState` channel `requestConnect`'s own doc - * describes reflects the new connected state. Resolves `{ ok: false, - * message }` on a rejected token rather than throwing, so the card's - * inline field can show the failure without a modal. - */ - readonly submitAccessToken: ( - token: string, - ) => Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }>; - /** Mints a grant and a live webhook trigger per repo id, then - * records the selection through the host's own `startReviewingRepos` - * binding. */ - readonly startReviewing: ( - repoIds: readonly string[], - ) => Promise<{ readonly startedTriggerCount: number }>; - readonly skip: () => Promise; -}; diff --git a/packages/chat-ui/src/blocks/connect-github-block-container.tsx b/packages/chat-ui/src/blocks/connect-github-block-container.tsx deleted file mode 100644 index 32b6598481..0000000000 --- a/packages/chat-ui/src/blocks/connect-github-block-container.tsx +++ /dev/null @@ -1,253 +0,0 @@ -// Wires the presentational `ConnectGithubBlockView` to a live -// `ConnectGithubActions` port (CL-6345) — mirroring `PollBlockView`'s -// own container shape: an initial `getConnectState` read on mount, plus -// a live `subscribeConnectState` fold for every update after. With no -// port at all, the card renders the same fixed-disabled disconnected -// framing every other block's "no port, no feature" fallback uses. -// -// CL-6463: a card's own successful PAT submit is the one change this -// container never waits on the host to fan out on its own — a credential -// saved through *this* card's field gets its own explicit `getConnectState` -// refetch below, run once as the direct consequence of that one submit -// (not a poll). A credential saved anywhere else (the Plugins page, -// another tab) settles through `packages/chat/src/connect-pending.ts`'s -// `settleConnectedService`, which publishes `chat.settings`; ChatWorkspace -// parses that event and calls `notifySettingsChanged` so a card already -// mounted flips without remounting (CL-6476). -// -// CL-6741: once a card has read connected, a later loading/error fold -// (or a remount that starts on loading) must keep the last connected -// snapshot — never flash DisconnectedBody / "Connect" again over a -// known-good connection. An explicit `disconnected` result clears the -// snapshot so a real disconnect still shows Connect. -import { useCallback, useEffect, useRef, useState } from "react"; -import type { ConnectGithubBlockData } from "../wire/blocks"; -import { reportError } from "@corbits/error-sink"; - -import { CHAT_STRINGS } from "../strings"; -import type { ConnectGithubActions, ConnectGithubQuery } from "./connect-github-actions"; -import type { OnboardingScene } from "./connect-github-block"; -import { ConnectGithubBlockView } from "./connect-github-block"; - -/** Positions in the room's three-step walkthrough. Which one is current - * is read off the live connect state, never off the card's own data: - * disconnected means connect, connected with nothing recorded (or a - * person who pressed "change repos") means pick, and repos the server - * actually recorded means reviewing. */ -const STEP_CONNECT = 0; -const STEP_PICK = 1; -const STEP_REVIEWING = 2; - -type ConnectedGithubQuery = Extract; - -/** Survives container remounts so a post-connect loading flash never - * resets the card to Connect (CL-6741). Cleared only on an explicit - * disconnected result. */ -const lastConnectedByMessageId = new Map(); - -function rememberConnected(messageId: string, query: ConnectedGithubQuery) { - lastConnectedByMessageId.set(messageId, query); -} - -function forgetConnected(messageId: string) { - lastConnectedByMessageId.delete(messageId); -} - -function lastConnectedOf(messageId: string): ConnectedGithubQuery | undefined { - return lastConnectedByMessageId.get(messageId); -} - -/** A state read that fails is an error the card says out loud, never a - * silent fall-through to "Connect GitHub" over a connection that - * exists (CL-7189): an unhandled rejection used to leave the query on - * `loading`, which renders the disconnected body. */ -async function readConnectState( - actions: ConnectGithubActions, - messageId: string, -): Promise { - try { - return await actions.getConnectState(messageId); - } catch (cause) { - reportError(cause, { operation: "connect-github.getConnectState" }); - return { - kind: "error", - message: CHAT_STRINGS.blockConnectGithubStateUnreadable, - }; - } -} - -function displayQueryOf(messageId: string, query: ConnectGithubQuery): ConnectGithubQuery { - if (query.kind === "connected" || query.kind === "disconnected") { - return query; - } - const prior = lastConnectedOf(messageId); - return prior ?? query; -} - -export function ConnectGithubBlockContainer({ - data, - messageId, - actions, -}: { - readonly data: ConnectGithubBlockData; - readonly messageId: string; - readonly actions?: ConnectGithubActions; -}) { - const [query, setQuery] = useState(() => { - return lastConnectedOf(messageId) ?? { kind: "loading" }; - }); - const [selectedRepoIds, setSelectedRepoIds] = useState( - () => lastConnectedOf(messageId)?.selectedRepoIds ?? [], - ); - /** A person who pressed "change repos" on the done state gets the - * picker back without the server's recorded selection changing — only - * pressing "Start reviewing" again writes anything. */ - const [repickRequested, setRepickRequested] = useState(false); - const [startReviewingError, setStartReviewingError] = useState(undefined); - const mountedRef = useRef(true); - const hadPickerRef = useRef(false); - const hadConnectRef = useRef(false); - - const applyQuery = useCallback( - (result: ConnectGithubQuery) => { - if (!mountedRef.current) return; - if (result.kind === "connected") { - rememberConnected(messageId, result); - setSelectedRepoIds(result.selectedRepoIds); - } else if (result.kind === "disconnected") { - forgetConnected(messageId); - } - setQuery(result); - }, - [messageId], - ); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - useEffect(() => { - if (actions === undefined) return; - void readConnectState(actions, messageId).then(applyQuery); - const unsubscribe = actions.subscribeConnectState(messageId, applyQuery); - return unsubscribe; - }, [actions, messageId, applyQuery]); - - // The one refetch this container ever runs outside its mount effect: - // a submit this card itself just made succeeded, so re-reading the - // card's own state is a direct consequence of that submit — never a - // poll, and it runs whether or not the host's `subscribeConnectState` - // happens to fan the change out on its own. - const submitAccessTokenAndRefresh = useCallback( - async (token: string) => { - if (actions === undefined) { - return { ok: false as const, message: "Not available." }; - } - const result = await actions.submitAccessToken(token); - if (result.ok) { - applyQuery(await readConnectState(actions, messageId)); - } - return result; - }, - [actions, messageId, applyQuery], - ); - - const displayQuery = displayQueryOf(messageId, query); - const recordedRepoIds = displayQuery.kind === "connected" ? displayQuery.selectedRepoIds : []; - const currentStepIndex = - displayQuery.kind !== "connected" - ? STEP_CONNECT - : recordedRepoIds.length === 0 || repickRequested - ? STEP_PICK - : STEP_REVIEWING; - if (currentStepIndex === STEP_CONNECT) hadConnectRef.current = true; - if (currentStepIndex === STEP_PICK) hadPickerRef.current = true; - const scene: OnboardingScene = { - title: data.requiredForTemplate, - currentStepIndex, - ...(data.promise !== undefined ? { promise: data.promise } : {}), - ...(data.steps !== undefined ? { steps: data.steps } : {}), - }; - - if (displayQuery.kind === "error") { - return ( - actions?.requestConnect()} - onSubmitAccessToken={submitAccessTokenAndRefresh} - /> - ); - } - - if (actions === undefined || displayQuery.kind !== "connected") { - return ( - actions?.requestConnect()} - onSubmitAccessToken={submitAccessTokenAndRefresh} - /> - ); - } - - if (currentStepIndex === STEP_REVIEWING) { - return ( - recordedRepoIds.includes(repo.id)) - .map((repo) => repo.name)} - onChangeRepos={() => setRepickRequested(true)} - {...(hadPickerRef.current ? { autoFocus: true } : {})} - /> - ); - } - - const connectedActions = actions; - - function toggleRepo(repoId: string) { - setSelectedRepoIds((current) => - current.includes(repoId) ? current.filter((id) => id !== repoId) : [...current, repoId], - ); - } - - async function startReviewing(repoIds: readonly string[]) { - try { - setStartReviewingError(undefined); - await connectedActions.startReviewing(repoIds); - if (mountedRef.current) setRepickRequested(false); - } catch (cause) { - reportError(cause, { operation: "connect-github.startReviewing" }); - if (mountedRef.current) { - setStartReviewingError(CHAT_STRINGS.blockConnectGithubStartReviewingError); - } - } - } - - return ( - setSelectedRepoIds(displayQuery.repos.map((repo) => repo.id))} - onChangeConnection={connectedActions.requestConnect} - onStartReviewing={(repoIds) => { - void startReviewing(repoIds); - }} - onSkip={() => { - void connectedActions.skip(); - }} - {...(startReviewingError !== undefined ? { error: startReviewingError } : {})} - {...(hadConnectRef.current ? { autoFocus: true } : {})} - /> - ); -} diff --git a/packages/chat-ui/src/blocks/connect-github-block.tsx b/packages/chat-ui/src/blocks/connect-github-block.tsx deleted file mode 100644 index e81af98f45..0000000000 --- a/packages/chat-ui/src/blocks/connect-github-block.tsx +++ /dev/null @@ -1,479 +0,0 @@ -// The room's first-minute scene card: one `BlockCard` that names the job, -// carries the walkthrough the workbench's own onboarding copy wrote, and -// flips its body between connecting, picking repos, and reviewing -- -// inline in the room, never a settings page or a dialog. -// Selection is controlled: like `PollBlockView` never keeps its own tally, -// this view never owns which repos are picked -- it renders what it's given -// and reports toggles upward. It stays pure and props-driven so it can be -// exercised and reviewed without a server; `./connect-github-block-container.tsx` -// is what wires these callbacks to a live `ConnectGithubActions` port -// (CL-6345), the same way `PollBlockView`'s own container wires it to -// `BlockResponseActions`. -// -// The repo row's control is `@corbits/react-ui`'s own `Checkbox` (bare -// mode) rather than a hand-rolled input -- AGENTS.md puts generic controls -// upstream in react-ui, not here. Its corner radius is react-ui's rounded -// default, a visual delta from the mock's flat radius-0 system; the row -// layout around it (name left, last-push time right) is workbench-specific -// composition and stays local. - -import { useEffect, useRef, useState } from "react"; -import { Button, Checkbox, Input } from "@corbits/react-ui"; -import { Check } from "@corbits/icons"; - -import { formatRelativeActivity } from "../relative-time"; -import { CHAT_STRINGS } from "../strings"; -import { BlockCard } from "./block-card"; - -export type ConnectGithubRepo = { - readonly id: string; - readonly name: string; - /** When GitHub last saw a push, ISO-8601 — absent on a repo with no - * commits yet. It is what the picker's ordering is explaining. */ - readonly lastPushedAt?: string; -}; - -/** One labelled step of the room's walkthrough, as the workbench's own - * onboarding copy wrote it — the card never holds step text of its own. */ -export type OnboardingSceneStep = { - readonly title: string; - readonly why: string; -}; - -/** The framing every state of this card keeps: the job the room is here - * to do, the one-line promise under it, and the ordered walkthrough. The - * card flips its body between states while this header stays put. */ -export type OnboardingScene = { - readonly title: string; - readonly promise?: string; - readonly steps?: readonly OnboardingSceneStep[]; - /** Which step of `steps` the person is on now, by position. */ - readonly currentStepIndex: number; -}; - -/** The walkthrough this card knows how to mark: connect, pick, review. - * A workbench whose steps read differently still gets its labels - * rendered — just without a "you're here" marker the positions would - * only be guessing at. */ -const MARKABLE_STEP_COUNT = 3; - -/** What the card's body is doing right now, without the scene framing - * every state shares — the shape a host names when it only cares about - * the body variant. */ -export type ConnectGithubCardBody = - | { - readonly kind: "disconnected"; - readonly onConnect: () => void; - /** Submits a pasted personal access token — the only connect path - * this repo builds today (CL-6345's PAT-first card; a GitHub - * App/OAuth `onConnect` path is CL-6343, out of scope). Resolves - * `{ ok: false, message }` on a rejected token rather than - * throwing, so the inline field can show the failure without a - * modal. */ - readonly onSubmitAccessToken: ( - token: string, - ) => Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }>; - } - | { - readonly kind: "error"; - readonly message: string; - readonly onConnect: () => void; - readonly onSubmitAccessToken: ( - token: string, - ) => Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }>; - } - | { - readonly kind: "connected"; - readonly orgName: string; - readonly repos: readonly ConnectGithubRepo[]; - readonly selectedRepoIds: readonly string[]; - readonly onToggleRepo: (repoId: string) => void; - readonly onSelectAll: () => void; - readonly onChangeConnection: () => void; - readonly onStartReviewing: (repoIds: readonly string[]) => void; - readonly onSkip: () => void; - /** Set when `onStartReviewing` rejected — the picker stays up and - * this is the card's own alert, never a toast-only failure. */ - readonly error?: string; - /** Move focus onto the pick-repos heading after Connect succeeded, - * so it does not stay on a control that unmounted. */ - readonly autoFocus?: boolean; - } - | { - readonly kind: "reviewing"; - readonly repoNames: readonly string[]; - readonly onChangeRepos: () => void; - /** Move focus onto the reviewing scene after Start reviewing - * succeeded, so it does not stay on a control that unmounted. */ - readonly autoFocus?: boolean; - }; - -export type ConnectGithubCardProps = ConnectGithubCardBody & { - readonly scene: OnboardingScene; -}; - -function repoMetaLabel(lastPushedAt: string | undefined): string { - if (lastPushedAt === undefined) { - return CHAT_STRINGS.blockConnectGithubRepoNeverPushed; - } - const relative = formatRelativeActivity(lastPushedAt); - return relative === "" - ? CHAT_STRINGS.blockConnectGithubRepoNeverPushed - : CHAT_STRINGS.blockConnectGithubRepoUpdated(relative); -} - -type StepState = "done" | "current" | "upcoming"; - -function stepStateAt(index: number, currentStepIndex: number): StepState { - if (index < currentStepIndex) return "done"; - if (index === currentStepIndex) return "current"; - return "upcoming"; -} - -function stepMarkLabel(state: StepState): string | undefined { - if (state === "done") return CHAT_STRINGS.blockConnectGithubStepDone; - if (state === "current") return CHAT_STRINGS.blockConnectGithubStepCurrent; - return undefined; -} - -/** The card's constant framing: what this room is for, the promise under - * it, and where the person is in the walkthrough. Every label here is the - * workbench's own onboarding copy, never this package's. */ -function SceneHeader({ scene }: { readonly scene: OnboardingScene }) { - const steps = scene.steps; - const marked = steps !== undefined && steps.length === MARKABLE_STEP_COUNT; - const currentWhy = marked ? steps[scene.currentStepIndex]?.why : undefined; - return ( - <> - {scene.promise !== undefined ? ( -

{scene.promise}

- ) : null} - {steps !== undefined && steps.length > 0 ? ( -
    - {steps.map((step, index) => { - const state = stepStateAt(index, scene.currentStepIndex); - const mark = marked ? stepMarkLabel(state) : undefined; - return ( -
  1. - {step.title} - {mark !== undefined ? ( - {mark} - ) : null} -
  2. - ); - })} -
- ) : null} - {currentWhy !== undefined ? ( -

{currentWhy}

- ) : null} - - ); -} - -/** The walkthrough's done state: the repos under review, what happens in - * them now, and one quiet way back to the picker. Deliberately says - * nothing about connecting — this card is past that. */ -function ReviewingBody({ - repoNames, - onChangeRepos, - autoFocus, -}: { - readonly repoNames: readonly string[]; - readonly onChangeRepos: () => void; - readonly autoFocus?: boolean; -}) { - const sceneRef = useRef(null); - useEffect(() => { - if (autoFocus === true) sceneRef.current?.focus(); - }, [autoFocus]); - return ( -
-

- - {CHAT_STRINGS.blockConnectGithubReviewingHeadline} -

-
    - {repoNames.map((name) => ( -
  • {name}
  • - ))} -
-

- {CHAT_STRINGS.blockConnectGithubReviewingLine} -

-
- -
-
- ); -} - -function DisconnectedBody({ - onConnect, - onSubmitAccessToken, - error: stateError, - actionLabel, -}: { - readonly onConnect: () => void; - readonly onSubmitAccessToken: ( - token: string, - ) => Promise<{ readonly ok: true } | { readonly ok: false; readonly message: string }>; - readonly error?: string; - readonly actionLabel?: string; -}) { - const [fieldOpen, setFieldOpen] = useState(false); - const [token, setToken] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(stateError); - - function openField() { - onConnect(); - setFieldOpen(true); - } - - async function submit() { - if (token.trim() === "" || submitting) return; - setSubmitting(true); - setError(undefined); - const result = await onSubmitAccessToken(token.trim()); - setSubmitting(false); - if (!result.ok) { - setError(result.message); - return; - } - setToken(""); - setFieldOpen(false); - } - - if (fieldOpen) { - return ( - <> -

{CHAT_STRINGS.blockConnectGithubIntro}

-
    - {CHAT_STRINGS.blockConnectGithubTokenSteps.map((step) => ( -
  1. {step}
  2. - ))} -
-

- - {CHAT_STRINGS.blockConnectGithubTokenSettingsLink} - -

- - { - setToken(event.target.value); - }} - disabled={submitting} - {...(error !== undefined ? { "aria-invalid": true } : {})} - /> - {error !== undefined ? ( -

- {error} -

- ) : null} -
- - -
- - ); - } - - return ( - <> - {error !== undefined ? ( -

- {error} -

- ) : null} -

{CHAT_STRINGS.blockConnectGithubIntro}

-
- -
-

- {CHAT_STRINGS.blockConnectGithubTokenHelper} -

- - ); -} - -function ConnectedBody({ - orgName, - repos, - selectedRepoIds, - onToggleRepo, - onSelectAll, - onChangeConnection, - onStartReviewing, - onSkip, - error, - autoFocus, -}: Extract) { - const headingRef = useRef(null); - useEffect(() => { - if (autoFocus === true) headingRef.current?.focus(); - }, [autoFocus]); - const pickedCount = selectedRepoIds.length; - return ( - <> -

- {CHAT_STRINGS.blockConnectGithubPickHeadline} -

-

- - {CHAT_STRINGS.blockConnectGithubConnectedAs(orgName)} - -

- -
- {CHAT_STRINGS.blockConnectGithubRepoCount(repos.length, pickedCount)} - -
- -
- {repos.map((repo) => { - const selected = selectedRepoIds.includes(repo.id); - const checkboxId = `connect-github-repo-${repo.id}`; - return ( -
- onToggleRepo(repo.id)} - /> - -
- ); - })} -
- -

- {CHAT_STRINGS.blockConnectGithubPermissionHelper} -

- - {error !== undefined ? ( -

- {error} -

- ) : null} - -
- - -
- - ); -} - -export function ConnectGithubBlockView(props: ConnectGithubCardProps) { - const currentStepTitle = props.scene.steps?.[props.scene.currentStepIndex]?.title; - return ( - - -
- {currentStepTitle ?? null} -
-
- {props.kind === "disconnected" ? ( - - ) : null} - {props.kind === "error" ? ( - - ) : null} - {props.kind === "connected" ? : null} - {props.kind === "reviewing" ? ( - - ) : null} -
-
- ); -} diff --git a/packages/chat-ui/src/blocks/form-block.tsx b/packages/chat-ui/src/blocks/form-block.tsx deleted file mode 100644 index 8f6f48af9b..0000000000 --- a/packages/chat-ui/src/blocks/form-block.tsx +++ /dev/null @@ -1,272 +0,0 @@ -// A form field's submitted value never comes from anything but this -// principal's own round-trip: the "submitted" state and its pre-filled -// values are read back from `BlockResponseActions.getResponses`'s `own` -// after every submit, never assumed from the values still sitting in local -// state. Other principals' submissions are never fetched or shown here at -// all -- the server-side aggregation never puts them on the wire for a -// form block in the first place (see `packages/chat/src/routes.ts`). - -import { Button } from "@corbits/react-ui"; -import { useEffect, useState } from "react"; -import type { FormEvent } from "react"; -import type { FormBlockData } from "../wire/blocks"; - -import { CHAT_STRINGS } from "../strings"; -import { BlockCard } from "./block-card"; -import type { BlockResponseActions, BlockResponseQuery } from "./block-responses"; - -type FieldValues = Readonly>; - -function defaultValues(data: FormBlockData): FieldValues { - const values: Record = {}; - for (const field of data.fields) { - values[field.id] = field.value ?? ""; - } - return values; -} - -function ownValues(query: BlockResponseQuery): FieldValues | null { - if (query.kind !== "ready" || query.own === null) return null; - return query.own.kind === "form" ? query.own.values : null; -} - -function isBlank(field: FormBlockData["fields"][number], value: string): boolean { - return field.input === "checkbox" ? value !== "true" : value.trim() === ""; -} - -function validate(data: FormBlockData, values: FieldValues): Readonly> { - const errors: Record = {}; - for (const field of data.fields) { - if (field.required === true && isBlank(field, values[field.id] ?? "")) { - errors[field.id] = CHAT_STRINGS.blockFormFieldRequired; - } - } - return errors; -} - -function FieldControl({ - fieldId, - field, - value, - disabled, - onChange, -}: { - readonly fieldId: string; - readonly field: FormBlockData["fields"][number]; - readonly value: string; - readonly disabled: boolean; - readonly onChange: (value: string) => void; -}) { - if (field.input === "textarea") { - return ( -