From 41da67de0230dbdda33abc1b4d89287ff26c2f4a Mon Sep 17 00:00:00 2001 From: Atai Barkai Date: Sun, 8 Nov 2026 00:11:30 -0800 Subject: [PATCH] Improve chat defaults, notice Markdown, and the new-thread action --- CHANGELOG.md | 9 ++ .../components/app-sidebar/app-sidebar.tsx | 21 ++-- app/src/components/gallery/cards.tsx | 15 ++- app/src/components/gallery/markdown.tsx | 19 ++++ app/tests/notice-card-markdown.test.tsx | 98 +++++++++++++++++++ server/tests/copilot.test.ts | 19 +++- shared/bot-prompt.ts | 38 +++---- 7 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 app/src/components/gallery/markdown.tsx create mode 100644 app/tests/notice-card-markdown.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f5ee76f9..be24f02ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index d21d7b1db5..7d7855d630 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -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"; @@ -229,7 +229,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + ( @@ -239,20 +239,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { )} /> diff --git a/app/src/components/gallery/cards.tsx b/app/src/components/gallery/cards.tsx index 4f631ad91b..2c116ce652 100644 --- a/app/src/components/gallery/cards.tsx +++ b/app/src/components/gallery/cards.tsx @@ -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 @@ -226,13 +227,17 @@ export function NoticeCard({ action={badge ? {badge} : undefined} title={title} > -

{body}

+ {body} {points?.length ? (
    {byPosition(points).map(({ item: entry, key }) => ( -
  • + // A point can already be a Markdown list, so give it only its own marker and indent. +
  • - {entry} + {entry}
  • ))}
@@ -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: { @@ -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: { diff --git a/app/src/components/gallery/markdown.tsx b/app/src/components/gallery/markdown.tsx new file mode 100644 index 0000000000..14d6ef6806 --- /dev/null +++ b/app/src/components/gallery/markdown.tsx @@ -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 ( + + {children ?? ""} + + ); +} diff --git a/app/tests/notice-card-markdown.test.tsx b/app/tests/notice-card-markdown.test.tsx new file mode 100644 index 0000000000..0f07673fbf --- /dev/null +++ b/app/tests/notice-card-markdown.test.tsx @@ -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( + , + ); + + 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( + alert("no")']} + />, + ); + + 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( + + + , + ); + expect(view.getByText("priority").getAttribute("data-streamdown")).toBe( + "strong", + ); + expect(view.getByText("first").getAttribute("data-streamdown")).toBe( + "strong", + ); + + view.rerender( + + + , + ); + expect(view.getByText("priority").getAttribute("data-streamdown")).toBe( + "strong", + ); + expect(view.getByText("first").getAttribute("data-streamdown")).toBe( + "strong", + ); + + view.rerender(); + expect(view.container.textContent).toBe(""); +}); diff --git a/server/tests/copilot.test.ts b/server/tests/copilot.test.ts index 9a00c40089..97a589fe28 100644 --- a/server/tests/copilot.test.ts +++ b/server/tests/copilot.test.ts @@ -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. @@ -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}`); }); @@ -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/); }); diff --git a/shared/bot-prompt.ts b/shared/bot-prompt.ts index 337e89cbb9..c68b1d53a0 100644 --- a/shared/bot-prompt.ts +++ b/shared/bot-prompt.ts @@ -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.",