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
74 changes: 63 additions & 11 deletions packages/chat-ui/src/blocks/connect-github-block-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
// `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.
//
// 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 "@corbits/chat/blocks";

Expand All @@ -23,6 +29,36 @@ import type {
} from "./connect-github-actions";
import { ConnectGithubBlockView } from "./connect-github-block";

type ConnectedGithubQuery = Extract<ConnectGithubQuery, { kind: "connected" }>;

/** 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<string, ConnectedGithubQuery>();

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);
}

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({
messageId,
actions,
Expand All @@ -31,15 +67,27 @@ export function ConnectGithubBlockContainer({
readonly messageId: string;
readonly actions?: ConnectGithubActions;
}) {
const [query, setQuery] = useState<ConnectGithubQuery>({ kind: "loading" });
const [selectedRepoIds, setSelectedRepoIds] = useState<readonly string[]>([]);
const [query, setQuery] = useState<ConnectGithubQuery>(() => {
return lastConnectedOf(messageId) ?? { kind: "loading" };
});
const [selectedRepoIds, setSelectedRepoIds] = useState<readonly string[]>(
() => lastConnectedOf(messageId)?.selectedRepoIds ?? [],
);
const mountedRef = useRef(true);

const applyQuery = useCallback((result: ConnectGithubQuery) => {
if (!mountedRef.current) return;
setQuery(result);
if (result.kind === "connected") setSelectedRepoIds(result.selectedRepoIds);
}, []);
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;
Expand Down Expand Up @@ -74,7 +122,9 @@ export function ConnectGithubBlockContainer({
[actions, messageId, applyQuery],
);

if (actions === undefined || query.kind !== "connected") {
const displayQuery = displayQueryOf(messageId, query);

if (actions === undefined || displayQuery.kind !== "connected") {
return (
<ConnectGithubBlockView
kind="disconnected"
Expand All @@ -95,11 +145,13 @@ export function ConnectGithubBlockContainer({
return (
<ConnectGithubBlockView
kind="connected"
orgName={query.orgName}
repos={query.repos}
orgName={displayQuery.orgName}
repos={displayQuery.repos}
selectedRepoIds={selectedRepoIds}
onToggleRepo={toggleRepo}
onSelectAll={() => setSelectedRepoIds(query.repos.map((repo) => repo.id))}
onSelectAll={() =>
setSelectedRepoIds(displayQuery.repos.map((repo) => repo.id))
}
onChangeConnection={actions.requestConnect}
onStartReviewing={(repoIds) => {
void actions.startReviewing(repoIds);
Expand Down
8 changes: 8 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,15 @@ export const CHAT_STRINGS = {
eventWorkbenchRenamedTo: (to: string): string => `Renamed to "${to}"`,
eventBlockResponsePoll: "A vote was recorded",
eventBlockResponseForm: "A form was submitted",
/** Plain-text form of the settle notice (CL-6741). EventLine renders
* the same copy with "Plugins" as a `/plugins` link. */
eventConnectionConnected: (displayName: string): string =>
`${displayName} connected successfully. Manage in Plugins`,
eventConnectionConnectedBeforePlugins: (displayName: string): string =>
`${displayName} connected successfully. Manage in `,
eventConnectionConnectedPlugins: "Plugins",
eventGeneric: (event: string) => event.replace(/[.\-_]+/g, " "),

inviteAgentAction: "Invite agent",
workbenchMembersLabel: "Members",
teamStackOverflow: (count: number) =>
Expand Down
6 changes: 6 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,12 @@
color: var(--muted-foreground);
}

.chat-event-line a {
color: inherit;
text-decoration: underline;
text-underline-offset: 0.12em;
}

.chat-event-time {
font-size: 0.6875rem;
opacity: 0.85;
Expand Down
13 changes: 13 additions & 0 deletions packages/chat-ui/src/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,16 @@ describe("friendlyEventText — workbench.agent-joined (CL-6594)", () => {
expect(friendlyEventText(part, [])).toBe("An agent joined");
});
});

describe("friendlyEventText — connection.connected (CL-6741)", () => {
test("names the connected service and points at Plugins", () => {
const part: Part & { kind: "event" } = {
kind: "event",
event: "connection.connected",
data: { connectorId: "github", displayName: "GitHub" },
};
expect(friendlyEventText(part, [])).toBe(
"GitHub connected successfully. Manage in Plugins",
);
});
});
35 changes: 34 additions & 1 deletion packages/chat-ui/src/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,15 @@ export function friendlyEventText(
? CHAT_STRINGS.eventBlockResponsePoll
: CHAT_STRINGS.eventBlockResponseForm;
}
case "connection.connected": {
const displayName =
data !== undefined && typeof data.displayName === "string"
? data.displayName
: undefined;
return displayName !== undefined
? CHAT_STRINGS.eventConnectionConnected(displayName)
: CHAT_STRINGS.eventGeneric(part.event);
}
default:
return CHAT_STRINGS.eventGeneric(part.event);
}
Expand All @@ -658,9 +667,33 @@ function EventLine({
createdAt: string;
participants: readonly ParticipantRecord[];
}) {
const data =
typeof part.data === "object" && part.data !== null
? (part.data as Record<string, unknown>)
: undefined;
const connectedDisplayName =
part.event === "connection.connected" &&
data !== undefined &&
typeof data.displayName === "string"
? data.displayName
: undefined;

return (
<div className="chat-event-line">
<span>{friendlyEventText(part, participants)}</span>
<span>
{connectedDisplayName !== undefined ? (
<>
{CHAT_STRINGS.eventConnectionConnectedBeforePlugins(
connectedDisplayName,
)}
<a href="/plugins">
{CHAT_STRINGS.eventConnectionConnectedPlugins}
</a>
</>
) : (
friendlyEventText(part, participants)
)}
</span>
<span className="chat-event-time">{formatTimestamp(createdAt)}</span>
</div>
);
Expand Down
97 changes: 97 additions & 0 deletions packages/chat-ui/test/connect-github-block-container.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,100 @@ describe("ConnectGithubBlockContainer post-submit refresh (CL-6463)", () => {
expect(tokenField.disabled).toBe(false);
});
});

describe("ConnectGithubBlockContainer keeps connected across loading (CL-6741)", () => {
test("a loading remount after connected keeps ConnectedBody — never flashes Connect", async () => {
let state: ConnectGithubQuery = {
kind: "connected",
orgName: "octocat",
repos: REPOS,
selectedRepoIds: [],
};
let subscriber: ((next: ConnectGithubQuery) => void) | undefined;

const actions: ConnectGithubActions = {
getConnectState: () => Promise.resolve(state),
subscribeConnectState: (_messageId, onUpdate) => {
subscriber = onUpdate;
return () => {
subscriber = undefined;
};
},
requestConnect: () => {},
submitAccessToken: async () => ({ ok: true as const }),
startReviewing: async () => ({ startedTriggerCount: 0 }),
skip: async () => {},
};

const el = await mount(actions);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(el.textContent).toContain("Connected to GitHub as octocat");
expect(el.textContent).not.toContain("Connect GitHub");

await act(async () => {
state = { kind: "loading" };
subscriber?.({ kind: "loading" });
});
expect(el.textContent).toContain("Connected to GitHub as octocat");
expect(el.textContent).not.toContain("Connect GitHub");
expect(el.querySelectorAll(".chat-block-connect-repo-row")).toHaveLength(
REPOS.length,
);

// Remount while the host still reports loading — the last connected
// snapshot must survive so Connect never flashes.
if (root !== null) {
await act(async () => {
root?.unmount();
});
root = null;
}
container?.remove();
container = null;

const remounted = await mount(actions);
expect(remounted.textContent).toContain("Connected to GitHub as octocat");
expect(remounted.textContent).not.toContain("Connect GitHub");
});

test("an explicit disconnected result after connected does show Connect again", async () => {
let state: ConnectGithubQuery = {
kind: "connected",
orgName: "octocat",
repos: REPOS,
selectedRepoIds: [],
};
let subscriber: ((next: ConnectGithubQuery) => void) | undefined;

const actions: ConnectGithubActions = {
getConnectState: () => Promise.resolve(state),
subscribeConnectState: (_messageId, onUpdate) => {
subscriber = onUpdate;
return () => {
subscriber = undefined;
};
},
requestConnect: () => {},
submitAccessToken: async () => ({ ok: true as const }),
startReviewing: async () => ({ startedTriggerCount: 0 }),
skip: async () => {},
};

const el = await mount(actions);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(el.textContent).toContain("Connected to GitHub as octocat");

await act(async () => {
state = { kind: "disconnected" };
subscriber?.({ kind: "disconnected" });
});
expect(el.textContent).toContain("Connect GitHub");
expect(el.textContent).not.toContain("Connected to GitHub as");
});
});
27 changes: 27 additions & 0 deletions packages/chat-ui/test/system-row-chrome.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,33 @@ describe("CL-6739: system / error / connect rows hide social chrome", () => {
expectNoSocialChrome(el);
});

test("connection.connected settle notice links Plugins to /plugins (CL-6741)", async () => {
const el = await mount([
{
id: "settle_1",
createdAt: "2026-01-01T00:00:00.000Z",
parts: [
{
kind: "event",
event: "connection.connected",
data: { connectorId: "github", displayName: "GitHub" },
},
],
sender: { name: null, address: "system@agents.example" },
} as MessageItem,
]);

const line = el.querySelector(".chat-event-line");
expect(line).not.toBeNull();
expect(line?.textContent).toContain(
"GitHub connected successfully. Manage in Plugins",
);
const pluginsLink = line?.querySelector('a[href="/plugins"]');
expect(pluginsLink).not.toBeNull();
expect(pluginsLink?.textContent).toBe("Plugins");
expectNoSocialChrome(el);
});

test("a generic system event row has no reaction, reply, or overflow", async () => {
const el = await mount([
{
Expand Down
Loading
Loading