Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
- Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to
empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them.

### An app card carries the task on once the app is ready

When a Bot stopped because it needed an app, and the person then connected it in another tab or an
administrator turned it on for the Bot, the card said "Ask again". It now sends "Gmail is ready now.
Please carry on." for the person, once, and the Bot picks the task back up; a card opened after the
change offers Continue instead. An administrator looking at the card gets the fix itself, "Turn on
and give it to Sales" or "Give it to Sales", which grants the app's read-only actions with the same
checks and trail as approving a request, or a link to the app's page where one press can't do it.

### Screens share one spacing, row and empty-state design

Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px
Expand Down
60 changes: 50 additions & 10 deletions app/src/components/channels/app-access-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
appAccessSentence,
} from "@/components/plugins/app-access-action";
import { PluginLogo } from "@/components/plugins/plugin-logo";
import { Button } from "@/components/ui/button";
import { type AppAccess, botAppQueryOptions } from "@/lib/app-access/queries";
import { useDeclaredBotId } from "@/lib/copilot/active-bot";
import { type CardPlace, useCarryOn } from "@/lib/copilot/carry-on";
import type { ConnectPrompt } from "@openbot/shared/connect-prompt";
import { ConnectAppCard } from "./connect-app-card";

Expand All @@ -31,35 +33,63 @@ import { ConnectAppCard } from "./connect-app-card";
* `reconnect` or `shared` refusal over a live `ready` is drawn as the refusal says — unless the
* account was connected after the refusal, which is the reconnection. Anything else live is newer
* than the refusal and is drawn as it is.
*
* AND THEN THE TASK CARRIES ON. Once the app is ready — connected in the other tab, switched on and
* given by an admin — the card sends the conversation on by itself, or offers Continue where it was
* not watching when that happened. `place` is where the card sits in its conversation, without
* which (a card drawn outside one) it only says the app is ready. See `lib/copilot/carry-on.ts`.
*/
export function AppAccessCard({ prompt }: { prompt: ConnectPrompt }) {
export function AppAccessCard({
prompt,
place,
}: {
prompt: ConnectPrompt;
place?: CardPlace;
}) {
const declared = useDeclaredBotId();
const agentId = prompt.agentId ?? declared;
/*
* A refusal from before refusals named their Bot, on a surface that declared none: there is no Bot
* to ask about, so the card it was drawn as then is the card it is drawn as now.
*/
if (!agentId) return <ConnectAppCard {...prompt} />;
return <LiveAppAccessCard agentId={agentId} prompt={prompt} />;
if (!agentId) return <ConnectAppCard {...prompt} place={place} />;
return <LiveAppAccessCard agentId={agentId} place={place} prompt={prompt} />;
}

function LiveAppAccessCard({
agentId,
place,
prompt,
}: {
agentId: string;
place: CardPlace | undefined;
prompt: ConnectPrompt;
}) {
const live = useQuery(botAppQueryOptions(agentId, prompt.serverId));
const live = useQuery({
...botAppQueryOptions(agentId, prompt.serverId),
/*
* Read again whenever this tab has the focus back. What changes the state usually happens in
* another tab — a consent screen, an admin's page for the app — and the person returning here
* is the moment the card should say what is true now.
*/
refetchOnWindowFocus: true,
});
const access = live.data ? asRefused(live.data, prompt) : null;
const name = access?.displayName ?? prompt.title;
const { carried, offer } = useCarryOn({
place,
ready: access ? access.state === "ready" : null,
// A refusal was blocked by definition; `show_app_access` says what it found.
blockedWhenAsked: prompt.reason !== "access" || prompt.blocked === true,
app: name,
});
/*
* A read that failed falls back to what this card was before it read anything, where that was a
* card at all: a person's own missing connection still gets its Connect button. The other reasons
* had only ever been a sentence, and the sentence is what is left.
*/
if (live.error && (prompt.reason ?? "connect") === "connect")
return <ConnectAppCard {...prompt} />;
const access = live.data ? asRefused(live.data, prompt) : null;
const name = access?.displayName ?? prompt.title;
return <ConnectAppCard {...prompt} place={place} />;
return (
<div
className="my-2 flex w-full max-w-2xl flex-col gap-3 rounded-xl border border-border p-4"
Expand All @@ -80,9 +110,13 @@ function LiveAppAccessCard({
*/}
{live.isPending ? null : access ? (
<p className="text-muted-foreground text-sm">
{access.state === "ready" && prompt.reason !== "access"
? `${name} is ready now. Ask again and the Bot can use it.`
: refusedSentence(access, prompt)}
{access.state !== "ready"
? refusedSentence(access, prompt)
: carried || offer
? `${name} is ready now.`
: prompt.reason !== "access"
? `${name} is ready now. Ask again and the Bot can use it.`
: refusedSentence(access, prompt)}
</p>
) : (
<p className="text-muted-foreground text-sm" role="alert">
Expand All @@ -95,6 +129,12 @@ function LiveAppAccessCard({
<div className="flex justify-end">
<AppAccessAction access={access} agentId={agentId} />
</div>
) : offer ? (
<div className="flex justify-end">
<Button onClick={offer} size="sm">
Continue
</Button>
</div>
) : null}
</div>
);
Expand Down
47 changes: 45 additions & 2 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
toolResultFailed,
} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
import type { CardPlace } from "@/lib/copilot/carry-on";
import {
readConnectPrompt,
SHOW_APP_ACCESS_TOOL,
Expand Down Expand Up @@ -1301,12 +1302,15 @@ const TranscriptToolCall = memo(function TranscriptToolCall({
name,
args,
result,
latest,
}: {
delay: number;
toolCallId: string;
name: string;
args: string;
result?: string;
/** Nothing said or queued since this call, and the Bot is not working. See `CardPlace`. */
latest: boolean;
}) {
const renderToolCall = useRenderToolCall();
const toolCall = useMemo(
Expand Down Expand Up @@ -1344,7 +1348,13 @@ const TranscriptToolCall = memo(function TranscriptToolCall({
* What was called, shimmering until its result arrives, and then the server's own words
* drawn the way a Bot's prose is drawn.
*/}
{drawn ?? <ServerToolLine name={name} result={result} />}
{drawn ?? (
<ServerToolLine
name={name}
place={{ toolCallId, latest }}
result={result}
/>
)}
</ToolRenderBoundary>
</Arriving>
);
Expand All @@ -1361,9 +1371,12 @@ const TranscriptToolCall = memo(function TranscriptToolCall({
export function ServerToolLine({
name,
result,
place,
}: {
name: string;
result?: string;
/** Where this call sits in the conversation, for an app card that may carry the task on. */
place?: CardPlace;
}) {
const running = result === undefined;
const { label, detail } = readToolName(name, running);
Expand Down Expand Up @@ -1398,7 +1411,7 @@ export function ServerToolLine({
: name === SHOW_APP_ACCESS_TOOL
? readConnectPrompt(answer)
: null;
if (card) return <AppAccessCard prompt={card} />;
if (card) return <AppAccessCard place={place} prompt={card} />;
/*
* THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text
* telling the Bot to say so plainly and not to browse to it — an instruction, which read by the
Expand Down Expand Up @@ -1441,6 +1454,32 @@ export function isPersonSentMessage(
return role === "user" && readFiring(text) === null;
}

/**
* WHERE THE CONVERSATION HAS GOT TO, for an app card that may carry its task on once the app is
* ready (see `lib/copilot/carry-on.ts`): the index from which a row is still the latest thing, or
* past the end when none is.
*
* A card is the latest thing while nothing has been said to the Bot since it, nothing is queued, and
* the Bot is not working: past that, the conversation has moved on, and an older task is not picked
* up again underneath it. ANY USER TURN COUNTS, a routine firing included — unlike the scroll
* machinery's `isPersonSentMessage`. A firing is not the person speaking, but it is a new task the
* Bot has answered, and "please carry on" sent after it would land on the routine's work rather than
* on the task the card interrupted. Exported so it can be checked without mounting anything.
*/
export function latestFrom(
items: readonly TranscriptItem[],
{ busy, queued }: { busy: boolean; queued: number },
): number {
if (busy || queued > 0) return items.length;
return (
items.findLastIndex(
(item) =>
(item.kind === "text" && item.role === "user") ||
item.kind === "attachments",
) + 1
);
}

/**
* The turn a row belongs to.
*
Expand Down Expand Up @@ -1602,6 +1641,9 @@ export function ChatTranscript({
)?.id ?? null;
useSmoothSendScroll(viewportRef, newestUserMessageId);

/* Which tool calls are still the latest thing, for an app card: see `latestFrom`. */
const latestIndex = latestFrom(items, { busy, queued: queued.length });

/* One rule for both kinds of row — see `anchorRowIds`, which is where it is written down. */
const anchorRows = anchorRowIds(items);

Expand Down Expand Up @@ -1723,6 +1765,7 @@ export function ChatTranscript({
<TranscriptToolCall
args={item.toolCall.function.arguments}
delay={delays.delayFor(item.id, index, items.length)}
latest={index >= latestIndex}
name={item.toolCall.function.name}
result={item.result}
toolCallId={item.toolCall.id}
Expand Down
27 changes: 24 additions & 3 deletions app/src/components/channels/connect-app-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { useConnectInNewTab } from "@/components/plugins/connect-in-new-tab";
import { Button } from "@/components/ui/button";
import { type CardPlace, useCarryOn } from "@/lib/copilot/carry-on";
import {
connectionsQueryOptions,
personalConnections,
Expand All @@ -26,15 +27,19 @@ import { queryClient } from "@/query-client";
* draws it from the app's live state for this person on that Bot. This one is drawn only where no
* Bot can be named — a refusal written before refusals carried one, on a surface that declared none
* — and it can only offer Connect, which is all such a refusal ever meant.
*
* Once connected it carries the conversation on, as `AppAccessCard` does: see `useCarryOn`.
*/
export function ConnectAppCard({
serverId,
title,
message,
place,
}: {
serverId: string;
title: string;
message: string;
place?: CardPlace | undefined;
}) {
const {
open: openTab,
Expand All @@ -59,6 +64,13 @@ export function ConnectAppCard({
const connected = personalConnections(
connections.data?.connections ?? [],
).some((row) => row.serverId === serverId && row.revoked !== true);
// Every card drawn here is a refusal, so the app was blocked when the Bot asked.
const { carried, offer } = useCarryOn({
place,
ready: connections.data ? connected : null,
blockedWhenAsked: true,
app: title,
});

/*
* The grants a Bot holds are read per conversation, so the turn that follows has to be asked
Expand All @@ -85,9 +97,18 @@ export function ConnectAppCard({
<p className="text-muted-foreground text-sm">{message}</p>
</div>
{connected ? (
<p className="text-sm" role="status">
{title} is connected. Ask again and the Bot can use it.
</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm" role="status">
{carried || offer
? `${title} is connected.`
: `${title} is connected. Ask again and the Bot can use it.`}
</p>
{offer ? (
<Button onClick={offer} size="sm">
Continue
</Button>
) : null}
</div>
) : needsKey ? (
<p className="text-sm" role="status">
{title} needs a key from you.{" "}
Expand Down
Loading
Loading