diff --git a/src/renderer/src/app/OnboardingConnectorsStep.tsx b/src/renderer/src/app/OnboardingConnectorsStep.tsx
index 502cc91b..e54eb71b 100644
--- a/src/renderer/src/app/OnboardingConnectorsStep.tsx
+++ b/src/renderer/src/app/OnboardingConnectorsStep.tsx
@@ -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({
/>
)}
+ {signIn && }
{connectError && (
= {
"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": "显示更多",
diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx
index 94d1eb1b..6f6856bf 100644
--- a/src/renderer/src/pages/ConnectorsPage.tsx
+++ b/src/renderer/src/pages/ConnectorsPage.tsx
@@ -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) {
)}
+ {signIn && }
+
void; onCheckAgain: () => void }) {
+ const name = signIn.slug;
+ const failed = signIn.phase === "failed" || signIn.phase === "timed_out";
+ return (
+
+
+ {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 })}
+
+ {signIn.phase === "waiting" && }
+ {signIn.phase === "timed_out" && }
+
+ );
+}
diff --git a/src/renderer/src/pages/connectors/sign-in.ts b/src/renderer/src/pages/connectors/sign-in.ts
new file mode 100644
index 00000000..c38af316
--- /dev/null
+++ b/src/renderer/src/pages/connectors/sign-in.ts
@@ -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" };
+}
diff --git a/src/renderer/src/pages/connectors/useConnectorCatalog.ts b/src/renderer/src/pages/connectors/useConnectorCatalog.ts
index ae98f7c5..c077f286 100644
--- a/src/renderer/src/pages/connectors/useConnectorCatalog.ts
+++ b/src/renderer/src/pages/connectors/useConnectorCatalog.ts
@@ -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(null);
const [busy, setBusy] = useState<{ slug: string; action: "connect" | "disconnect" } | null>(null);
const [connectError, setConnectError] = useState(null);
+ const [signIn, setSignIn] = useState(null);
const pollTimer = useRef | 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,
diff --git a/tests/unit/connector-sign-in.test.ts b/tests/unit/connector-sign-in.test.ts
new file mode 100644
index 00000000..2de7b1e8
--- /dev/null
+++ b/tests/unit/connector-sign-in.test.ts
@@ -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." });
+ });
+});