Q196: a connector sign-in always ends connected, failed or timed out, and says which

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 09:31:52 -04:00
1 parent 56eac39b1b
commit 4ca61c5deb
7 files changed
+130 -9

No files matched your search

@@ -1,3 +1,4 @@
import { SignInStatus } from "../pages/connectors/SignInStatus";
import { useMemo } from "react";
import type { AppSnapshot } from "../../../shared/domain";
import { Button } from "../shared/ui";
@@ -23,7 +24,7 @@ export function ConnectorsStep({
actions: SnapshotActions;
onFinish: () => void;
}) {
const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions);
const { catalog, busySlug, connectError, signIn, stopWaiting, checkAgain, connect, retry } = useConnectorCatalog(orgId, actions);
const connectedSlugs = useMemo(
() =>
new Set(
@@ -65,6 +66,7 @@ export function ConnectorsStep({
/>
</div>
)}
{signIn && <SignInStatus signIn={signIn} onStop={stopWaiting} onCheckAgain={checkAgain} />}
{connectError && (
<div
style={{
+6
View File
@@ -696,6 +696,12 @@ export const zh: Record<string, string> = {
"Working language": "工作语言",
"That did not work": "操作未成功",
"Working…": "处理中…",
"Waiting for the {name} sign-in in your browser (check {check} of {checks}).": "正在等待你在浏览器中完成 {name} 登录(第 {check}/{checks} 次检查)。",
"Stop waiting": "停止等待",
"{name} ({label}) is connected.": "{name}({label})已连接。",
"{name} ({label}) is still not connected. If you finished signing in, check again; otherwise connect again.": "{name}({label})仍未连接。如果已经完成登录,请再次检查;否则请重新连接。",
"Check again": "再次检查",
"Connecting {name} ({label}) failed: {reason}": "连接 {name}({label})失败:{reason}",
"Searching…": "正在搜索…",
"Nothing matches “{words}”.": "没有与“{words}”匹配的结果。",
"Show more": "显示更多",
+4 -1
View File
@@ -1,3 +1,4 @@
import { SignInStatus } from "./connectors/SignInStatus";
import { useMemo } from "react";
import { Button } from "../shared/ui";
import { t } from "../i18n/i18n";
@@ -16,7 +17,7 @@ import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands";
export function ConnectorsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = requireActiveEmployee(snapshot);
const { catalog, busySlug, busyAction, connectError, connect, disconnect, grant, revoke, retry } = useConnectorCatalog(orgId, actions);
const { catalog, busySlug, busyAction, connectError, signIn, stopWaiting, checkAgain, connect, disconnect, grant, revoke, retry } = useConnectorCatalog(orgId, actions);
const accounts = useMemo(
() => snapshot.connectorAccounts.filter((account) => account.orgId === orgId),
@@ -61,6 +62,8 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
</div>
)}
{signIn && <SignInStatus signIn={signIn} onStop={stopWaiting} onCheckAgain={checkAgain} />}
<ConnectedList
accounts={accounts}
grants={grants}
@@ -0,0 +1,21 @@
import { Button } from "../../shared/ui";
import { t } from "../../i18n/i18n";
import type { SignIn } from "./useConnectorCatalog";
/** Says where a connector sign-in stands: waiting (with how many checks), connected, timed out, or failed. */
export function SignInStatus({ signIn, onStop, onCheckAgain }: { signIn: SignIn; onStop: () => void; onCheckAgain: () => void }) {
const name = signIn.slug;
const failed = signIn.phase === "failed" || signIn.phase === "timed_out";
return (
<div role={failed ? "alert" : "status"} className="row" style={{ gap: 10, fontSize: 13, marginBottom: 14, color: failed ? "var(--red)" : "var(--text-2)" }}>
<span style={{ flex: 1, minWidth: 0 }}>
{signIn.phase === "waiting" && t("Waiting for the {name} sign-in in your browser (check {check} of {checks}).", { name, check: signIn.check, checks: signIn.checks })}
{signIn.phase === "connected" && t("{name} ({label}) is connected.", { name, label: signIn.label })}
{signIn.phase === "timed_out" && t("{name} ({label}) is still not connected. If you finished signing in, check again; otherwise connect again.", { name, label: signIn.label })}
{signIn.phase === "failed" && t("Connecting {name} ({label}) failed: {reason}", { name, label: signIn.label, reason: signIn.message })}
</span>
{signIn.phase === "waiting" && <Button size="small" onClick={onStop}>{t("Stop waiting")}</Button>}
{signIn.phase === "timed_out" && <Button size="small" onClick={onCheckAgain}>{t("Check again")}</Button>}
</div>
);
}
@@ -0,0 +1,12 @@
/** What one check of the account row says about the sign-in that created `newAccountId`: done, failed for good, or not yet. */
export function judgeSignIn(
account: { id: string; status: string; pendingAccountId: string | null } | undefined,
newAccountId: string,
): { outcome: "connected" } | { outcome: "failed"; message: string } | { outcome: "waiting" } {
if (!account || account.pendingAccountId === newAccountId) return { outcome: "waiting" };
// A reconnect that never took over leaves the earlier account in place; that is not this sign-in succeeding.
if (account.id !== newAccountId) return { outcome: "failed", message: "The new sign-in did not complete; the earlier connection is kept." };
if (account.status === "ACTIVE" && !account.pendingAccountId) return { outcome: "connected" };
if (account.status === "FAILED" || account.status === "EXPIRED") return { outcome: "failed", message: `The account is ${account.status.toLowerCase()}.` };
return { outcome: "waiting" };
}
@@ -1,3 +1,4 @@
import { judgeSignIn } from "./sign-in";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectorCatalogEntry } from "../../../../shared/commands";
import type { SnapshotActions } from "../../app/useSnapshot";
@@ -11,6 +12,14 @@ export type CatalogState =
type CatalogSlot = { orgId: string; state: CatalogState } | null;
/** Where a sign-in started from this window stands; it always ends connected, failed, or timed out. */
export type SignIn = { slug: string; label: string; accountId: string } & (
| { phase: "waiting"; check: number; checks: number }
| { phase: "connected" }
| { phase: "timed_out" }
| { phase: "failed"; message: string }
);
const CONNECT_POLL_INTERVAL_MS = 4000;
const CONNECT_POLL_ATTEMPTS = 15;
@@ -24,6 +33,9 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
busySlug: string | null;
busyAction: "connect" | "disconnect" | null;
connectError: string | null;
signIn: SignIn | null;
stopWaiting: () => void;
checkAgain: () => void;
connect: (toolkitSlug: string, label: string) => void;
disconnect: (toolkitSlug: string, label: string) => void;
grant: (agentId: string, toolkitSlug: string, accountLabel: string, employeeId: string) => void;
@@ -33,7 +45,10 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
const [catalogSlot, setCatalogSlot] = useState<CatalogSlot>(null);
const [busy, setBusy] = useState<{ slug: string; action: "connect" | "disconnect" } | null>(null);
const [connectError, setConnectError] = useState<string | null>(null);
const [signIn, setSignIn] = useState<SignIn | null>(null);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Each sign-in check run has its own number; a stopped or replaced run's late answers are dropped.
const pollRun = useRef(0);
const catalog: CatalogState = catalogSlot && catalogSlot.orgId === orgId ? catalogSlot.state : { phase: "loading" };
const load = useCallback(() => {
@@ -65,6 +80,7 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
}, [orgId]);
useEffect(() => () => {
pollRun.current += 1;
if (pollTimer.current) clearTimeout(pollTimer.current);
}, []);
@@ -79,39 +95,81 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
[actions, orgId],
);
const pollUntilActive = useCallback((toolkitSlug: string, label: string) => {
// Checks the account until it is ACTIVE, failed, or the checks run out; the card stays busy until one of those.
const pollUntilActive = useCallback((toolkitSlug: string, label: string, accountId: string) => {
if (pollTimer.current) clearTimeout(pollTimer.current);
pollRun.current += 1;
const run = pollRun.current;
const sign = { slug: toolkitSlug, label, accountId };
const end = (outcome: SignIn) => {
if (pollRun.current !== run) return;
setSignIn(outcome);
setBusy(null);
};
const poll = (attempt: number) => {
if (attempt >= CONNECT_POLL_ATTEMPTS) return;
if (pollRun.current !== run) return;
if (attempt >= CONNECT_POLL_ATTEMPTS) {
end({ ...sign, phase: "timed_out" });
return;
}
setSignIn({ ...sign, phase: "waiting", check: attempt + 1, checks: CONNECT_POLL_ATTEMPTS });
pollTimer.current = setTimeout(() => {
void refreshAccounts().then((data) => {
if (pollRun.current !== run) return;
const account = data?.connectorAccounts.find(
(item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug && item.label === label,
);
if (account?.status === "ACTIVE" && !account.pendingAccountId) return;
const judged = judgeSignIn(account, accountId);
if (judged.outcome === "connected") return end({ ...sign, phase: "connected" });
if (judged.outcome === "failed") return end({ ...sign, phase: "failed", message: judged.message });
poll(attempt + 1);
});
}, (error: unknown) => end({ ...sign, phase: "failed", message: error instanceof Error ? error.message : String(error) }));
}, CONNECT_POLL_INTERVAL_MS);
};
poll(0);
}, [refreshAccounts, orgId]);
const stopWaiting = useCallback(() => {
pollRun.current += 1;
if (pollTimer.current) clearTimeout(pollTimer.current);
setSignIn(null);
setBusy(null);
}, []);
const checkAgain = useCallback(() => {
if (!signIn) return;
setBusy({ slug: signIn.slug, action: "connect" });
pollUntilActive(signIn.slug, signIn.label, signIn.accountId);
}, [signIn, pollUntilActive]);
const connect = useCallback((toolkitSlug: string, label: string) => {
pollRun.current += 1;
const run = pollRun.current;
if (pollTimer.current) clearTimeout(pollTimer.current);
setBusy({ slug: toolkitSlug, action: "connect" });
setConnectError(null);
setSignIn(null);
void window.agentCompany
.connectComposioToolkit({ orgId, toolkitSlug, label })
.then(async (result) => {
// Unmounted, stopped or replaced while connecting: this answer starts nothing.
if (pollRun.current !== run) return;
if (!result.ok) {
setConnectError(result.error.message);
setBusy(null);
return;
}
pollUntilActive(toolkitSlug, label.trim().toLowerCase());
// Opening the browser is not being connected: the card stays busy while the sign-in is checked.
pollUntilActive(toolkitSlug, label.trim().toLowerCase(), result.data.accountId);
if (result.data.redirectUrl) {
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
if (!opened.ok) setConnectError(opened.error.message);
}
})
.finally(() => setBusy(null));
}, (error: unknown) => {
if (pollRun.current !== run) return;
setConnectError(error instanceof Error ? error.message : String(error));
setBusy(null);
});
}, [orgId, pollUntilActive]);
const disconnect = useCallback((toolkitSlug: string, label: string) => {
@@ -139,6 +197,9 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
busySlug: busy?.slug ?? null,
busyAction: busy?.action ?? null,
connectError,
signIn,
stopWaiting,
checkAgain,
connect,
disconnect,
grant,
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { judgeSignIn } from "../../src/renderer/src/pages/connectors/sign-in";
describe("connector sign-in", () => {
test("ends connected or failed only on what the new account says; anything else keeps waiting", () => {
const row = (id: string, status: string, pendingAccountId: string | null = null) => ({ id, status, pendingAccountId });
expect(judgeSignIn(row("ca_new", "ACTIVE"), "ca_new")).toEqual({ outcome: "connected" });
expect(judgeSignIn(row("ca_new", "FAILED"), "ca_new")).toEqual({ outcome: "failed", message: "The account is failed." });
expect(judgeSignIn(row("ca_new", "EXPIRED"), "ca_new")).toEqual({ outcome: "failed", message: "The account is expired." });
expect(judgeSignIn(row("ca_new", "INITIATED"), "ca_new")).toEqual({ outcome: "waiting" });
expect(judgeSignIn(undefined, "ca_new")).toEqual({ outcome: "waiting" });
// A reconnect waits while the new account is pending, and fails if it never takes over from the old one.
expect(judgeSignIn(row("ca_old", "ACTIVE", "ca_new"), "ca_new")).toEqual({ outcome: "waiting" });
expect(judgeSignIn(row("ca_old", "ACTIVE"), "ca_new")).toEqual({ outcome: "failed", message: "The new sign-in did not complete; the earlier connection is kept." });
});
});