diff --git a/apps/web/src/connect-github-actions.ts b/apps/web/src/connect-github-actions.ts index e108241a8..31c47da96 100644 --- a/apps/web/src/connect-github-actions.ts +++ b/apps/web/src/connect-github-actions.ts @@ -12,13 +12,14 @@ // own. Reading state and starting reviews call the workbench-scoped routes // `@corbits/connections`'s `createConnectGithubRoutes` exposes. // -// `subscribeConnectState` has no server push to fold from yet (connecting a -// credential publishes no `chat.settings` event of its own — only -// `start-reviewing` does, through the settings PATCH `startReviewing` -// triggers). Every action that can change the card's state -// (`submitAccessToken`, `startReviewing`, `skip`) re-reads `getConnectState` -// itself and fans the result out to every subscriber, so the card still -// only ever fires one fetch per real change — never a poll loop. +// `subscribeConnectState` is the card's live fold. Actions this host +// itself runs (`submitAccessToken`, `startReviewing`, `skip`) re-read +// `getConnectState` and fan the result out. A credential completed +// elsewhere publishes `chat.settings`; ChatWorkspace parses that event +// and calls `notifySettingsChanged`, which reuses the same refresh so +// a mounted card flips without remounting (CL-6476). Every action that +// can change the card's state still only fires one fetch per real +// change — never a poll loop. import type { ConnectGithubActions, ConnectGithubQuery, @@ -56,6 +57,9 @@ export function createChatConnectGithubActions( listeners.delete(onUpdate); }; }, + async notifySettingsChanged() { + await refresh(); + }, requestConnect() { // The card's inline field is what actually collects and submits the // token (`submitAccessToken` below); "Connect GitHub" only opens diff --git a/apps/web/src/connect-service-actions.ts b/apps/web/src/connect-service-actions.ts index b22b2ea33..6ed712826 100644 --- a/apps/web/src/connect-service-actions.ts +++ b/apps/web/src/connect-service-actions.ts @@ -9,8 +9,11 @@ // // OAuth connects navigate the whole page to the hosted start route and // return to this room; the card re-reads its state on remount, and the -// hub's connect-settling hook posts the in-room resume message — so no -// client-side event fold is needed for the flip. +// hub's connect-settling hook posts the in-room resume message. A +// credential completed without leaving the room (another tab, the +// Plugins page) publishes `chat.settings`; ChatWorkspace parses that +// event and calls `notifySettingsChanged` so a mounted card flips +// without remounting (CL-6476). import type { ConnectServiceActions, ConnectServiceQuery, @@ -101,6 +104,18 @@ export function createChatConnectServiceActions( }; } + async function refreshSubscribed(): Promise { + await Promise.all( + [...listeners.keys()].map(async (connectorId) => { + const query = await readState(connectorId).catch(() => ({ + kind: "error" as const, + message: "Couldn't check this connection. Try again.", + })); + fanOut(connectorId, query); + }), + ); + } + return { getConnectState(connectorId) { return readState(connectorId).catch(() => ({ @@ -116,6 +131,9 @@ export function createChatConnectServiceActions( set.delete(onUpdate); }; }, + notifySettingsChanged() { + return refreshSubscribed(); + }, async connect(connectorId) { const slug = bareConnectorId(connectorId); const preset = mcpPresetBySlug(MCP_PRESETS, slug); diff --git a/apps/web/test/connect-github-actions.test.ts b/apps/web/test/connect-github-actions.test.ts new file mode 100644 index 000000000..7d624bff8 --- /dev/null +++ b/apps/web/test/connect-github-actions.test.ts @@ -0,0 +1,56 @@ +// CL-6476: an out-of-band `chat.settings` (credential settled elsewhere) +// must reach every `subscribeConnectState` listener through the host's +// own refresh — never only on the card's next mount. + +import { afterEach, describe, expect, test } from "bun:test"; +import type { ConnectGithubQuery } from "@corbits/chat-ui"; + +import { createChatConnectGithubActions } from "../src/connect-github-actions"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const DISCONNECTED = { kind: "disconnected" as const }; +const CONNECTED = { + kind: "connected" as const, + orgName: "octocat", + repos: [{ id: "1", name: "acme/widgets" }], + selectedRepoIds: [] as string[], +}; + +function stubGithubState(state: () => unknown) { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.endsWith("/github/state")) { + return new Response(JSON.stringify(state()), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unstubbed fetch: ${path}`); + }) as typeof fetch; +} + +describe("createChatConnectGithubActions notifySettingsChanged", () => { + test("refresh fans the latest getConnectState to every subscriber", async () => { + let wire = DISCONNECTED as unknown; + stubGithubState(() => wire); + const actions = createChatConnectGithubActions("tnt_1", "wb_1"); + const received: ConnectGithubQuery[] = []; + const unsubscribe = actions.subscribeConnectState("m1", (state) => { + received.push(state); + }); + + expect(await actions.getConnectState("m1")).toEqual(DISCONNECTED); + expect(received).toEqual([]); + + wire = CONNECTED; + await actions.notifySettingsChanged(); + + expect(received).toEqual([CONNECTED]); + unsubscribe(); + }); +}); diff --git a/apps/web/test/connect-service-actions.test.ts b/apps/web/test/connect-service-actions.test.ts index 4f1e43d20..c78924087 100644 --- a/apps/web/test/connect-service-actions.test.ts +++ b/apps/web/test/connect-service-actions.test.ts @@ -106,4 +106,29 @@ describe("createChatConnectServiceActions with a token preset", () => { kind: "connected", }); }); + + test("notifySettingsChanged re-reads subscribed connectors and fans listeners", async () => { + let listed = PRESET_LIST; + stubFetch(() => new Response(JSON.stringify(listed))); + const actions = createChatConnectServiceActions("tnt_1", "/bench"); + const received: unknown[] = []; + const unsubscribe = actions.subscribeConnectState("github-mcp", (query) => { + received.push(query); + }); + + expect(await actions.getConnectState("github-mcp")).toEqual({ + kind: "disconnected", + affordance: "api-key", + docsUrl: "https://github.com/settings/tokens", + }); + expect(received).toEqual([]); + + listed = { + data: PRESET_LIST.data.map((entry) => ({ ...entry, connected: true })), + }; + await actions.notifySettingsChanged(); + + expect(received).toEqual([{ kind: "connected" }]); + unsubscribe(); + }); }); diff --git a/packages/chat-ui/src/blocks/connect-github-actions.ts b/packages/chat-ui/src/blocks/connect-github-actions.ts index bd6e0efc0..f8cf69ed5 100644 --- a/packages/chat-ui/src/blocks/connect-github-actions.ts +++ b/packages/chat-ui/src/blocks/connect-github-actions.ts @@ -35,15 +35,24 @@ export type ConnectGithubActions = { * `getConnectState`. A credential completed elsewhere (the Plugins * page, another tab) settles this connector's entry on * `@workbench/templates`'s `template/pendingConnections` - * (CL-6463's `settleConnectedService`) so the *next* fresh - * `getConnectState` — e.g. on this card's next mount — already reads - * connected, since that read resolves against the real credential, - * never this setting. Returns an unsubscribe. + * (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 diff --git a/packages/chat-ui/src/blocks/connect-github-block-container.tsx b/packages/chat-ui/src/blocks/connect-github-block-container.tsx index 8bef95f41..4c4717216 100644 --- a/packages/chat-ui/src/blocks/connect-github-block-container.tsx +++ b/packages/chat-ui/src/blocks/connect-github-block-container.tsx @@ -11,9 +11,9 @@ // 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 clears this room's own -// `template/pendingConnections` entry — so this card's next mount already -// reads connected without needing a push while it sits open. +// `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 diff --git a/packages/chat-ui/src/blocks/connect-service-actions.ts b/packages/chat-ui/src/blocks/connect-service-actions.ts index 5ad1a7019..f7377e5ac 100644 --- a/packages/chat-ui/src/blocks/connect-service-actions.ts +++ b/packages/chat-ui/src/blocks/connect-service-actions.ts @@ -30,6 +30,13 @@ export interface ConnectServiceActions { connectorId: string, listener: (query: ConnectServiceQuery) => void, ): () => void; + /** + * Re-reads live connect state for every subscribed connector and fans + * it to those listeners. ChatWorkspace calls this when a parsed + * `chat.settings` event lands so a mounted card flips without + * remounting (CL-6476). + */ + notifySettingsChanged(): Promise; /** One-click connect: starts the hosted OAuth hand-off (navigating * away and back) or completes a keyless preset in place. */ connect(connectorId: string): Promise; diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index 7d3e122d5..255cb6e4e 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -112,6 +112,7 @@ import { ChatMessageEventData, ChatPinEventData, ChatReactionEventData, + ChatSettingsEventData, } from "@corbits/chat/stream-events"; import { useThreadNavigation } from "./use-thread-navigation"; import { mergePendingSends, useOptimisticSends } from "./use-optimistic-sends"; @@ -992,6 +993,26 @@ function ChatWorkspaceInner({ } break; } + case "chat.settings": { + const parsed = ChatSettingsEventData(data); + if (!(parsed instanceof type.errors)) { + connectGithubActions?.notifySettingsChanged().catch((cause) => { + reportError(cause, { + operation: "chat.notifyGithubSettingsChanged", + tenantId, + roomId: activeWorkbenchId, + }); + }); + connectServiceActions?.notifySettingsChanged().catch((cause) => { + reportError(cause, { + operation: "chat.notifyServiceSettingsChanged", + tenantId, + roomId: activeWorkbenchId, + }); + }); + } + break; + } case WORKBENCHES_MUTATED_STREAM_TYPE: { applyStreamWorkbenchesMutated(data); break; diff --git a/packages/chat-ui/test/connect-card-settings-stream.test.tsx b/packages/chat-ui/test/connect-card-settings-stream.test.tsx new file mode 100644 index 000000000..1cdf2d837 --- /dev/null +++ b/packages/chat-ui/test/connect-card-settings-stream.test.tsx @@ -0,0 +1,354 @@ +// CL-6476: a mounted connect card must flip to connected when an +// out-of-band `chat.settings` lands on the workbench stream — never only +// on remount. `settleConnectedService` already publishes that event; +// ChatWorkspace must parse it and ask the host to fan `subscribeConnectState`. + +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as errorSink from "@corbits/error-sink"; + +import type { + ConnectGithubActions, + ConnectGithubQuery, + ConnectServiceActions, + ConnectServiceQuery, +} from "../src/index"; + +const realFetch = globalThis.fetch; +const realEventSource = globalThis.EventSource; + +class StubEventSource { + static instances: StubEventSource[] = []; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + readyState = 1; + listeners = new Map void>(); + + constructor(readonly url: string) { + StubEventSource.instances.push(this); + } + + addEventListener( + eventType: string, + listener: (message: MessageEvent) => void, + ) { + this.listeners.set(eventType, listener); + } + + emit(eventType: string, data: unknown) { + this.listeners.get(eventType)?.({ + data: JSON.stringify(data), + } as MessageEvent); + } + + close() { + this.readyState = 2; + } +} + +afterEach(() => { + globalThis.fetch = realFetch; + globalThis.EventSource = realEventSource; + StubEventSource.instances = []; +}); + +const WORKBENCH_WIRE = { + id: "ch_1", + title: "Launch Planning", + kind: "workbench", + pinned: false, + participants: [] as { address: string; handle: string }[], +}; + +const GITHUB_MESSAGE = { + id: "m_settings_gh", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "block" as const, + block: { + type: "connect-github", + data: { requiredForTemplate: "github", state: "disconnected" }, + }, + }, + ], + sender: { name: "Myra", address: "myra@agents.example" }, +}; + +const SERVICE_MESSAGE = { + id: "m_settings_svc", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [ + { + kind: "block" as const, + block: { + type: "connect-service", + data: { + connectorId: "gmail", + displayName: "Gmail", + reason: "Connect Gmail so I can send this for you.", + }, + }, + }, + ], + sender: { name: "Myra", address: "myra@agents.example" }, +}; + +function stubFetch(messages: readonly unknown[]) { + globalThis.EventSource = StubEventSource as unknown as typeof EventSource; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = typeof input === "string" ? input : String(input); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { + return json({ items: [WORKBENCH_WIRE] }); + } + if (/\/chat\/workbenches\?kind=chat$/.test(path)) + return json({ items: [] }); + if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { + return json({ rootThreadId: "", items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { + return json({ items: messages }); + } + if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); + if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/agents$/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { + return json({ items: [] }); + } + if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { + return json({ + ...WORKBENCH_WIRE, + settings: {}, + contextWindow: { value: 20, source: "inherit" }, + }); + } + if (/\/chat\/bench\/settings$/.test(path)) { + return json({ settings: {}, contextWindow: 20 }); + } + if ( + /\/chat\/workbenches\/[^/]+\/presence$/.test(path) && + init?.method === "POST" + ) { + return json({}); + } + throw new Error(`unstubbed fetch: ${path}`); + }) as typeof fetch; +} + +const { ChatWorkspace } = await import("../src/chat-workspace"); + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function mount(props: Parameters[0]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }); + act(() => { + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(ChatWorkspace, props), + ), + ); + }); + return { + container, + settle: () => act(() => sleep(30)), + unmount: () => root.unmount(), + }; +} + +function firstStream(): StubEventSource { + const instance = StubEventSource.instances[0]; + if (instance === undefined) throw new Error("no stream connected"); + return instance; +} + +const SETTINGS_EVENT = { + updatedBy: "prn_1", + settings: { "template/pendingConnections": [] as string[] }, +}; + +function liveGithubActions() { + let connected = false; + const listeners = new Set<(state: ConnectGithubQuery) => void>(); + let notifyCount = 0; + const connectedState: ConnectGithubQuery = { + kind: "connected", + orgName: "octocat", + repos: [{ id: "1", name: "acme/widgets" }], + selectedRepoIds: [], + }; + const actions = { + getConnectState: () => + Promise.resolve( + connected ? connectedState : { kind: "disconnected" }, + ), + subscribeConnectState: ( + _messageId: string, + onUpdate: (state: ConnectGithubQuery) => void, + ) => { + listeners.add(onUpdate); + return () => { + listeners.delete(onUpdate); + }; + }, + requestConnect: () => undefined, + submitAccessToken: () => Promise.resolve({ ok: true as const }), + startReviewing: () => Promise.resolve({ startedTriggerCount: 0 }), + skip: () => Promise.resolve(), + notifySettingsChanged: async () => { + notifyCount += 1; + const state = await actions.getConnectState(); + for (const listener of listeners) listener(state); + }, + } satisfies ConnectGithubActions; + return { + actions, + settle: () => { + connected = true; + }, + notifyCount: () => notifyCount, + }; +} + +function liveServiceActions() { + let connected = false; + const listeners = new Set<(query: ConnectServiceQuery) => void>(); + let notifyCount = 0; + const actions = { + getConnectState: () => + Promise.resolve( + connected + ? { kind: "connected" } + : { kind: "disconnected", affordance: "oauth" }, + ), + subscribeConnectState: ( + _connectorId: string, + listener: (query: ConnectServiceQuery) => void, + ) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + connect: () => Promise.resolve({ ok: true as const }), + submitKey: () => Promise.resolve({ ok: true as const }), + notifySettingsChanged: async () => { + notifyCount += 1; + const state = await actions.getConnectState(); + for (const listener of listeners) listener(state); + }, + } satisfies ConnectServiceActions; + return { + actions, + settle: () => { + connected = true; + }, + notifyCount: () => notifyCount, + }; +} + +describe("mounted connect cards flip on chat.settings (CL-6476)", () => { + test("a GitHub card stays mounted and reads connected after a valid settings event", async () => { + stubFetch([GITHUB_MESSAGE]); + const github = liveGithubActions(); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + connectGithubActions: github.actions, + }); + await harness.settle(); + + expect(harness.container.textContent).toContain("Connect GitHub"); + const card = harness.container.querySelector(".chat-block"); + expect(card).not.toBeNull(); + + github.settle(); + act(() => { + firstStream().emit("chat.settings", SETTINGS_EVENT); + }); + await harness.settle(); + + expect(github.notifyCount()).toBe(1); + expect(harness.container.textContent).toContain( + "Connected to GitHub as octocat", + ); + expect(harness.container.querySelector(".chat-block")).toBe(card); + harness.unmount(); + }); + + test("a generic connect card stays mounted and reads connected after a valid settings event", async () => { + stubFetch([SERVICE_MESSAGE]); + const service = liveServiceActions(); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + connectServiceActions: service.actions, + }); + await harness.settle(); + + expect(harness.container.textContent).toContain("Connect Gmail"); + const card = harness.container.querySelector(".chat-block"); + expect(card).not.toBeNull(); + + service.settle(); + act(() => { + firstStream().emit("chat.settings", SETTINGS_EVENT); + }); + await harness.settle(); + + expect(service.notifyCount()).toBe(1); + expect(harness.container.textContent).toContain("Gmail connected"); + expect(harness.container.querySelector(".chat-block")).toBe(card); + harness.unmount(); + }); + + test("an arktype miss on chat.settings does not notify or dump the payload", async () => { + stubFetch([GITHUB_MESSAGE]); + const github = liveGithubActions(); + const reported: unknown[] = []; + const report = spyOn(errorSink, "reportError").mockImplementation( + (error) => { + reported.push(error); + return "ref_test"; + }, + ); + const harness = mount({ + tenant: { kind: "ready", tenantId: "tnt_1" }, + workbenchId: "ch_1", + connectGithubActions: github.actions, + }); + await harness.settle(); + + act(() => { + firstStream().emit("chat.settings", { not: "a settings object" }); + }); + await harness.settle(); + + expect(github.notifyCount()).toBe(0); + expect(harness.container.textContent).toContain("Connect GitHub"); + expect(reported).toEqual([]); + report.mockRestore(); + harness.unmount(); + }); +}); diff --git a/packages/chat-ui/test/connect-github-block-container.test.tsx b/packages/chat-ui/test/connect-github-block-container.test.tsx index 27d57d37c..04b014b36 100644 --- a/packages/chat-ui/test/connect-github-block-container.test.tsx +++ b/packages/chat-ui/test/connect-github-block-container.test.tsx @@ -1,11 +1,9 @@ // CL-6463: a successful PAT submit must flip the connect-github card to // connected on its own — never leaning on a host that happens to fan the -// change out through `subscribeConnectState` (real hosts vary: the real -// `createChatConnectGithubActions` only fans out after its own actions, -// never off a live `chat.settings` push). These fakes deliberately never -// call the subscriber from `submitAccessToken`, so a pass here proves the -// container drove its own state from the submit's own result — not from a -// side channel a differently-wired host might forget. +// change out through `subscribeConnectState`. These fakes deliberately +// never call the subscriber from `submitAccessToken`, so a pass here +// proves the container drove its own state from the submit's own result +// — not from a side channel a differently-wired host might forget. import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; import { createRoot } from "react-dom/client"; @@ -87,6 +85,7 @@ function buildNeverNotifiesHarness(options?: { : { kind: "disconnected" }, ), subscribeConnectState: () => () => {}, + notifySettingsChanged: async () => {}, requestConnect: () => {}, submitAccessToken: async (_token: string) => { const result = options?.submitResult ?? { ok: true as const }; @@ -214,6 +213,7 @@ describe("ConnectGithubBlockContainer keeps connected across loading (CL-6741)", subscriber = undefined; }; }, + notifySettingsChanged: async () => {}, requestConnect: () => {}, submitAccessToken: async () => ({ ok: true as const }), startReviewing: async () => ({ startedTriggerCount: 0 }), @@ -271,6 +271,7 @@ describe("ConnectGithubBlockContainer keeps connected across loading (CL-6741)", subscriber = undefined; }; }, + notifySettingsChanged: async () => {}, requestConnect: () => {}, submitAccessToken: async () => ({ ok: true as const }), startReviewing: async () => ({ startedTriggerCount: 0 }), @@ -299,6 +300,7 @@ describe("ConnectGithubBlockContainer names a kind:error state (PR 422)", () => const actions: ConnectGithubActions = { getConnectState: () => Promise.resolve({ kind: "error", message }), subscribeConnectState: () => () => {}, + notifySettingsChanged: async () => {}, requestConnect: () => {}, submitAccessToken: async () => ({ ok: true as const }), startReviewing: async () => ({ startedTriggerCount: 0 }), @@ -328,6 +330,7 @@ describe("ConnectGithubBlockContainer names a kind:error state (PR 422)", () => const actions: ConnectGithubActions = { getConnectState: () => Promise.reject(new Error("boom")), subscribeConnectState: () => () => {}, + notifySettingsChanged: async () => {}, requestConnect: () => {}, submitAccessToken: async () => ({ ok: true as const }), startReviewing: async () => ({ startedTriggerCount: 0 }), diff --git a/packages/chat-ui/test/connect-github-flow.test.tsx b/packages/chat-ui/test/connect-github-flow.test.tsx index 674d58fb2..321a6845e 100644 --- a/packages/chat-ui/test/connect-github-flow.test.tsx +++ b/packages/chat-ui/test/connect-github-flow.test.tsx @@ -114,6 +114,7 @@ function buildHarness() { subscriber = undefined; }; }, + notifySettingsChanged: async () => undefined, requestConnect() { // Opening the card's own inline field — the actual connect happens // through `submitAccessToken` below. diff --git a/packages/chat-ui/test/connect-service-flow.test.tsx b/packages/chat-ui/test/connect-service-flow.test.tsx index d39180345..c5da7d126 100644 --- a/packages/chat-ui/test/connect-service-flow.test.tsx +++ b/packages/chat-ui/test/connect-service-flow.test.tsx @@ -57,6 +57,7 @@ function fakeActions(initial: ConnectServiceQuery): { listeners.add(listener); return () => listeners.delete(listener); }, + notifySettingsChanged: async () => undefined, connect: (connectorId) => { connects.push(connectorId); return Promise.resolve({ ok: true }); diff --git a/packages/chat-ui/test/onboarding-scene-card.test.tsx b/packages/chat-ui/test/onboarding-scene-card.test.tsx index 52e935c22..9a00a0646 100644 --- a/packages/chat-ui/test/onboarding-scene-card.test.tsx +++ b/packages/chat-ui/test/onboarding-scene-card.test.tsx @@ -105,6 +105,7 @@ function fixedStateActions(state: ConnectGithubQuery): ConnectGithubActions { return { getConnectState: () => Promise.resolve(state), subscribeConnectState: () => () => undefined, + notifySettingsChanged: () => Promise.resolve(), requestConnect: () => undefined, submitAccessToken: () => Promise.resolve({ ok: true as const }), startReviewing: () => Promise.resolve({ startedTriggerCount: 0 }),