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." }); + }); +});