From 4b461f6770936b1a0a21aa9cb6bf932523dae5b3 Mon Sep 17 00:00:00 2001 From: Cheskie Londinsky Date: Sun, 23 Aug 2026 15:17:17 -0400 Subject: [PATCH] Add upstream-inspired app UX improvements --- README.md | 2 +- app/index.html | 8 + app/src/components/skills/edit-skill.tsx | 1 + app/src/components/skills/skill-fields.tsx | 18 +- app/src/components/skills/skill-tools.tsx | 123 + app/src/components/theme-provider.tsx | 24 +- app/src/lib/copilot/bot-thread.ts | 88 +- app/src/lib/plugins/queries.ts | 3 + app/src/lib/skills/form.ts | 12 + app/src/lib/theme.ts | 3 + app/src/routes/_authed/_app/bot.tsx | 37 +- app/src/styles.css | 2 + app/tests/bot-thread.test.ts | 13 + app/tests/skill-form.test.ts | 42 + app/tests/theme-preference.test.ts | 39 + docs/architecture.md | 2 +- server/drizzle/0012_dusty_wraith.sql | 10 + server/drizzle/meta/0012_snapshot.json | 7299 ++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/plugins.ts | 23 + server/src/plugins/routes.ts | 39 +- server/src/plugins/store.ts | 82 + server/tests/skill-tools.integration.test.ts | 175 + 23 files changed, 8014 insertions(+), 38 deletions(-) create mode 100644 app/src/components/skills/skill-tools.tsx create mode 100644 app/tests/bot-thread.test.ts create mode 100644 app/tests/skill-form.test.ts create mode 100644 server/drizzle/0012_dusty_wraith.sql create mode 100644 server/drizzle/meta/0012_snapshot.json create mode 100644 server/tests/skill-tools.integration.test.ts diff --git a/README.md b/README.md index 02646cc..5043395 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui), - **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only. - **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component. - **Governed MCP with OAuth**: a curated catalogue ships for Atlassian, Box, Slack, Salesforce and ServiceNow. Servers can use a write-only bearer token or OAuth discovery, dynamic registration and PKCE; encrypted tokens, grants, policy, approvals and audit stay server-side. -- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. +- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. Authors can record which connected tools a skill expects without granting access to any of them. - **Durable work control center**: `/work` combines queued and active runs, reusable manual/scheduled/webhook routines, user-to-Bot and Bot-to-Bot handoffs, shared project artifacts, and actionable notifications. A leased executor adds heartbeats, bounded attempts, timeout budgets, retries and crash recovery. - **Governed autonomy without a second runtime**: proactive monitors stay quiet when nothing needs attention; paired Telegram, Slack, Discord, and signed webhook messages use the ordinary task queue and a retrying outbox; skill and memory learning stays in review; transient model failures can follow a visible fallback route; and large tool catalogs/results are bounded without bypassing grants or policy. - **Shared project teams**: assign several coworkers to a project, open one team channel, and explicitly choose which coworker answers each turn. Computers, credentials, and browser sessions remain isolated per Bot. diff --git a/app/index.html b/app/index.html index cd14d71..d8cb3e9 100644 --- a/app/index.html +++ b/app/index.html @@ -4,6 +4,14 @@ OpenBot + +
diff --git a/app/src/components/skills/edit-skill.tsx b/app/src/components/skills/edit-skill.tsx index fa26cad..2821588 100644 --- a/app/src/components/skills/edit-skill.tsx +++ b/app/src/components/skills/edit-skill.tsx @@ -79,6 +79,7 @@ export function EditSkill({ slug }: { slug: string }) { title: skill.title, summary: skill.summary ?? "", instructions: skill.instructions, + tools: skill.tools ?? [], }} error={saveSkill.error} /* diff --git a/app/src/components/skills/skill-fields.tsx b/app/src/components/skills/skill-fields.tsx index f2c1559..5f88a86 100644 --- a/app/src/components/skills/skill-fields.tsx +++ b/app/src/components/skills/skill-fields.tsx @@ -1,4 +1,5 @@ import { useForm } from "@tanstack/react-form"; +import { SkillTools } from "@/components/skills/skill-tools"; import { Button } from "@/components/ui/button"; import { Field, @@ -11,12 +12,12 @@ import { Textarea } from "@/components/ui/textarea"; import { type SkillFormValues, skillFormSchema } from "@/lib/skills/form"; /** - * The four things a person decides about a skill: what they type, what it is called, what it is - * for, and what the Bot is actually told. + * The things a person decides about a skill: its command, name, purpose, instructions, and the + * connected tools it expects. * * Written as its own component rather than inline in the panel because creating and editing a skill - * are the same four fields, and the second one is coming — a skill whose instructions can only be - * set once is a skill nobody will correct. + * share the same fields — a skill whose instructions can only be set once is a skill nobody will + * correct. */ export function SkillFields({ defaultValues, @@ -188,6 +189,15 @@ export function SkillFields({ ); }} + + + {(field) => ( + + )} + {/* diff --git a/app/src/components/skills/skill-tools.tsx b/app/src/components/skills/skill-tools.tsx new file mode 100644 index 0000000..2e5f535 --- /dev/null +++ b/app/src/components/skills/skill-tools.tsx @@ -0,0 +1,123 @@ +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { pluginsPageQueryOptions } from "@/lib/plugins/queries"; +import { declaredElsewhere } from "@/lib/skills/form"; + +/** + * Tool requirements belong to the saved skill, but never widen a Bot's grants. + */ +export function SkillTools({ + selected, + onChange, +}: { + selected: string[]; + onChange: (refs: string[]) => void; +}) { + const plugins = useQuery(pluginsPageQueryOptions()); + const held = new Set(selected); + const servers = (plugins.data?.servers ?? []).filter( + (server) => server.tools.length > 0, + ); + const missing = plugins.data + ? declaredElsewhere( + selected, + servers.flatMap((server) => server.tools.map((tool) => tool.ref)), + ) + : []; + + const toggle = (ref: string) => { + onChange( + held.has(ref) + ? selected.filter((candidate) => candidate !== ref) + : [...selected, ref], + ); + }; + + return ( + + Tools this skill expects + + {plugins.isPending ? ( +

+ Loading connected tools… +

+ ) : plugins.error ? ( +

+ Connected tools could not be loaded. +

+ ) : servers.length === 0 ? ( +

+ No connected server currently offers tools. Most skills only need + instructions, so you can still save this one. +

+ ) : ( +
+ {servers.map((server) => ( +
+

{server.title}

+
+ {server.tools.map((tool) => { + const selectedTool = held.has(tool.ref); + return ( + + ); + })} +
+
+ ))} +
+ )} + + {missing.length > 0 ? ( +
+

Not connected here

+
+ {missing.map((ref) => ( + + ))} +
+

+ These saved requirements are unavailable because a connector is + missing or stopped offering the tool. Click one to remove it. +

+
+ ) : null} + +

+ This records what the skill needs. It does not grant access: the Bot can + only call tools it was already given, and every call still passes policy + and audit checks. +

+
+ ); +} diff --git a/app/src/components/theme-provider.tsx b/app/src/components/theme-provider.tsx index d92a32e..9c023f9 100644 --- a/app/src/components/theme-provider.tsx +++ b/app/src/components/theme-provider.tsx @@ -19,15 +19,31 @@ type ThemeContextValue = { const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: ReactNode }) { - const [dark, setDark] = useState(() => - parseStoredDarkTheme(window.localStorage.getItem(THEME_STORAGE_KEY)), - ); + const [dark, setDark] = useState(() => { + try { + return parseStoredDarkTheme( + window.localStorage.getItem(THEME_STORAGE_KEY), + ); + } catch { + // Storage can be unavailable in privacy modes. The light default still leaves the app usable. + return false; + } + }); useEffect(() => { applyDarkTheme(dark, { - setStoredValue: (key, value) => window.localStorage.setItem(key, value), + setStoredValue: (key, value) => { + try { + window.localStorage.setItem(key, value); + } catch { + // Applying the live theme matters even when the preference cannot be persisted. + } + }, toggleRootClass: (name, force) => document.documentElement.classList.toggle(name, force), + setRootColorScheme: (scheme) => { + document.documentElement.style.colorScheme = scheme; + }, }); }, [dark]); diff --git a/app/src/lib/copilot/bot-thread.ts b/app/src/lib/copilot/bot-thread.ts index 087b852..06a5a48 100644 --- a/app/src/lib/copilot/bot-thread.ts +++ b/app/src/lib/copilot/bot-thread.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; /** * The thread the direct Bot chat talks in. @@ -13,9 +13,14 @@ import { useEffect, useState } from "react"; const KEY = "openbot.bot-thread"; +/** One construction point keeps reads and writes isolated per Bot. */ +export function botThreadKey(agentId: string): string { + return `${KEY}.${agentId}`; +} + function remembered(agentId: string): string | null { try { - return window.localStorage.getItem(`${KEY}.${agentId}`); + return window.localStorage.getItem(botThreadKey(agentId)); } catch { // Storage can be unavailable or full. A thread for this visit is better than no chat at all. return null; @@ -24,7 +29,7 @@ function remembered(agentId: string): string | null { function remember(agentId: string, threadId: string): void { try { - window.localStorage.setItem(`${KEY}.${agentId}`, threadId); + window.localStorage.setItem(botThreadKey(agentId), threadId); } catch { // As above: the conversation still works, it just will not be here next time. } @@ -48,32 +53,81 @@ async function mint(): Promise { * `undefined` until it is known, which is not the same as absent: rendering the chat before then * would let it mint an id of its own, and that is the one this deployment would then be stuck with. */ -export function useBotThread(agentId: string): string | undefined { +export type BotThread = { + threadId: string | undefined; + /** Starts a separate conversation without deleting the one currently stored upstream. */ + startNew: () => Promise; + startingNew: boolean; +}; + +export function useBotThread(agentId: string): BotThread { const [threadId, setThreadId] = useState(undefined); + const [startingNew, setStartingNew] = useState(false); + const requestRef = useRef(0); + const mintingRef = useRef(false); + const mountedRef = useRef(true); useEffect(() => { - let current = true; + mountedRef.current = true; + const request = requestRef.current + 1; + requestRef.current = request; setThreadId(undefined); + setStartingNew(false); + mintingRef.current = false; const existing = remembered(agentId); if (existing) { setThreadId(existing); - return; + } else { + mintingRef.current = true; + setStartingNew(true); + void mint() + .then((minted) => { + if (!mountedRef.current || requestRef.current !== request) return; + // Falling back to one made here keeps the chat working when the deployment cannot be asked; + // it is simply a thread nothing can later attribute. + const next = minted ?? crypto.randomUUID(); + if (minted) remember(agentId, minted); + setThreadId(next); + }) + .finally(() => { + if (!mountedRef.current || requestRef.current !== request) return; + mintingRef.current = false; + setStartingNew(false); + }); } - void mint().then((minted) => { - if (!current) return; - // Falling back to one made here keeps the chat working when the deployment cannot be asked; - // it is simply a thread nothing can later attribute. - const next = minted ?? crypto.randomUUID(); - if (minted) remember(agentId, minted); - setThreadId(next); - }); - return () => { - current = false; + mountedRef.current = false; + if (requestRef.current !== request) return; + requestRef.current += 1; + mintingRef.current = false; }; }, [agentId]); - return threadId; + const startNew = useCallback(async () => { + if (mintingRef.current) return false; + + const request = requestRef.current + 1; + requestRef.current = request; + mintingRef.current = true; + setStartingNew(true); + try { + const minted = await mint(); + if (!minted || !mountedRef.current || requestRef.current !== request) { + return false; + } + + remember(agentId, minted); + setThreadId(minted); + return true; + } finally { + if (mountedRef.current && requestRef.current === request) { + mintingRef.current = false; + setStartingNew(false); + } + } + }, [agentId]); + + return { threadId, startNew, startingNew }; } diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index a614f1e..5b63fa2 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -50,6 +50,8 @@ export type PluginSkill = { origin: string; installedBy: string | null; grantedTo: string[]; + /** Tools the skill expects as `/` refs; this grants no access. */ + tools: string[]; }; export type CatalogueItem = { @@ -83,6 +85,7 @@ export type GrantedPlugins = { title: string; summary: string; instructions: string; + tools: string[]; }[]; }; diff --git a/app/src/lib/skills/form.ts b/app/src/lib/skills/form.ts index 596243b..f4c3f7d 100644 --- a/app/src/lib/skills/form.ts +++ b/app/src/lib/skills/form.ts @@ -34,6 +34,8 @@ export const skillFormSchema = z.object({ .string() .trim() .min(1, "Instructions are required — this is what the Bot follows."), + /** Tool requirements are declarations. The server remains authoritative for valid refs. */ + tools: z.array(z.string()), }); export type SkillFormValues = z.infer; @@ -43,4 +45,14 @@ export const emptySkillForm: SkillFormValues = { title: "", summary: "", instructions: "", + tools: [], }; + +/** Declarations that are not offered by any currently connected server. */ +export function declaredElsewhere( + selected: readonly string[], + offered: readonly string[], +): string[] { + const known = new Set(offered); + return selected.filter((ref) => !known.has(ref)); +} diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts index b642454..aadbe96 100644 --- a/app/src/lib/theme.ts +++ b/app/src/lib/theme.ts @@ -7,9 +7,12 @@ export function parseStoredDarkTheme(value: string | null) { type ThemeEffects = { setStoredValue: (key: string, value: string) => void; toggleRootClass: (name: string, force: boolean) => void; + setRootColorScheme: (scheme: "dark" | "light") => void; }; export function applyDarkTheme(dark: boolean, effects: ThemeEffects) { effects.setStoredValue(THEME_STORAGE_KEY, dark ? "dark" : "light"); effects.toggleRootClass("dark", dark); + // The pre-paint script sets an inline value, so theme changes must update that same value. + effects.setRootColorScheme(dark ? "dark" : "light"); } diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index 2b48518..010515d 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -3,6 +3,7 @@ import { CopilotChatView, type CopilotChatViewProps, } from "@copilotkit/react-core/v2"; +import { IconPlus } from "@tabler/icons-react"; import { createFileRoute } from "@tanstack/react-router"; import { type ComponentProps, @@ -17,6 +18,7 @@ import { thinkingStatusText, useThinkingSeconds, } from "@/components/channels/thinking-status"; +import { Button } from "@/components/ui/button"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { useBotThread } from "@/lib/copilot/bot-thread"; import { useStoppedTurn } from "@/lib/copilot/stopped-turn"; @@ -146,7 +148,8 @@ function RouteComponent() { // Tool calls here act on this Bot's own computer. useActiveBot(agentId); // Minted by this deployment rather than by the chat, and the same one on the next visit. - const threadId = useBotThread(agentId); + const { threadId, startNew, startingNew } = useBotThread(agentId); + const [newChatError, setNewChatError] = useState(null); /* * A turn that ends without an answer has to be said out loud here, because the packaged chat says * nothing. It reports a failed run to an `onError` prop and otherwise carries on as though the @@ -159,11 +162,39 @@ function RouteComponent() { return (
-

Browser Bot

+
+

Browser Bot

+ +

Ask it to open a page and watch it work.

+ {newChatError ? ( +

+ {newChatError} +

+ ) : null} {/* * Under the header rather than at the end of the transcript, which is where the missing answer * was going to be and where the channel draws its own version of this. The packaged chat owns @@ -187,7 +218,7 @@ function RouteComponent() { diff --git a/app/src/styles.css b/app/src/styles.css index 3273e3a..f14cd82 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -25,6 +25,7 @@ html { * expensive rather than flat. */ :root { + color-scheme: light; --background: oklch(0.988 0.0026 247); --foreground: oklch(0.152 0.009 252); --card: oklch(1 0 0); @@ -60,6 +61,7 @@ html { } .dark { + color-scheme: dark; --background: oklch(0.148 0.007 256); --foreground: oklch(0.982 0.0025 250); --card: oklch(0.196 0.008 257); diff --git a/app/tests/bot-thread.test.ts b/app/tests/bot-thread.test.ts new file mode 100644 index 0000000..45a05cb --- /dev/null +++ b/app/tests/bot-thread.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { botThreadKey } from "../src/lib/copilot/bot-thread"; + +describe("direct Bot thread storage", () => { + test("uses a stable namespaced key for one Bot", () => { + expect(botThreadKey("bot-a")).toBe("openbot.bot-thread.bot-a"); + expect(botThreadKey("bot-a")).toBe(botThreadKey("bot-a")); + }); + + test("keeps different Bots in different conversations", () => { + expect(botThreadKey("bot-a")).not.toBe(botThreadKey("bot-b")); + }); +}); diff --git a/app/tests/skill-form.test.ts b/app/tests/skill-form.test.ts new file mode 100644 index 0000000..36aaa06 --- /dev/null +++ b/app/tests/skill-form.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + declaredElsewhere, + emptySkillForm, + skillFormSchema, +} from "../src/lib/skills/form"; + +const valid = { + slug: "standup", + title: "Standup", + summary: "", + instructions: "Summarise yesterday.", +}; + +describe("skill tool requirements", () => { + test("a new skill starts with no tool requirements", () => { + expect(emptySkillForm.tools).toEqual([]); + expect( + skillFormSchema.safeParse({ ...emptySkillForm, ...valid }).success, + ).toBeTrue(); + }); + + test("keeps an explicit empty list so a save can clear old requirements", () => { + const parsed = skillFormSchema.safeParse({ ...valid, tools: [] }); + expect(parsed.success && parsed.data.tools).toEqual([]); + }); + + test("carries server and tool refs without changing them", () => { + const tools = ["drive/search", "slack/post_message"]; + const parsed = skillFormSchema.safeParse({ ...valid, tools }); + expect(parsed.success && parsed.data.tools).toEqual(tools); + }); + + test("shows requirements no connected server currently offers", () => { + expect( + declaredElsewhere( + ["drive/search", "slack/post_message"], + ["drive/search"], + ), + ).toEqual(["slack/post_message"]); + }); +}); diff --git a/app/tests/theme-preference.test.ts b/app/tests/theme-preference.test.ts index 52eeaf4..93f2e98 100644 --- a/app/tests/theme-preference.test.ts +++ b/app/tests/theme-preference.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; import { applyDarkTheme, @@ -15,13 +16,51 @@ describe("theme preference", () => { test("persists and applies the selected theme", () => { const writes: Array<[string, string]> = []; const toggles: Array<[string, boolean]> = []; + const schemes: Array = []; applyDarkTheme(true, { setStoredValue: (key, value) => writes.push([key, value]), toggleRootClass: (name, force) => toggles.push([name, force]), + setRootColorScheme: (scheme) => schemes.push(scheme), }); expect(writes).toEqual([[THEME_STORAGE_KEY, "dark"]]); expect(toggles).toEqual([["dark", true]]); + expect(schemes).toEqual(["dark"]); + }); +}); + +describe("pre-paint theme boot", () => { + const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); + + test("reads the same storage key the app writes", () => { + expect(html).toContain(THEME_STORAGE_KEY); + }); + + test("runs as a classic inline script before the app module", () => { + const inline = html.match(/]*\bsrc=)[^>]*>/); + const appModule = html.indexOf('