diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fdb96761c..77dcd32f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,20 @@ laptop with `bun run dev`, that left its token-protected API reachable from the the machine itself. Unset, nothing changes, and a computer driving a native Chrome (OpenBot Desktop's) still always listens on loopback. +### The default look: an inset page, OpenBot's palette and Bots as orbs + +Each page now sits in a panel inset from the window, on a darker frame shared with the sidebar, +with white cards on a light page; admin and settings scroll inside it like the rest of the app. +The palette leans towards CopilotKit's lilac, with a plum dark mode, and the type is Atkinson +Hyperlegible. Each Bot appears as an orb in its own colors. The sidebar says "Waiting for you" +on a conversation that needs you, and orange means nothing else. Home has a subheader and cycles +through example jobs from your own Bots; a conversation opens with its Bot's orb, name and role. +Rows show an app's logo or initial instead of the same plug; Home shows Bots as compact cards; +Connections tiles no longer cut their text off; and the Composio directory shows its rows loading. +Colors, card edges and shadows are theme tokens in `theme.css`, and empty states carry a faint dot +pattern whose file, color and strength are tokens too (`--empty-pattern*`), so a deployment can +restyle or turn them off. Skins keep their own look. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/app/PRODUCT.md b/app/PRODUCT.md new file mode 100644 index 0000000000..fe5c7cd442 --- /dev/null +++ b/app/PRODUCT.md @@ -0,0 +1,59 @@ +# Product + + + +## Platform + +web + +## Users + +Two audiences use the same app. + +- **Company employee (daily end user).** Usually not technical. Asks Bots to do jobs: read an expense claim, turn a meeting note into follow-ups, research a topic. Expects the ease of ChatGPT, Claude or Grok. +- **IT owner (buyer and administrator).** Chooses and deploys OpenBot. Adds Bots, grants tools and connections, answers approvals, and reads the audit log. Needs trust and control. + +## Product Purpose + +OpenBot is an AI assistant that a company runs on its own infrastructure and can change completely. Any agent that speaks AG-UI arrives as a coworker. Success: employees use it as their everyday assistant, and the IT owner is confident about what each Bot can do. + +## Positioning + +Bots are coworkers, and you own them. Each Bot has a computer of its own (a real browser with its own logins, files and only the tools you grant). It runs on your infrastructure, with your PostgreSQL and your model choice. A hosted chatbot cannot truthfully say either. + +## Operating Context + +- The web app in `app/` is the main surface. A Tauri desktop app and a mobile client use the same frame. +- Main areas: chat with a chosen Bot, the Bot's own computer and browser, Bots, skills, routines, connections, memory, approvals, audit, admin and settings. +- OpenBot is a template. Companies clone the repository and rename it through `brand.yaml`. +- A person can pick a skin per account: the default `openbot` skin, OpenDuet, OpenDots or OpenMuse. + +## Capabilities and Constraints + +- Light and dark mode must both ship finished. +- The skin system stays. This redesign replaces the default `openbot` skin. The other skins stay as alternates. +- Desktop and mobile must both work at native quality. +- Dense admin screens (audit log, approvals, tables, settings) must stay easy to scan. +- Stack: React 19, Vite, Tailwind 4, shadcn and Base UI components, Tabler icons, Motion. + +## Brand Commitments + +- The new look is opinionated. When a company renames OpenBot, it swaps the name and logo. The visual world stays. +- The design must move away from the generic shadcn look (gray neutrals, Inter, default components). +- The user named Grok and Muse as references for simplicity, polish and elegance. The result must be unique, not a copy of either. +- Binding brand input (pinned 2026-10-28): take the pixelation and color schemes of copilotkit.ai. Pixelation means square-pixel dither fields. The colors are periwinkle `#7480F1` to mint `#23D5A2`, sky `#8DC1F1` to violet `#686FDF`, lilac `#BEC2FF`, pale lavender `#EDEDF5` and deep plum `#35295F`. The rest of that website is not a reference. +- Direction rounds 1 and 2 were rejected as generic. The user likes bold directions, except extreme ones such as a hand-drawn newsprint catalog. + +## Evidence on Hand + +- Real product copy in `README.md` and `docs/`. +- Real Bots: General Assistant, Knowledge, Risk Analyst, and ten catalog Bots in `examples/fintech/catalog/`. +- No customer logos, testimonials or usage numbers. Do not invent them. + +## Product Principles + +1. A Bot is a colleague, not a chat widget. It has a name, a computer and a record of its work. +2. Ownership feels calm and solid. Nothing in the interface suggests a hosted service. +3. The conversation leads. Chrome that does not help the current job stays out of the way. +4. Control is visible but quiet. Approvals and audit are part of the product, not an alarm system. +5. Every detail is finished. Rough spacing, type and motion break trust. diff --git a/app/package.json b/app/package.json index b5be1f7ac8..3c5d42e0da 100644 --- a/app/package.json +++ b/app/package.json @@ -18,6 +18,8 @@ "@base-ui/react": "^1.6.0", "@better-auth/sso": "^1.7.1", "@copilotkit/react-core": "1.83.0", + "@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0", + "@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "@openbot/shared": "workspace:*", "@shadcn/react": "^0.3.0", diff --git a/app/src/assets/patterns/dot-matrix.svg b/app/src/assets/patterns/dot-matrix.svg new file mode 100644 index 0000000000..be9834f09a --- /dev/null +++ b/app/src/assets/patterns/dot-matrix.svg @@ -0,0 +1,136 @@ + + diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx index 2b4a7d5d6c..62286f8498 100644 --- a/app/src/components/admin/admin-sidebar.tsx +++ b/app/src/components/admin/admin-sidebar.tsx @@ -1,15 +1,16 @@ import { IconArrowLeft, IconBuildingBank, + IconBulb, IconChecks, IconCode, IconDeviceDesktop, - IconFileText, IconKey, IconLayoutGrid, IconListDetails, IconPuzzle, IconShieldCheck, + IconSlash, IconUsers, } from "@tabler/icons-react"; import { Link, type LinkOptions } from "@tanstack/react-router"; @@ -79,12 +80,12 @@ const GROUPS: { }, { title: "Skills", - icon: IconFileText, + icon: IconSlash, linkOptions: { to: "/admin/skills" }, }, { title: "Automatic learning", - icon: IconFileText, + icon: IconBulb, linkOptions: { to: "/admin/learning" }, }, { @@ -130,7 +131,7 @@ export function AdminSidebar({ ...props }: React.ComponentProps) { return ( - + {/* * Pinned to the same height as the app sidebar's header. Left to itself this one is 60px * against the app's 45px — `p-2` around a `size="lg"` button rather than a fixed height — so diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 9ca9845070..8b06b59165 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,5 +1,11 @@ import Avatar from "boring-avatars"; +import { BotOrb } from "@/components/agents/bot-orb"; +import { DEFAULT_SKIN, useSkinId } from "@/skins/store"; +/** + * A Bot's avatar. OpenBot's own look draws the Bot as its orb; a skin keeps the face it was + * designed with. + */ export function AbstractAvatar({ name, seed, @@ -9,6 +15,11 @@ export function AbstractAvatar({ seed: string; size?: number; }) { + const skin = useSkinId(); + if (skin === DEFAULT_SKIN) { + return ; + } + return ( + /* + * The avatar beside the name rather than above it: a tall card left most of its height empty + * between the two, and a row reads the name and what the Bot is for in one glance. + */ +
- {/* Bottom-aligned, where the text sat on the earlier artwork card; the avatar holds the top. */} -
- +
+ {agent.name} - + {agent.roleDescription}
diff --git a/app/src/components/agents/bot-orb.tsx b/app/src/components/agents/bot-orb.tsx new file mode 100644 index 0000000000..2c7c2c7ea5 --- /dev/null +++ b/app/src/components/agents/bot-orb.tsx @@ -0,0 +1,83 @@ +import type { CSSProperties } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { orbPalette } from "@/lib/orb-palette"; + +/** At this size and smaller, an idle orb is drawn still. */ +const STATIC_MAX_SIZE = 32; + +/* + * The still orb: the live orb's base sphere (a lit radial in the palette's own stops) with one + * conic sweep over it for the mesh, and a soft highlight on top. Drawn from the custom properties + * set on the element, so the fill is written once and the palette is the only thing that varies. + */ +const STATIC_FILL = [ + "radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.28), transparent 34%)", + "conic-gradient(from 200deg at 70% 30%, var(--c1), transparent 30% 65%, var(--c1))", + "radial-gradient(circle at 38% 34%, var(--c4) 0%, var(--c2) 42%, var(--c3) 100%)", +].join(", "); + +/** + * A Bot drawn as its orb, in the palette its seed picks (`orbPalette`), so renaming a Bot keeps + * its color. + * + * At avatar sizes an idle Bot gets a still orb drawn with CSS alone: twenty animated orbs in a + * sidebar would each run a blur and a backdrop filter on every frame. A larger orb, or one whose + * Bot is working, is the live `AgentOrb`. Neither moves under reduced motion: `AgentOrb` honours + * it, and the still orb has nothing to move. + */ +export function BotOrb({ + seed, + name, + size = 32, + state = "idle", +}: { + seed: string; + name: string; + size?: number; + state?: "idle" | "working" | "waiting"; +}) { + const colors = orbPalette(seed); + + if (size <= STATIC_MAX_SIZE && state === "idle") { + return ( + + ); + } + + return ( + + {/* Hidden so the Bot is announced once, by name. A waiting Bot idles: the next move is the + person's, not the Bot's. */} + + + ); +} diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx index 6a693c4910..90ad484fdd 100644 --- a/app/src/components/agents/handoff-panel.tsx +++ b/app/src/components/agents/handoff-panel.tsx @@ -1,4 +1,4 @@ -import { IconArrowsExchange, IconLock, IconRobot } from "@tabler/icons-react"; +import { IconArrowsExchange, IconBolt, IconLock } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Fragment } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; @@ -102,7 +102,7 @@ export function HandoffPanel({ agentId }: { agentId: string }) { ) : null} {grantable && total === 0 ? ( - + When there are more, you can let this Bot ask them here. ) : null} diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 27fe657b18..0d003e8b03 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -3,7 +3,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Link, type LinkOptions, useParams } from "@tanstack/react-router"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type * as React from "react"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { DropdownMenu, DropdownMenuContent, @@ -26,16 +26,18 @@ import { SidebarMenuItem, SidebarRail, } from "@/components/ui/sidebar"; +import { approvalInboxOptions } from "@/lib/approvals"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { type ChannelSummary, channelListQueryOptions, } from "@/lib/channels/queries"; import { useChannelEvents } from "@/lib/channels/use-channel-events"; +import { waitingThreadIds } from "@/lib/channels/waiting"; import { appConfig } from "@/lib/generated/application-config"; import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion"; import { navigationFor } from "@/lib/navigation"; -import { relativeTime } from "@/lib/relative-time"; +import { relativeTimeShort } from "@/lib/relative-time"; import { type MessageListEmphasis, useMessageListEmphasis, @@ -153,10 +155,12 @@ function ChannelRow({ channel, animateOrder, emphasis, + waiting, }: { channel: ChannelSummary; animateOrder: boolean; emphasis: MessageListEmphasis; + waiting: boolean; }) { const shouldReduceMotion = useReducedMotion(); // Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen` @@ -186,13 +190,14 @@ function ChannelRow({ lastMessage={channel.lastMessage ?? undefined} lastMessageAt={ channel.lastMessageAt - ? relativeTime(channel.lastMessageAt) + ? relativeTimeShort(channel.lastMessageAt) : undefined } pinned={channel.pinned} unread={unread} // A turn running, or work the conversation waits on between turns: a hop, a routine. busy={(channel.busy ?? false) || (channel.work?.length ?? 0) > 0} + waiting={waiting} /> ); @@ -205,6 +210,9 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const channels = useInfiniteQuery(channelListQueryOptions()); // One socket for the app, opened where the roster is kept live. useChannelEvents(); + // The approvals inbox, polled: a conversation stops waiting on the refresh after it is answered. + const inbox = useQuery(approvalInboxOptions()); + const waiting = useMemo(() => waitingThreadIds(inbox.data), [inbox.data]); const [search, setSearch] = useState(""); const scrollRoot = useRef(null); const searching = search.trim().length > 0; @@ -220,7 +228,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { const navigation = navigationFor(currentUser?.role); return ( - + @@ -311,6 +319,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { key={channel.id} animateOrder={animateOrder} channel={channel} + waiting={waiting.has(channel.threadId)} /> ))} diff --git a/app/src/components/app-sidebar/channel-item-content.tsx b/app/src/components/app-sidebar/channel-item-content.tsx index ece35115c2..4b4183eb13 100644 --- a/app/src/components/app-sidebar/channel-item-content.tsx +++ b/app/src/components/app-sidebar/channel-item-content.tsx @@ -1,8 +1,15 @@ -import { IconPinFilled } from "@tabler/icons-react"; +import { IconHandStop, IconPinFilled } from "@tabler/icons-react"; import { ChannelAvatar } from "@/components/channels/avatar"; import type { MessageListEmphasis } from "@/lib/settings/message-list"; +import { cn } from "@/lib/utils"; -/** Shared by live conversations and the appearance preference previews. */ +/** + * Shared by live conversations and the appearance preference previews. + * + * The second line says the one thing most worth knowing. A conversation that waits on you says so, + * in orange, which means nothing else in OpenBot. One whose Bot is working shows the working dots + * in place of its summary, so the avatar carries no badge of its own. Otherwise it is the summary. + */ export function ChannelItemContent({ participantIds, name, @@ -14,6 +21,7 @@ export function ChannelItemContent({ unread = false, pinned = false, revealing = false, + waiting = false, }: { participantIds: string[]; name: string; @@ -25,18 +33,16 @@ export function ChannelItemContent({ unread?: boolean; pinned?: boolean; revealing?: boolean; + /** An approval or a question in this conversation waits on the person. */ + waiting?: boolean; }) { - const primaryText = `text-[0.9rem] leading-5 tracking-[-1%] ${unread ? "font-semibold" : "font-medium"}`; + const primaryText = `text-sm leading-5 tracking-[-1%] ${unread ? "font-semibold" : "font-medium"}`; const secondaryText = "text-[12px] leading-4 text-muted-foreground"; return ( -
+
- +
@@ -56,16 +62,42 @@ export function ChannelItemContent({ ) : null}
-
- {displayedTitle} - {revealing ? ( - - ) : null} -
+ {waiting ? ( +
+
+ ) : busy ? ( +
+ Working… + + + +
+ ) : ( +
+ {displayedTitle} + {revealing ? ( + + ) : null} +
+ )}
); } + +function WorkingDot({ className }: { className?: string }) { + return ( +