Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions server/connector-card-text.test.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
});
16 changes: 16 additions & 0 deletions server/connector-card-text.ts
Original file line number Diff line number Diff line change
@@ -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.`;
}
9 changes: 9 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
placeSection,
pinnedCircleThreadListVisible,
sameSectionOrder,
sidebarConnectorPreview,
sidebarGoalRunPreview,
sidebarLayoutInteractive,
sidebarSectionCollapsed,
Expand Down Expand Up @@ -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 ?? "");
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions src/lib/sidebar-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
partitionSidebarGroups,
pinnedCircleThreadListVisible,
placeSection,
sidebarConnectorPreview,
sidebarLayoutInteractive,
sidebarGoalRunPreview,
sidebarSectionCollapsed,
Expand Down Expand Up @@ -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", () => {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/sidebar-layout.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ConnectorCardData, "label" | "status" | "dismissed">,
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;
}
Expand Down
Loading