Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions apps/web/src/connect-github-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/connect-service-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -101,6 +104,18 @@ export function createChatConnectServiceActions(
};
}

async function refreshSubscribed(): Promise<void> {
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(() => ({
Expand All @@ -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);
Expand Down
56 changes: 56 additions & 0 deletions apps/web/test/connect-github-actions.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions apps/web/test/connect-service-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
17 changes: 13 additions & 4 deletions packages/chat-ui/src/blocks/connect-github-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/**
* Both the card's "Connect GitHub" and "Use an access token instead"
* actions funnel here — this repo's connect cards are PAT-first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/chat-ui/src/blocks/connect-service-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/** One-click connect: starts the hosted OAuth hand-off (navigating
* away and back) or completes a keyless preset in place. */
connect(connectorId: string): Promise<ConnectServiceResult>;
Expand Down
21 changes: 21 additions & 0 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading