Skip to content
Draft
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 @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

- Bots answer ordinary questions, summaries and short lists directly in Markdown by default.
Notice cards are reserved for distinct actionable alerts or an explicit request; other components
are used when their visual or interactive behavior makes the answer easier to use. Existing
deployments keep their published component descriptions; administrators can update the Notice
and Checklist descriptions to match the new defaults.
- Notice cards render Markdown in their explanation and supporting points, including links,
formatting and code, using the same renderer as conversation replies.
- The sidebar's new-conversation action is labelled "New thread" beside a compose icon.

- OpenBot Desktop updates and sets up its local back end on a slow connection. Before the back
end's update (a new one, or the next Start's retry of one that did not finish) or a setup that
resumes, it downloads the back end's images itself, one at a time and with no fixed time limit,
Expand Down
21 changes: 7 additions & 14 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconLogout, IconPlus, IconSearch } from "@tabler/icons-react";
import { IconEdit, IconLogout, IconSearch } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, type LinkOptions, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
Expand Down Expand Up @@ -229,7 +229,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<Sidebar variant="inset" {...props}>
<SidebarHeader className="h-12 p-2">
<SidebarMenu>
<SidebarMenuItem className="flex flex-row gap-1.5">
<SidebarMenuItem className="flex flex-row items-center gap-1.5">
<SidebarMenuButton
className="font-semibold text-[14px] tracking-tighter h-full leading-tight"
render={(props) => (
Expand All @@ -239,20 +239,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)}
/>
<Button
aria-label="New conversation"
size="icon"
variant="ghost"
render={(props) => (
<Link
{...props}
to="/channel/new"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
size="sm"
className="shrink-0 gap-1.5 px-2 text-[13px] font-normal text-muted-foreground hover:text-foreground"
render={(props) => <Link {...props} to="/channel/new" />}
>
<IconPlus />
<IconEdit className="size-4" />
<span>New thread</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
Expand Down
15 changes: 10 additions & 5 deletions app/src/components/gallery/cards.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { GalleryComponent } from "@/lib/copilot/gallery-registry";
import { Badge, GalleryFrame, type Tone } from "./frame";
import { GalleryMarkdown } from "./markdown";
import { byPosition } from "./positions";

const tone = z
Expand Down Expand Up @@ -226,13 +227,17 @@ export function NoticeCard({
action={badge ? <Badge tone={noticeTone}>{badge}</Badge> : undefined}
title={title}
>
<p className="text-sm">{body}</p>
<GalleryMarkdown>{body}</GalleryMarkdown>
{points?.length ? (
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
{byPosition(points).map(({ item: entry, key }) => (
<li className="flex gap-2" key={key}>
// A point can already be a Markdown list, so give it only its own marker and indent.
<li
className="flex gap-2 [&:has(>div>:is(ol,ul):first-child)>span]:hidden [&>div>:is(ol,ul)]:pl-0"
key={key}
>
<span aria-hidden="true">·</span>
<span className="min-w-0">{entry}</span>
<GalleryMarkdown>{entry}</GalleryMarkdown>
</li>
))}
</ul>
Expand Down Expand Up @@ -300,7 +305,7 @@ export const GALLERY: GalleryComponent[] = [
summary:
"Shows the steps of a piece of work and which ones are done, such as a release plan.",
description:
"Show the tasks or steps of a piece of work and which are finished, such as a release plan or an onboarding list. Only for work somebody ticks off. Not for presenting information: findings, options, recommendations or a list of points go in text or a notice. The person cannot tick these here, so do not use it to ask for anything.",
"Show the tasks or steps of a piece of work and which are finished, such as a release plan or an onboarding list. Only for work somebody ticks off. Not for presenting information: findings, options, recommendations and ordinary lists of points belong in a Markdown reply. The person cannot tick these here, so do not use it to ask for anything.",
parameters: ChecklistCardProps,
Component: ChecklistCard as GalleryComponent["Component"],
preview: {
Expand All @@ -320,7 +325,7 @@ export const GALLERY: GalleryComponent[] = [
summary:
"Shows a headline with a short explanation and a few supporting points.",
description:
"Show a headline, a short explanation and optional supporting points. Use instead of writing several paragraphs of prose.",
"Show a distinct actionable alert needing the person's attention, such as a failed scheduled task requiring intervention or a new issue blocking their work. Also use when the person explicitly asks for a notice card. Answer ordinary questions, explanations, summaries, recommendations and short lists directly in Markdown, including important emails or deadlines. Importance, a caveat or several paragraphs alone does not justify a notice. Do not use this to wrap or repeat a routine answer. Body and supporting points support Markdown.",
parameters: NoticeCardProps,
Component: NoticeCard as GalleryComponent["Component"],
preview: {
Expand Down
19 changes: 19 additions & 0 deletions app/src/components/gallery/markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useContext } from "react";
import { Streamdown } from "streamdown";
import { markdownComponents, markdownPlugins } from "@/lib/markdown";
import { GalleryStreamingContext } from "./streaming";

/** Bot-authored card prose uses the same renderer and links as a chat reply. */
export function GalleryMarkdown({ children }: { children?: string }) {
const streaming = useContext(GalleryStreamingContext);
return (
<Streamdown
className="min-w-0 break-words text-sm"
components={markdownComponents}
mode={streaming ? "streaming" : "static"}
plugins={markdownPlugins}
>
{children ?? ""}
</Streamdown>
);
}
98 changes: 98 additions & 0 deletions app/tests/notice-card-markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { cleanup, render, waitFor } from "@testing-library/react";
import { NoticeCard } from "@/components/gallery/cards";
import { GalleryStreamingContext } from "@/components/gallery/streaming";
import { settleReactWork } from "./settle-react-work";

beforeAll(() => GlobalRegistrator.register({ url: "http://localhost/" }));
afterEach(cleanup);
afterAll(async () => {
await settleReactWork();
GlobalRegistrator.unregister();
});

test("notice body and supporting points render Markdown with the reply's links", () => {
const view = render(
<NoticeCard
title="Two emails to check"
tone="caution"
body="These are my **highest-priority** picks. See the [review](https://docs.google.com/document/d/review/edit)."
points={[
"1. ElevenLabs — new login. [Read the email](https://mail.google.com/mail/u/0/#all/example-one).",
"2. AWS — update `redis`. [Read the email](https://mail.google.com/mail/u/0/#all/example-two).",
]}
/>,
);

expect(view.getByText("Heads up")).toBeTruthy();
expect(
view.getByText("highest-priority").getAttribute("data-streamdown"),
).toBe("strong");
expect(view.getByText("redis").tagName).toBe("CODE");
const emails = view.getAllByRole("link", { name: "Read the email" });
expect(emails.map((link) => link.getAttribute("href"))).toEqual([
"https://mail.google.com/mail/u/0/#all/example-one",
"https://mail.google.com/mail/u/0/#all/example-two",
]);
for (const link of emails) {
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noreferrer noopener");
}
expect(view.getByRole("link", { name: "review Doc" })).toBeTruthy();
expect(view.container.textContent).not.toContain("[Read the email]");
});

test("notice Markdown supports blocks and keeps the existing renderer's sanitization", async () => {
const view = render(
<NoticeCard
body={
"## Review\n\n- **First**\n- Second\n\n```js\nconst ready = true;\n```"
}
points={["[Unsafe](javascript:alert(1))", '<script>alert("no")</script>']}
/>,
);

expect(view.getByRole("heading", { name: "Review" })).toBeTruthy();
expect(view.getByText("First").getAttribute("data-streamdown")).toBe(
"strong",
);
expect(view.container.querySelector("p pre, p div, p ul")).toBeNull();
expect(view.container.querySelector("script")).toBeNull();
expect(view.container.querySelector('a[href^="javascript:"]')).toBeNull();
await waitFor(() => {
expect(
view.container.querySelector('pre span[style*="--shiki-dark"]'),
).toBeTruthy();
});
expect(view.getByRole("button", { name: /copy/i })).toBeTruthy();
});

test("notice Markdown handles partial arguments while streaming and completed text on replay", () => {
const view = render(
<GalleryStreamingContext.Provider value>
<NoticeCard body="Check **priority" points={["Read **first"]} />
</GalleryStreamingContext.Provider>,
);
expect(view.getByText("priority").getAttribute("data-streamdown")).toBe(
"strong",
);
expect(view.getByText("first").getAttribute("data-streamdown")).toBe(
"strong",
);

view.rerender(
<GalleryStreamingContext.Provider value={false}>
<NoticeCard body="Check **priority**." points={["Read **first**."]} />
</GalleryStreamingContext.Provider>,
);
expect(view.getByText("priority").getAttribute("data-streamdown")).toBe(
"strong",
);
expect(view.getByText("first").getAttribute("data-streamdown")).toBe(
"strong",
);

view.rerender(<NoticeCard />);
expect(view.container.textContent).toBe("");
});
19 changes: 14 additions & 5 deletions server/tests/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1410,10 +1410,9 @@ describe("where a Bot says its answer came from", () => {
});

/**
* A Bot is told to answer with a component when the answer has structure.
* A Bot answers in Markdown by default and uses components when they add value.
*
* Every Bot was offered the gallery and none was told when to use it, so structured answers came back
* as prose. The rule travels the same three ways the provenance rule does, and each is asserted,
* The rule travels the same three ways the provenance rule does, and each is asserted,
* because they are built by different code: the built-in prompt, the standing role that is a remote
* Bot's only instruction, and a background turn, which builds its Bot through the resolver rather
* than the request path.
Expand All @@ -1434,7 +1433,7 @@ describe("when a Bot answers with a component", () => {
);
const prompt = "prompt" in configuration ? configuration.prompt : undefined;

// Its last line points at "the rules above", so the order is part of the rule.
// Its provenance reference points at "the rules above", so the order is part of the rule.
expect(prompt).toContain(`${PROVENANCE_GUIDANCE}\n\n${COMPONENT_GUIDANCE}`);
});

Expand All @@ -1446,7 +1445,17 @@ describe("when a Bot answers with a component", () => {
const guidance = COMPONENT_GUIDANCE.toLowerCase().replace(/\s+/g, " ");
for (const kind of ["a chart", "a checklist", "a record card", "choose"])
expect(guidance).toContain(kind);
expect(guidance).toContain("plain text only for a short conversational");
expect(guidance).toContain("answer directly in markdown by default");
expect(guidance).toContain("when the person explicitly asks for it");
expect(guidance).toContain(
"materially easier to understand or use than markdown",
);
expect(guidance).toContain(
"reserve a notice card for a distinct actionable alert",
);
expect(guidance).not.toContain(
"plain text only for a short conversational",
);
// Tool names are camelCase or snake_case; the tools on a turn vary per Bot and grant.
expect(COMPONENT_GUIDANCE).not.toMatch(/\b[a-z]+[A-Z]\w*\b|\b\w+_\w+\b/);
});
Expand Down
38 changes: 21 additions & 17 deletions shared/bot-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,32 +230,36 @@ export const PROVENANCE_GUIDANCE = asParagraphs(PROVENANCE_GUIDANCE_LINES);
/**
* When to answer with a component rather than prose.
*
* Every Bot is offered the gallery (charts, tables, record cards, checklists, choices), sandboxed UI
* in the browser, and the display half of the gallery on a background turn. Nothing said when to use
* any of it, so each tool's own description decided alone: a comparison of figures got a chart, but
* a first-week plan came back as headed lists and a landmark's key facts as bullets, from a Bot
* holding a checklist and a record card.
* Every Bot is offered the gallery and generated UI. Preferring a component whenever an answer had
* structure made ordinary answers into cards: even a short list of emails became a notice. Markdown
* is the default; a component needs a concrete benefit or an explicit request.
*
* It names kinds of answer and never a tool, because the tools on a turn differ: by Bot, by grant,
* and between a person's own turn and a background one, where nothing that waits on a person is
* offered. A list of names would promise a component somebody's turn does not hold.
*
* It goes straight after `PROVENANCE_GUIDANCE` in every prompt, which is what "the rules above" in
* its last line points at: a chart drawn because this said to is still a chart whose figures have to
* It goes straight after `PROVENANCE_GUIDANCE` in every prompt, which is what "the rules above"
* points at: a chart drawn because this said to is still a chart whose figures have to
* say where they came from.
*/
const COMPONENT_GUIDANCE_LINES = [
"When an answer has structure, answer with a component from your tools rather than in prose.",
"Numbers or a comparison: a chart or headline figures. Rows that share the same fields: a table.",
"Tasks or steps of a piece of work, with which are finished: a checklist. A list of points, findings",
"or recommendations: text or a notice, never a checklist, whose ticks would mark the answer done.",
"The details of one thing, such as a person, an order or an account: a record card. A choice",
"the person has to make: a card they choose from. Use whichever of the tools you hold on this turn",
"fits, and put a sentence or two of text around it saying what it shows or what comes next, without",
"repeating what it already shows.",
"Answer directly in Markdown by default. Simple questions, explanations, summaries, recommendations",
"and short lists belong in your reply, including a list of the top emails and why they matter.",
"Headings, bullets, links, numbers or several paragraphs are not by themselves a reason to draw a",
"component or generate an interface. When in doubt, answer in Markdown.",
"",
"Answer in plain text only for a short conversational reply, or when none of your tools fits the",
"answer. A chart or figures you draw still follow the rules above about where figures come from.",
"Use a component from the tools you hold when the person explicitly asks for it, or when its visual",
"or interactive behavior makes the answer materially easier to understand or use than Markdown.",
"A chart can reveal a trend; a table can help compare many rows; a checklist can track tasks and",
"which are finished; a record card can organize a record's fields; a choice card lets the person",
"choose. Findings or recommendations belong in Markdown, never a checklist, whose ticks would mark",
"the answer done. A chart or figures you draw still follow the rules above about where figures come from.",
"",
"Reserve a notice card for a distinct actionable alert that needs the person's attention, such as",
"a failed scheduled task needing intervention or a new issue blocking their work, or for an explicit",
"request for a notice card. An ordinary answer about risks, deadlines or important emails is still",
"an answer: give it in Markdown. Importance or a caveat alone does not call for a notice.",
"Do not wrap a routine answer in a notice or repeat the same answer in both text and a card.",
"",
"Draw a component once, when you have the result it shows. Do not draw a draft and then a second",
"one that replaces it: everything you draw stays in front of the person.",
Expand Down
Loading