From d8d0ef771c549b20c415ffa55a3078e14f707330 Mon Sep 17 00:00:00 2001 From: aivsomkar Date: Tue, 6 Oct 2026 20:50:48 +0530 Subject: [PATCH] Connection cards: a plain line for clients that cannot draw them, and an app-and-state sidebar preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each in-chat connection card now carries a plain `text` line naming the app (and the account alias, when there is one), so a client that does not know the `connector` kind still shows something. The desktop sidebar previews a card as " · " in the reader's language instead. Split out of #2357 and #2361. Co-Authored-By: Claude Opus 5.5 --- server/connector-card-text.test.ts | 18 ++++++++++++++++++ server/connector-card-text.ts | 16 ++++++++++++++++ server/index.test.ts | 9 +++++++++ server/index.ts | 4 ++++ src/components/Sidebar.tsx | 6 +++++- src/lib/sidebar-layout.test.ts | 15 +++++++++++++++ src/lib/sidebar-layout.ts | 18 ++++++++++++++++++ 7 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 server/connector-card-text.test.ts create mode 100644 server/connector-card-text.ts diff --git a/server/connector-card-text.test.ts b/server/connector-card-text.test.ts new file mode 100644 index 0000000000..192cc3f7cf --- /dev/null +++ b/server/connector-card-text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { connectorCardText } from "./connector-card-text.ts"; + +describe("connectorCardText", () => { + it("names the app the bot is waiting on", () => { + expect(connectorCardText("GitHub")).toBe("Connect GitHub to continue."); + }); + + it("names the account for a second-account card", () => { + expect(connectorCardText("Gmail", "work")).toBe("Connect Gmail as “work” to continue."); + }); + + it("collapses stray whitespace and never prints an empty name", () => { + expect(connectorCardText(" Google \n Calendar ", " ")).toBe("Connect Google Calendar to continue."); + expect(connectorCardText(" ")).toBe("Connect this app to continue."); + }); +}); diff --git a/server/connector-card-text.ts b/server/connector-card-text.ts new file mode 100644 index 0000000000..ab94c6d93c --- /dev/null +++ b/server/connector-card-text.ts @@ -0,0 +1,16 @@ +// The plain line a connection card carries beside its `connector` payload. +// +// Desktop and current phones draw `kind: "connector"` as a card and never +// read this. It exists for clients that do not know the kind yet — phone +// builds from before the in-chat card shipped decode it as "unknown" and draw +// whatever `text` a message has, so without it they showed nothing at all +// while the bot sat waiting for a connection. Exports and backups read it too. +// It names the app and points at the card; it never carries a link, because +// the authorization URL is only ever handed to the person's own client. + +/** "Connect GitHub to continue." — or, for a second account, the alias too. */ +export function connectorCardText(label: string, alias?: string): string { + const app = label.replace(/\s+/g, " ").trim() || "this app"; + const account = alias?.replace(/\s+/g, " ").trim(); + return account ? `Connect ${app} as “${account}” to continue.` : `Connect ${app} to continue.`; +} diff --git a/server/index.test.ts b/server/index.test.ts index 67d2cb23b2..41db2f48a0 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -10940,6 +10940,12 @@ describe("harness HTTP API", () => { const [work, other, duplicate] = requested.body.messageIds; expect(work).toBe(duplicate); expect(other).not.toBe(work); + // Older phones draw an unknown kind by its text, so each card carries + // a plain line naming the app and the account it is waiting on. + const stored = (await api("GET", `/api/threads/${bot.threadId}/messages`)).body.messages as any[]; + const workCard = stored.find((message) => message.id === work); + expect(workCard).toMatchObject({ kind: "connector", connector: { alias: "work", status: "required" } }); + expect(workCard.text).toBe(`Connect ${workCard.connector.label} as “work” to continue.`); expect((await create([{ slug: "gmail", alias: "work" }])).body.messageIds).toEqual([work]); const card = (id: string, action: string) => `/api/bots/${bot.id}/connector-cards/${id}/${action}`; expect((await api("POST", card(work, "authorize"), { threadId: bot.threadId })).body.url).toBe("https://connect.composio.dev/fixture-only"); @@ -10979,6 +10985,9 @@ describe("harness HTTP API", () => { }); expect(response.status).toBe(200); const { messageIds } = await response.json() as { messageIds: string[] }; + const stored = (await api("GET", `/api/threads/${bot.threadId}/messages`)).body.messages as any[]; + const slackCard = stored.find((message) => message.id === messageIds[0]); + expect(slackCard.text).toBe(`Connect ${slackCard.connector.label} to continue.`); const card = `/api/bots/${bot.id}/connector-cards/${messageIds[0]}/authorize`; const paths = ["/api/connectors/slack/authorize", card, card]; const before = connectorLinkRequests.length; diff --git a/server/index.ts b/server/index.ts index 28f04d385e..dba885b78a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -124,6 +124,7 @@ import { } from "./cloud-backend.ts"; import * as composio from "./composio.ts"; import { connectorCallFromFrame, connectorRefusalText, connectorUnrecognizedText, evaluateConnectorTools } from "./connector-verdict.ts"; +import { connectorCardText } from "./connector-card-text.ts"; import { chiefOfStaffSystemPrompt } from "./chief-of-staff.ts"; import { buildRecall } from "./recall.ts"; import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.ts"; @@ -18831,6 +18832,9 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => { const message = store.appendMessage(threadId, { role: "bot", kind: "connector", + // Read only by clients that predate the card (older phones draw + // an unknown kind by its text); see connector-card-text.ts. + text: connectorCardText(toolkit.label, item.alias), ...(owner.group ? { from: { botId: owner.bot.id, name: owner.bot.name, color: owner.bot.color } } : {}), connector: { slug: item.slug, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8e3dcf4446..54c1163c82 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -94,6 +94,7 @@ import { placeSection, pinnedCircleThreadListVisible, sameSectionOrder, + sidebarConnectorPreview, sidebarGoalRunPreview, sidebarLayoutInteractive, sidebarSectionCollapsed, @@ -162,6 +163,7 @@ function preview(bot: Bot, visible: Message[], instances: InstanceInfo[]): strin // a failed turn reads as the chat row says it, never "error: …" if (last.kind === "activity" && last.tool) return activityPreview(last.tool, botEngine(bot, instances)); if (last.kind === "screen") return t("sidebar.preview.screenFrame"); + if (last.kind === "connector" && last.connector) return sidebarConnectorPreview(last.connector, t); const peer = peerLine(last); if (peer) return `${peer.name}: ${peer.body}`; return citationPreviewText(last.text ?? ""); @@ -192,7 +194,9 @@ function groupPreview(group: Group, bots: Bot[], instances: InstanceInfo[]): str ? activityPreview(last.tool, botEngine(bots.find((bot) => bot.id === last.from?.botId), instances)) : last.kind === "goal.run" && last.goalRun ? sidebarGoalRunPreview(last.goalRun) - : (last.text ?? ""); + : last.kind === "connector" && last.connector + ? sidebarConnectorPreview(last.connector, t) + : (last.text ?? ""); const readable = citationPreviewText(text); if (last.role === "user") return t("sidebar.preview.you", { text: readable }); return last.from ? `${last.from.name}: ${readable}` : readable; diff --git a/src/lib/sidebar-layout.test.ts b/src/lib/sidebar-layout.test.ts index af4749bbea..9b9fbbd158 100644 --- a/src/lib/sidebar-layout.test.ts +++ b/src/lib/sidebar-layout.test.ts @@ -12,6 +12,7 @@ import { partitionSidebarGroups, pinnedCircleThreadListVisible, placeSection, + sidebarConnectorPreview, sidebarLayoutInteractive, sidebarGoalRunPreview, sidebarSectionCollapsed, @@ -131,6 +132,20 @@ describe("sidebar virtual sections", () => { finishedAt: 2, })).toBe("Completed: Drafted and verified."); }); + + it("previews a connection card by its app and state, not the phone fallback line", () => { + const say = (key: string) => ({ + "connectors.card.connected": "Connected", + "connectors.card.waiting": "Waiting for sign-in…", + "connectors.card.connectSecurely": "Connect securely", + })[key] ?? key; + const card = { label: "GitHub", status: "required" as const }; + expect(sidebarConnectorPreview(card, say)).toBe("GitHub · Connect securely"); + expect(sidebarConnectorPreview({ ...card, status: "failed" }, say)).toBe("GitHub · Connect securely"); + expect(sidebarConnectorPreview({ ...card, status: "authorizing" }, say)).toBe("GitHub · Waiting for sign-in…"); + expect(sidebarConnectorPreview({ ...card, status: "connected" }, say)).toBe("GitHub · Connected"); + expect(sidebarConnectorPreview({ ...card, dismissed: true }, say)).toBe("GitHub"); + }); }); describe("sidebar section ordering", () => { diff --git a/src/lib/sidebar-layout.ts b/src/lib/sidebar-layout.ts index b4a4a798e8..62fc3cd997 100644 --- a/src/lib/sidebar-layout.ts +++ b/src/lib/sidebar-layout.ts @@ -1,4 +1,5 @@ import type { GroupGoalRunCardData } from "../../shared/group-goal-run"; +import type { ConnectorCardData } from "../../shared/wire"; export const PINNED_SECTION_ID = "builtin:pinned"; export const CHANNELS_SECTION_ID = "builtin:channels"; @@ -67,6 +68,23 @@ export function sidebarGoalRunPreview(run: GroupGoalRunCardData): string { return summary ? `${label}: ${summary}` : label; } +/** The catalog keys a connection card's preview reads its state from. */ +export type SidebarConnectorPreviewKey = "connectors.card.connected" | "connectors.card.waiting" | "connectors.card.connectSecurely"; + +/** A connection card previews as the app and where it stands, in the + * reader's language, never as the English line the computer writes for + * phones that cannot draw the card yet. */ +export function sidebarConnectorPreview( + connector: Pick, + say: (key: SidebarConnectorPreviewKey) => string, +): string { + if (connector.dismissed) return connector.label; + const key: SidebarConnectorPreviewKey = connector.status === "connected" + ? "connectors.card.connected" + : connector.status === "authorizing" ? "connectors.card.waiting" : "connectors.card.connectSecurely"; + return `${connector.label} · ${say(key)}`; +} + export function sidebarLayoutInteractive(density: SidebarDensityMode, query: string): boolean { return density !== "icons" && query.trim().length === 0; }