diff --git a/src/main/app/ipc-contracts/index.ts b/src/main/app/ipc-contracts/index.ts index 7d412e1d..5c0e0698 100644 --- a/src/main/app/ipc-contracts/index.ts +++ b/src/main/app/ipc-contracts/index.ts @@ -15,6 +15,7 @@ export const IPC_CONTRACTS: { [K in InvokedMethod]: z.ZodType> } = { loadSnapshot: noInput, relaunchApp: noInput, reportUnsaved: z.strictObject({ unsaved: z.boolean() }), + copyText: z.strictObject({ text: z.string().max(100_000) }), saveViewState: z.strictObject({ key: z.string().min(1).max(200), value: z.json().refine((value) => JSON.stringify(value).length <= 4_000, "View state value is too large.") }), runningSessionCount: noInput, pickDirectory: noInput, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index d09de26d..71940ee5 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -1,6 +1,6 @@ import { bumpDataVersion, currentDataVersion } from "../services/data-version"; import { createIpcGate } from "./ipc-gate"; -import { app, dialog, shell, type BrowserWindow } from "electron"; +import { app, dialog, shell, type BrowserWindow, clipboard } from "electron"; import type { DatabaseConnection } from "../database/connection"; import { IPC_CHANNELS } from "../../shared/ipc"; import { fail, ok, type DesktopResult } from "../../shared/result"; @@ -159,6 +159,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); handle("completeSetup", (input) => mutate(() => completeSetup(deps.connection.db, input.orgId))); + handle("copyText", (input) => { + clipboard.writeText(input.text); + return ok(null); + }); handle("saveViewState", (input) => { try { saveViewState(deps.connection.db, input.key, input.value); diff --git a/src/preload/index.ts b/src/preload/index.ts index cf2e3a26..20d21400 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ const api: DesktopApi = { relaunchApp: () => invoke(IPC_CHANNELS.relaunchApp), reportUnsaved: (input) => invoke(IPC_CHANNELS.reportUnsaved, input), saveViewState: (input) => invoke(IPC_CHANNELS.saveViewState, input), + copyText: (input) => invoke(IPC_CHANNELS.copyText, input), saveOnboardingDraft: (input) => invoke(IPC_CHANNELS.saveOnboardingDraft, input), completeSetup: (input) => invoke(IPC_CHANNELS.completeSetup, input), runningSessionCount: () => invoke(IPC_CHANNELS.runningSessionCount), diff --git a/src/renderer/src/app/OnboardingConnectorsStep.tsx b/src/renderer/src/app/OnboardingConnectorsStep.tsx index 5a03a124..d21d43b2 100644 --- a/src/renderer/src/app/OnboardingConnectorsStep.tsx +++ b/src/renderer/src/app/OnboardingConnectorsStep.tsx @@ -1,3 +1,4 @@ +import { ErrorText } from "../shared/ErrorText"; import { AccountSyncNote } from "../pages/connectors/AccountSyncNote"; import { SignInStatus } from "../pages/connectors/SignInStatus"; import { useMemo } from "react"; @@ -39,24 +40,7 @@ export function ConnectorsStep({ return (

{t("Connectors")}

- {catalog.phase === "error" && ( -
- - {catalog.message} - - -
- )} + {catalog.phase === "error" && } {catalog.phase === "ready" && (
} - {connectError && ( -
- {connectError} -
- )} + {connectError && }
diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 0c433915..67660f1c 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -698,6 +698,9 @@ export const zh: Record = { "Working…": "处理中…", "Waiting for the {name} sign-in in your browser (check {check} of {checks}).": "正在等待你在浏览器中完成 {name} 登录(第 {check}/{checks} 次检查)。", "Stop waiting": "停止等待", + "Copy": "复制", + "Copied": "已复制", + "Copy failed": "复制失败", "Checking account status with Composio…": "正在向 Composio 核对账号状态…", "Account status checked with Composio at {time}.": "已于 {time} 向 Composio 核对账号状态。", "Account status could not be checked with Composio: {reason}. The statuses below may be out of date.": "无法向 Composio 核对账号状态:{reason}。下方状态可能已过时。", diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index dd6a874f..1678b8bf 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -1,7 +1,7 @@ +import { ErrorText } from "../shared/ErrorText"; import { AccountSyncNote } from "./connectors/AccountSyncNote"; import { SignInStatus } from "./connectors/SignInStatus"; import { useMemo } from "react"; -import { Button } from "../shared/ui"; import { t } from "../i18n/i18n"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee } from "./types"; @@ -48,20 +48,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {

{t("Connectors")}

- {connectError && ( -
- {connectError} -
- )} + {connectError && } {signIn && } @@ -79,24 +66,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { onRevoke={revoke} /> - {catalog.phase === "error" && ( -
- - {catalog.message} - - -
- )} + {catalog.phase === "error" && } {catalog.phase === "ready" && ( void } }) { + // The copy result belongs to the message it was for; a new message starts fresh. + const [copied, setCopied] = useState<{ message: string; ok: boolean } | null>(null); + const copy = copied && copied.message === message ? (copied.ok ? "copied" : "failed") : "idle"; + const copyMessage = () => { + void window.agentCompany.copyText({ text: message }).then((result) => { + if (!result.ok) console.error(`[errors] copy failed: ${result.error.message}`); + setCopied({ message, ok: result.ok }); + }, (error: unknown) => { + console.error(`[errors] copy failed: ${error instanceof Error ? error.message : String(error)}`); + setCopied({ message, ok: false }); + }); + }; + return ( +
+ {message} + + {action && } +
+ ); +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 89d318a5..78a506ef 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -129,6 +129,8 @@ export interface DesktopApi { reportUnsaved(input: { unsaved: boolean }): Promise>; /** Remembers one piece of where the operator was; null forgets it. */ saveViewState(input: { key: string; value: unknown }): Promise>; + /** Puts text on the system clipboard. */ + copyText(input: { text: string }): Promise>; saveOnboardingDraft(input: { draft: OnboardingDraft | null }): Promise>; completeSetup(input: { orgId: string }): Promise>; /** Every agent session running now, in every company: what a restart interrupts. */ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 74405731..a68c41c9 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -3,6 +3,7 @@ export const IPC_CHANNELS = { relaunchApp: "agentcompany:relaunch-app", reportUnsaved: "agentcompany:report-unsaved", saveViewState: "agentcompany:save-view-state", + copyText: "agentcompany:copy-text", saveOnboardingDraft: "agentcompany:save-onboarding-draft", completeSetup: "agentcompany:complete-setup", runningSessionCount: "agentcompany:running-session-count", diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 4391cea4..35610133 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -220,6 +220,13 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByRole("button", { name: "Refresh" })).toHaveCount(0); // Without a Composio key the account check fails, and the page says the statuses may be stale. await expect(page.getByRole("alert").filter({ hasText: "Account status could not be checked with Composio" })).toBeVisible(); + // A connector failure is shown whole and can be copied. + const catalogError = page.getByRole("alert").filter({ has: page.getByRole("button", { name: "Retry" }) }).filter({ hasNotText: "Account status" }).first(); + await expect(catalogError).toBeVisible(); + const shownReason = (await catalogError.locator("span").first().innerText()).trim(); + await catalogError.getByRole("button", { name: "Copy" }).click(); + await expect(catalogError.getByRole("button", { name: "Copied" })).toBeVisible(); + expect((await app.evaluate(({ clipboard }) => clipboard.readText())).trim()).toBe(shownReason); await page.getByRole("button", { name: "Settings" }).click(); await expect(page.getByText("AI CEO", { exact: true })).toBeVisible();