diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b261f7c54..2130c1e389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `computers/` on the workspace volume (below). Files saved before the upgrade stay at the root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. +### Pick a skin: OpenDuet, OpenDots or OpenMuse + +Settings → Preferences → Skin changes how OpenBot looks for you, and nothing about what it does. +Three skins draw the open-source templates' looks over OpenBot's own features: OpenDuet (one +phone-width column with Chat and Tasks), OpenDots (a rail, a sidebar of Bots with their avatars, and +the Bot's computer beside the chat) and OpenMuse (one Bot at the centre, with Chat, Activity, Ideas, +Goals and Apps). Every skin keeps your deployment's name from `brand.yaml`, OpenBot's own mark and +each Bot's own avatar; only the look changes. The choice is saved on your account, so it follows +you to every device, and changes nothing for anybody else. The templates' own features that +OpenBot has no server for, such as OpenDots' Spaces, OpenDuet's morning brief and OpenMuse's mail and +calendar, are not part of the skins. + ### The audit log reads like a log Admin → Audit fills the window and runs the table across the whole pane, with the title, the views diff --git a/app/index.html b/app/index.html index 56293c4bd0..a839d57c50 100644 --- a/app/index.html +++ b/app/index.html @@ -11,6 +11,8 @@ const dark = window.localStorage.getItem("openbot-theme") === "dark"; document.documentElement.classList.toggle("dark", dark); document.documentElement.style.colorScheme = dark ? "dark" : "light"; + const skin = window.localStorage.getItem("openbot-skin"); + if (skin && skin !== "openbot") document.documentElement.dataset.skin = skin; } catch {} diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 30087fc613..77ab33f7cc 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -1,13 +1,4 @@ -import { - IconBolt, - IconBox, - IconLogout, - IconPlug, - IconPlus, - IconSearch, - IconSettings, - IconShieldLock, -} from "@tabler/icons-react"; +import { IconLogout, IconPlus, IconSearch } from "@tabler/icons-react"; import { useInfiniteQuery, useMutation, @@ -54,6 +45,7 @@ import { import { useChannelEvents } from "@/lib/channels/use-channel-events"; 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 { type MessageListEmphasis, @@ -66,8 +58,6 @@ import { Channel } from "./channel"; import { ChannelPagination } from "./channel-pagination"; const appLinkOptions = { to: "/" } satisfies LinkOptions; -const adminLinkOptions = { to: "/admin" } satisfies LinkOptions; -const settingsLinkOptions = { to: "/settings" } satisfies LinkOptions; const userMenuItemClassName = "gap-2 px-2 py-1.5"; @@ -238,6 +228,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { */ const animateOrder = !searching && (channels.data?.length ?? 0) <= MAX_ANIMATED_ROWS; + const navigation = navigationFor(currentUser?.role); const handleSignOut = async () => { await signOut.mutateAsync(); @@ -349,68 +340,29 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - - ( - - )} - > -
- -
- Bots - {/* What waits on you across every Bot; Bots lists them first. */} - -
-
- {/* - * Beside Bots rather than inside Admin: each is somebody's own. Capabilities are your skills - * and what your Bots remember about you; Connections are the apps a Bot reaches as you. - * Capabilities links to `/capabilities` rather than a tab, so it stays lit on both tabs. - */} - - ( - - )} - > -
- -
- Capabilities -
-
- - ( - - )} - > -
- -
- Connections -
-
+ {navigation + .filter((item) => item.section === "primary") + .map((item) => ( + + ( + + )} + > +
+ +
+ {item.label} + {/* What waits on you across every Bot; Bots lists them first. */} + {item.id === "bots" ? : null} +
+
+ ))} ) { side="top" sideOffset={8} > - {/* Admin routes are server-guarded; hide the entry for users who cannot open them. */} - {currentUser?.role === "admin" ? ( - } - > - - Admin - - ) : null} - } - > - - Settings - + {navigation + .filter((item) => item.section === "account") + .map((item) => ( + } + > + + {item.label} + + ))} )} - + {/* The slot is ours: `PromptArea` passes no attribute through to its own box. */} +
+ +
- +
+ +
@@ -1624,6 +1634,7 @@ export function Composer({
-
+
{notice}
{/* Rendered for the whole animation, so the way out is available immediately. */} -
-
+
+
{title}
diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 7fbca5c2f3..d643881b32 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -83,9 +83,11 @@ export function PageShell({ const bar = showToggle || !!backButton; const header = ( -
+
-

{title}

+

+ {title} +

{action}
{description ? ( diff --git a/app/src/lib/auth/mutations.ts b/app/src/lib/auth/mutations.ts index 771ee0df05..041813970b 100644 --- a/app/src/lib/auth/mutations.ts +++ b/app/src/lib/auth/mutations.ts @@ -1,5 +1,6 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; import { client } from "@/lib/client"; +import { clearSkin } from "@/skins/store"; import { authKeys } from "./queries"; async function signOut() { @@ -9,9 +10,19 @@ async function signOut() { }); } +/** + * Every way out of OpenBot signs out through here, the skins' own menus included. + * + * The skin is the account's, and this browser only caches it to paint before the preferences load. + * Clearing it here means the next person on a shared machine starts from the default rather than + * from the last person's skin. + */ export function signOutMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: signOut, - onSuccess: () => queryClient.removeQueries({ queryKey: authKeys.all }), + onSuccess: () => { + clearSkin(); + queryClient.removeQueries({ queryKey: authKeys.all }); + }, }); } diff --git a/app/src/lib/navigation.ts b/app/src/lib/navigation.ts new file mode 100644 index 0000000000..cd39386ba8 --- /dev/null +++ b/app/src/lib/navigation.ts @@ -0,0 +1,87 @@ +import { + IconBolt, + IconBox, + type Icon, + IconPlug, + IconSettings, + IconShieldLock, +} from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; + +/** + * Where a person can go in OpenBot, in one list. + * + * OpenBot's own sidebar draws it, and so does every skin that replaces the sidebar, so a destination + * added here reaches every frame at once. One left out of a frame's own copy would simply be + * missing from that skin, with nothing failing to say so. + * + * `section` says where OpenBot's sidebar puts each entry: `primary` destinations sit in its footer, + * `account` ones in the menu under the person's name. A skin may group them its own way. + * + * Bots, Capabilities and Connections are each somebody's own, which is why they sit beside each + * other and not inside Admin. Capabilities are your skills and what your Bots remember about you; + * Connections are the apps a Bot reaches as you. Capabilities links to `/capabilities` rather than + * a tab, so it stays lit on both of its tabs. + */ +export type NavigationItem = { + id: "bots" | "capabilities" | "connections" | "admin" | "settings"; + label: string; + to: "/bots" | "/capabilities" | "/connections" | "/admin" | "/settings"; + icon: Icon; + section: "primary" | "account"; + /** Admin routes are guarded on the server; the entry is hidden from people who cannot open them. */ + adminOnly?: boolean; +}; + +export const NAVIGATION: readonly NavigationItem[] = [ + { + id: "bots", + label: "Bots", + to: "/bots", + icon: IconBolt, + section: "primary", + }, + { + id: "capabilities", + label: "Capabilities", + to: "/capabilities", + icon: IconBox, + section: "primary", + }, + { + id: "connections", + label: "Connections", + to: "/connections", + icon: IconPlug, + section: "primary", + }, + { + id: "admin", + label: "Admin", + to: "/admin", + icon: IconShieldLock, + section: "account", + adminOnly: true, + }, + { + id: "settings", + label: "Settings", + to: "/settings", + icon: IconSettings, + section: "account", + }, +]; + +/** The destinations this person may open, in order. */ +export function navigationFor( + role: string | undefined, +): readonly NavigationItem[] { + return NAVIGATION.filter((item) => !item.adminOnly || role === "admin"); +} + +/** `navigationFor` the signed-in person. */ +export function useNavigationItems(): readonly NavigationItem[] { + const { data: user } = useQuery(currentUserQueryOptions()); + return navigationFor(user?.role); +} diff --git a/app/src/lib/plugins/listed.ts b/app/src/lib/plugins/listed.ts new file mode 100644 index 0000000000..72a21d73e1 --- /dev/null +++ b/app/src/lib/plugins/listed.ts @@ -0,0 +1,58 @@ +import type { PluginServer } from "./queries"; + +/* + * Which apps the Connections page lists, and in which group. Here and not in the route file, so + * anything else that lists a person's apps (a skin's own screen) reads the same rules without + * importing a route, which works against TanStack Router's automatic code splitting. + */ + +/** + * Whether a row came through a broker. A deployment only ever holds rows from one provider, so the + * page needs to recognise "brokered", not to know which broker. + */ +const isBrokered = (server: PluginServer): boolean => + server.provenance === "composio" || server.provenance === "arcade"; + +/** + * The brokered apps this page lists, which is not every brokered row. + * + * Exported as a function rather than left inline in the render, for the reason `matchingApps` on the + * Composio picker is: the rule is the thing worth pinning and pinning it needs no DOM, no router and + * no query client. See its one clause below. + */ +export function brokeredAccountsListedOn( + servers: PluginServer[], +): PluginServer[] { + return servers.filter( + (server) => + isBrokered(server) && + server.authScheme !== "NO_AUTH" && + server.accountMode !== "shared", + ); +} + +/** + * The servers this page may list at all: none reached through Composio when the deployment has no + * Composio key. + * + * An app an administrator enabled while a key was set keeps its row after the key is removed, and + * listing it offered a person an account they cannot connect, under a sentence about an environment + * variable only an administrator can set. Without the broker there is nothing for them to decide. + */ +export function serversListedFor(page: { + servers: PluginServer[]; + composioConfigured: boolean; +}): PluginServer[] { + return page.composioConfigured + ? page.servers + : page.servers.filter((server) => server.provenance !== "composio"); +} + +/** Apps whose account is the organisation's, shown so a person knows a Bot does not act as them there. */ +export function sharedAccountsListedOn( + servers: PluginServer[], +): PluginServer[] { + return servers.filter( + (server) => isBrokered(server) && server.accountMode === "shared", + ); +} diff --git a/app/src/lib/settings/message-list.ts b/app/src/lib/settings/message-list.ts index f8f155c721..36d653284d 100644 --- a/app/src/lib/settings/message-list.ts +++ b/app/src/lib/settings/message-list.ts @@ -6,7 +6,8 @@ import { } from "@tanstack/react-query"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { client } from "@/lib/client"; -import type { UserPreferences } from "@openbot/shared/user-preferences"; +import type { SkinId, UserPreferences } from "@openbot/shared/user-preferences"; +import { getSkinId, setSkin } from "@/skins/store"; import { settingsKeys } from "./queries"; export type { MessageListEmphasis } from "@openbot/shared/user-preferences"; @@ -96,3 +97,51 @@ export function saveUserPreferencesMutationOptions( ), }); } + +/** Held while a skin save is out, so the account sync can tell its own write from a stale read. */ +export const saveSkinMutationKey = [ + ...settingsKeys.all, + "preferences", + "skin", +] as const; + +/** + * Choose how OpenBot looks for this person, on every device they sign in from. + * + * The skin is drawn the moment it is picked, and the cached preferences are patched to match, so + * `SkinAccountSync` agrees with the picker while the save is out. A read already in flight is + * cancelled first, or it would land the old skin over the new one. A failed save puts back both the + * skin and the cache, and the picker says so. The reply seeds the cache with what the server stored. + */ +export function saveSkinMutationOptions( + queryClient: QueryClient, + userId: string | undefined, +) { + const queryKey = userPreferencesQueryOptions(userId).queryKey; + return mutationOptions({ + mutationKey: saveSkinMutationKey, + mutationFn: (skin: SkinId): Promise => { + if (!userId) throw new Error("Sign in to save your preferences."); + return client("/api/settings/preferences", "preferences", { + method: "PATCH", + body: { skin }, + fallback: "Could not save your skin", + }); + }, + onMutate: async (skin) => { + const previousSkin = getSkinId(); + setSkin(skin); + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + if (previous) queryClient.setQueryData(queryKey, { ...previous, skin }); + return { previous, previousSkin }; + }, + onError: (_error, _skin, context) => { + if (!context) return; + if (context.previous) + queryClient.setQueryData(queryKey, context.previous); + setSkin(context.previousSkin); + }, + onSuccess: (preferences) => queryClient.setQueryData(queryKey, preferences), + }); +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 87fdb447a2..de90ab8ce1 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -54,6 +54,7 @@ import { Route as AuthedAppConnectionsIndexRouteImport } from './routes/_authed/ import { Route as AuthedAppConnectionsKeyRouteImport } from './routes/_authed/_app/connections.$key' import { Route as AuthedAppGroupChannelIdRouteImport } from './routes/_authed/_app/group/$channelId' import { Route as AuthedAppGroupNewRouteImport } from './routes/_authed/_app/group/new' +import { Route as AuthedAppSScreenRouteImport } from './routes/_authed/_app/s/$screen' import { Route as AuthedAdminBoundariesIndexRouteImport } from './routes/_authed/admin/boundaries_.$index' import { Route as AuthedAdminBoundariesNewRouteImport } from './routes/_authed/admin/boundaries_.new' import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed/admin/components/index' @@ -311,6 +312,11 @@ const AuthedAppGroupNewRoute = AuthedAppGroupNewRouteImport.update({ path: '/group/new', getParentRoute: () => AuthedAppRoute, } as any) +const AuthedAppSScreenRoute = AuthedAppSScreenRouteImport.update({ + id: '/s/$screen', + path: '/s/$screen', + getParentRoute: () => AuthedAppRoute, +} as any) const AuthedAdminBoundariesIndexRoute = AuthedAdminBoundariesIndexRouteImport.update({ id: '/boundaries_/$index', @@ -500,6 +506,7 @@ export interface FileRoutesByFullPath { '/connections/$key': typeof AuthedAppConnectionsKeyRoute '/group/$channelId': typeof AuthedAppGroupChannelIdRoute '/group/new': typeof AuthedAppGroupNewRoute + '/s/$screen': typeof AuthedAppSScreenRoute '/admin/boundaries/$index': typeof AuthedAdminBoundariesIndexRoute '/admin/boundaries/new': typeof AuthedAdminBoundariesNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute @@ -567,6 +574,7 @@ export interface FileRoutesByTo { '/connections/$key': typeof AuthedAppConnectionsKeyRoute '/group/$channelId': typeof AuthedAppGroupChannelIdRoute '/group/new': typeof AuthedAppGroupNewRoute + '/s/$screen': typeof AuthedAppSScreenRoute '/admin/boundaries/$index': typeof AuthedAdminBoundariesIndexRoute '/admin/boundaries/new': typeof AuthedAdminBoundariesNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute @@ -640,6 +648,7 @@ export interface FileRoutesById { '/_authed/_app/connections/$key': typeof AuthedAppConnectionsKeyRoute '/_authed/_app/group/$channelId': typeof AuthedAppGroupChannelIdRoute '/_authed/_app/group/new': typeof AuthedAppGroupNewRoute + '/_authed/_app/s/$screen': typeof AuthedAppSScreenRoute '/_authed/admin/boundaries_/$index': typeof AuthedAdminBoundariesIndexRoute '/_authed/admin/boundaries_/new': typeof AuthedAdminBoundariesNewRoute '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute @@ -712,6 +721,7 @@ export interface FileRouteTypes { | '/connections/$key' | '/group/$channelId' | '/group/new' + | '/s/$screen' | '/admin/boundaries/$index' | '/admin/boundaries/new' | '/admin/components/$name' @@ -779,6 +789,7 @@ export interface FileRouteTypes { | '/connections/$key' | '/group/$channelId' | '/group/new' + | '/s/$screen' | '/admin/boundaries/$index' | '/admin/boundaries/new' | '/admin/components/$name' @@ -851,6 +862,7 @@ export interface FileRouteTypes { | '/_authed/_app/connections/$key' | '/_authed/_app/group/$channelId' | '/_authed/_app/group/new' + | '/_authed/_app/s/$screen' | '/_authed/admin/boundaries_/$index' | '/_authed/admin/boundaries_/new' | '/_authed/admin/components/$name' @@ -1204,6 +1216,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAppGroupNewRouteImport parentRoute: typeof AuthedAppRoute } + '/_authed/_app/s/$screen': { + id: '/_authed/_app/s/$screen' + path: '/s/$screen' + fullPath: '/s/$screen' + preLoaderRoute: typeof AuthedAppSScreenRouteImport + parentRoute: typeof AuthedAppRoute + } '/_authed/admin/boundaries_/$index': { id: '/_authed/admin/boundaries_/$index' path: '/boundaries/$index' @@ -1501,6 +1520,7 @@ interface AuthedAppRouteChildren { AuthedAppConnectionsKeyRoute: typeof AuthedAppConnectionsKeyRoute AuthedAppGroupChannelIdRoute: typeof AuthedAppGroupChannelIdRoute AuthedAppGroupNewRoute: typeof AuthedAppGroupNewRoute + AuthedAppSScreenRoute: typeof AuthedAppSScreenRoute AuthedAppAgentsIndexRoute: typeof AuthedAppAgentsIndexRoute AuthedAppBotsIndexRoute: typeof AuthedAppBotsIndexRoute AuthedAppConnectionsIndexRoute: typeof AuthedAppConnectionsIndexRoute @@ -1532,6 +1552,7 @@ const AuthedAppRouteChildren: AuthedAppRouteChildren = { AuthedAppConnectionsKeyRoute: AuthedAppConnectionsKeyRoute, AuthedAppGroupChannelIdRoute: AuthedAppGroupChannelIdRoute, AuthedAppGroupNewRoute: AuthedAppGroupNewRoute, + AuthedAppSScreenRoute: AuthedAppSScreenRoute, AuthedAppAgentsIndexRoute: AuthedAppAgentsIndexRoute, AuthedAppBotsIndexRoute: AuthedAppBotsIndexRoute, AuthedAppConnectionsIndexRoute: AuthedAppConnectionsIndexRoute, diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx index 2e34d837f9..8eb4238163 100644 --- a/app/src/routes/_authed.tsx +++ b/app/src/routes/_authed.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; import { currentUserQueryOptions, needsOnboarding } from "../lib/auth/queries"; import { CopilotProvider } from "../lib/copilot/provider"; import { AppHotkeys } from "../lib/hotkeys/app-hotkeys"; +import { SkinAccountSync } from "../skins/account-sync"; export const Route = createFileRoute("/_authed")({ beforeLoad: async ({ context, location }) => { @@ -25,6 +26,8 @@ export const Route = createFileRoute("/_authed")({ component: () => ( + {/* The skin saved on the account, drawn over the browser's cached one once it loads. */} + ), diff --git a/app/src/routes/_authed/_app.tsx b/app/src/routes/_authed/_app.tsx index 0868efcad6..4c7af72786 100644 --- a/app/src/routes/_authed/_app.tsx +++ b/app/src/routes/_authed/_app.tsx @@ -2,12 +2,23 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; import { SelfHostBanner } from "@/components/layout/self-host-banner"; import { SidebarShell } from "@/components/layout/sidebar-shell"; +import { useActiveSkin } from "@/skins/registry"; export const Route = createFileRoute("/_authed/_app")({ component: RouteComponent, }); function RouteComponent() { + const { Frame } = useActiveSkin(); + + if (Frame) { + return ( + + + + ); + } + return ( // One viewport, never scrolls: panes scroll inside it. A growable shell lets the transcript's // scroller size against the page, grow it, and grow again. diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 7ef1c5fd64..5cc23c2d17 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -106,7 +106,10 @@ function RouteComponent() { } >
-
+
{/* Keyed on the displayed name so cold channel loads animate the resolved name, not the id. */}
diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx index a7fbeedb4a..f5231720b3 100644 --- a/app/src/routes/_authed/_app/channel/new.tsx +++ b/app/src/routes/_authed/_app/channel/new.tsx @@ -124,7 +124,10 @@ function RouteComponent() { return (
-
+
To: keyedLookup(MARKS, key) ?? IconPlug; -/** - * Whether a row came through a broker. A deployment only ever holds rows from one provider, so the - * page needs to recognise "brokered", not to know which broker. - */ -const isBrokered = (server: PluginServer): boolean => - server.provenance === "composio" || server.provenance === "arcade"; - -/** - * The brokered apps this page lists, which is not every brokered row. - * - * Exported as a function rather than left inline in the render, for the reason `matchingApps` on the - * Composio picker is: the rule is the thing worth pinning and pinning it needs no DOM, no router and - * no query client. See its one clause below. - */ -export function brokeredAccountsListedOn( - servers: PluginServer[], -): PluginServer[] { - return servers.filter( - (server) => - isBrokered(server) && - server.authScheme !== "NO_AUTH" && - server.accountMode !== "shared", - ); -} - -/** - * The servers this page may list at all: none reached through Composio when the deployment has no - * Composio key. - * - * An app an administrator enabled while a key was set keeps its row after the key is removed, and - * listing it offered a person an account they cannot connect, under a sentence about an environment - * variable only an administrator can set. Without the broker there is nothing for them to decide. - */ -export function serversListedFor(page: { - servers: PluginServer[]; - composioConfigured: boolean; -}): PluginServer[] { - return page.composioConfigured - ? page.servers - : page.servers.filter((server) => server.provenance !== "composio"); -} - -/** Apps whose account is the organisation's, shown so a person knows a Bot does not act as them there. */ -export function sharedAccountsListedOn( - servers: PluginServer[], -): PluginServer[] { - return servers.filter( - (server) => isBrokered(server) && server.accountMode === "shared", - ); -} - function RouteComponent() { const { connected: outcome, authorized, linked } = Route.useSearch(); /* diff --git a/app/src/routes/_authed/_app/group/$channelId.tsx b/app/src/routes/_authed/_app/group/$channelId.tsx index c8ce2fa055..e59dd19941 100644 --- a/app/src/routes/_authed/_app/group/$channelId.tsx +++ b/app/src/routes/_authed/_app/group/$channelId.tsx @@ -19,7 +19,10 @@ function RouteComponent() { useMarkOpenChannelRead(channelId); return (
-
+
; + + return ; +} diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index ef6309e543..9d52eb0016 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -19,6 +19,7 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { formatHotkey, HOTKEYS } from "@/lib/hotkeys/hotkeys"; +import { SkinPicker } from "@/skins/skin-picker"; export const Route = createFileRoute("/_authed/settings/")({ component: RouteComponent, @@ -37,7 +38,7 @@ function RouteComponent() { */ return ( @@ -58,6 +59,8 @@ function RouteComponent() { /> + + diff --git a/app/src/skins/account-sync.tsx b/app/src/skins/account-sync.tsx new file mode 100644 index 0000000000..f04851a1b3 --- /dev/null +++ b/app/src/skins/account-sync.tsx @@ -0,0 +1,29 @@ +import { useIsMutating } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { + saveSkinMutationKey, + useUserPreferences, +} from "@/lib/settings/message-list"; +import { parseStoredSkin, setSkin } from "./store"; + +/** + * Draws the skin saved on the person's account, whenever their preferences load or change. + * + * The browser's cache paints first, so a reload never flashes the default. When the account says + * otherwise (the person chose another skin on another device, or somebody else used this browser + * before them), the account wins. Mounted once, inside the signed-in boundary. + * + * While the person's own save is out it stands aside: the picker already drew their choice, and a + * read that started before the save would otherwise put the old skin back for a moment. + */ +export function SkinAccountSync() { + const accountSkin = useUserPreferences().data?.skin; + const saving = useIsMutating({ mutationKey: saveSkinMutationKey }) > 0; + + useEffect(() => { + if (saving || accountSkin === undefined) return; + setSkin(parseStoredSkin(accountSkin)); + }, [accountSkin, saving]); + + return null; +} diff --git a/app/src/skins/opendots/computer-pane.tsx b/app/src/skins/opendots/computer-pane.tsx new file mode 100644 index 0000000000..a181d7f94f --- /dev/null +++ b/app/src/skins/opendots/computer-pane.tsx @@ -0,0 +1,93 @@ +import { IconDeviceDesktop, IconX } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { ComputerViewPanel } from "@/components/computer/computer-panel"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { IconAction } from "./sidebar"; + +/** + * OpenDots' right pane, showing OpenBot's own computer for one Bot. + * + * OpenDots' pane has a Brief tab beside the computer; OpenBot keeps no brief, so the computer is + * the pane's only tab. Which Bot's computer it shows starts as the Bot the page is about and can be + * changed, the way OpenDots' "Computer for" picker does. + */ +export function ComputerPane({ + defaultBotId, + onClose, +}: { + defaultBotId: string | undefined; + onClose: () => void; +}) { + const agents = useQuery(agentListQueryOptions()); + const [chosen, setChosen] = useState(); + const bots = agents.data ?? []; + const bot = + bots.find((agent) => agent.id === chosen) ?? + bots.find((agent) => agent.id === defaultBotId) ?? + bots[0]; + return ( + + ); +} diff --git a/app/src/skins/opendots/frame.tsx b/app/src/skins/opendots/frame.tsx new file mode 100644 index 0000000000..a63809e34e --- /dev/null +++ b/app/src/skins/opendots/frame.tsx @@ -0,0 +1,464 @@ +import { + IconClockHour3, + IconDeviceDesktop, + IconLayoutSidebar, + IconLogout, + IconMenu2, + IconPlayerPause, + IconPlayerPlay, + IconPlus, +} from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + Link, + type LinkProps, + useNavigate, + useRouterState, +} from "@tanstack/react-router"; +import { type ReactNode, useEffect, useState } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { BotsNavBadge } from "@/components/bot-profile/attention"; +import { SelfHostBanner } from "@/components/layout/self-host-banner"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { signOutMutationOptions } from "@/lib/auth/mutations"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; +import { setBotPausedMutationOptions } from "@/lib/bot-lifecycle/mutations"; +import { botLifecycleQueryOptions } from "@/lib/bot-lifecycle/queries"; +import { useChannelEvents } from "@/lib/channels/use-channel-events"; +import { appConfig } from "@/lib/generated/application-config"; +import { type NavigationItem, useNavigationItems } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; +import { queryClient } from "@/query-client"; +import { ComputerPane } from "./computer-pane"; +import { CoworkerPersona } from "./persona"; +import { botName, type Place, usePlace } from "./place"; +import { IconAction, initialsOf, OpenDotsSidebar } from "./sidebar"; + +/** The rail's square buttons: 36px, borderless, a grey glyph that darkens on hover. */ +const railButton = + "grid size-9 place-items-center rounded-lg text-(--opendots-rail-icon) outline-none transition-colors hover:bg-(--opendots-hover) hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-[18px] [&_svg]:stroke-[1.75]"; + +function RailTip({ + label, + children, +}: { + label: string; + children: React.ReactElement; +}) { + return ( + + + {label} + + ); +} + +/** Who is signed in, and the way out, at the foot of the rail as OpenBot keeps it in its sidebar. */ +function RailUserMenu() { + const user = useQuery(currentUserQueryOptions()); + const navigate = useNavigate(); + const signOut = useMutation(signOutMutationOptions(queryClient)); + if (!user.data) return null; + return ( + + + } + > + {initialsOf(user.data)} + + + + + {user.data.name?.trim() || user.data.email} + + + + { + await signOut.mutateAsync(); + await navigate({ to: "/sign" }); + }} + variant="destructive" + > + + Sign out + + + + ); +} + +/** The 48px strip at the far left: the brand, the sidebar toggle, and the few places used most. */ +/** One of OpenBot's destinations as a rail button, with the Bots badge on Bots. */ +function RailDestination({ + item, + showBadge, +}: { + item: NavigationItem; + showBadge: boolean; +}) { + const Icon = item.icon; + return ( + + + + {item.id === "bots" && showBadge ? ( + // What waits on you across every Bot. OpenBot's own badge, which also sends the + // browser notification, so exactly one is ever mounted. + + + + ) : null} + + + ); +} + +function Rail({ + newChat, + navCollapsed, + onToggleNav, + showBadge, +}: { + newChat: LinkProps; + navCollapsed: boolean; + onToggleNav: () => void; + /** Whether the rail carries the Bots badge: at desktop width, where the rail is on screen. */ + showBadge: boolean; +}) { + const navigation = useNavigationItems(); + return ( + + ); +} + +/** + * Pause and resume for the one Bot the page is about. OpenDots pauses every Dot at once; OpenBot + * pauses each Bot on its own, so this is that Bot's switch and only appears where there is one. + */ +function PauseControl({ botId, name }: { botId: string; name: string }) { + const lifecycle = useQuery(botLifecycleQueryOptions(botId)); + const setPaused = useMutation(setBotPausedMutationOptions(queryClient)); + if (!lifecycle.data) return null; + const paused = lifecycle.data.paused; + return ( + + ); +} + +function Topbar({ + place, + navCollapsed, + onToggleNav, + computerOpen, + onToggleComputer, +}: { + place: Place; + navCollapsed: boolean; + onToggleNav: () => void; + computerOpen: boolean; + onToggleComputer: () => void; +}) { + const agents = useQuery(agentListQueryOptions()); + const name = botName(agents.data, place.botId); + return ( +
+ + +
+ {place.botId && name ? ( + + ) : null} + + + +
+
+ ); +} + +/** + * OpenDots' frame around every OpenBot page: the icon rail, the Dots sidebar, a top bar with + * breadcrumbs and the computer toggle, and the computer pane on the right. The page itself, chat + * included, is OpenBot's own and renders as `children`. + */ +export function OpenDotsFrame({ children }: { children: ReactNode }) { + // One socket for the app, opened where the roster is kept live, as OpenBot's own sidebar does. + useChannelEvents(); + const place = usePlace(); + const navigate = useNavigate(); + const [mobileOpen, setMobileOpen] = useState(false); + const [navCollapsed, setNavCollapsed] = useState(false); + const [paneOpen, setPaneOpen] = useState(false); + const href = useRouterState({ select: (state) => state.location.href }); + + // OpenBot's own breakpoint, so the drawer and OpenBot's pages agree about what a phone is. + const isMobile = useIsMobile(); + + // A page chosen from the drawer replaces it, as tapping a row in OpenDots' drawer does. + // biome-ignore lint/correctness/useExhaustiveDependencies: closing on every navigation is the point. + useEffect(() => { + setMobileOpen(false); + }, [href]); + + /** New chat is with the Bot on screen when there is one, as OpenDots starts it with the open one. */ + const newChat: LinkProps = + place.section === "chat" && place.botId + ? { to: "/channel/new", search: { agent: place.botId } } + : { to: "/channel/new" }; + + /* + * A conversation draws OpenBot's own Computer pane, opened by its `?watch` flag, and opens it by + * itself when the Bot starts using the browser. The toggle drives that pane there rather than + * opening a second screen beside it; everywhere else it opens the frame's own. + */ + const computerOpen = place.ownsWatch ? place.watching : paneOpen; + const toggleComputer = () => { + if (!place.ownsWatch) { + setPaneOpen((open) => !open); + return; + } + void navigate({ + to: ".", + search: (previous: Record) => ({ + ...previous, + settings: undefined, + watch: place.watching ? undefined : true, + }), + }); + }; + + return ( +
+ setNavCollapsed((collapsed) => !collapsed)} + showBadge={!isMobile} + /> + {isMobile ? ( + <> + + {/* OpenBot's own sheet: it traps focus, closes on Escape and on the scrim. */} + + + Navigation + setMobileOpen(false)} + place={place} + /> + + + + ) : ( + + )} +
+ setNavCollapsed((collapsed) => !collapsed)} + place={place} + /> + +
+
+ {children} + {place.coworkerHome && place.botId ? ( + + ) : null} +
+ {paneOpen && !place.ownsWatch ? ( +
+ setPaneOpen(false)} + /> +
+ ) : null} +
+
+
+ ); +} diff --git a/app/src/skins/opendots/index.ts b/app/src/skins/opendots/index.ts new file mode 100644 index 0000000000..d2431ccd53 --- /dev/null +++ b/app/src/skins/opendots/index.ts @@ -0,0 +1,21 @@ +import type { Skin } from "../types"; +import { OpenDotsFrame } from "./frame"; +import { MemoriesScreen, RoutinesScreen } from "./screens"; + +/* + * OpenDots over OpenBot. The frame is OpenDots' rail, Dots sidebar, top bar and computer pane; the + * pages inside it are OpenBot's, chat included, in OpenDots' colours. Left out because OpenBot has + * nothing behind them: Spaces and their pages, page chats, the Brief, and the pause-everything + * switch (OpenBot pauses one Bot at a time, which the top bar offers for the Bot on screen). + */ +export const opendots: Skin = { + id: "opendots", + name: "OpenDots", + description: + "A compact sidebar of your Bots, with each Bot's computer beside the conversation.", + Frame: OpenDotsFrame, + screens: { + routines: RoutinesScreen, + memories: MemoriesScreen, + }, +}; diff --git a/app/src/skins/opendots/persona.tsx b/app/src/skins/opendots/persona.tsx new file mode 100644 index 0000000000..e06f46de67 --- /dev/null +++ b/app/src/skins/opendots/persona.tsx @@ -0,0 +1,46 @@ +import { IconDots } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import { agentListQueryOptions } from "@/lib/agents/queries"; + +/** + * OpenDots' coworker home: the Bot you are about to talk to, centred above an empty conversation. + * + * Drawn by the frame over OpenBot's own new-conversation page, which leaves that space empty. It + * holds no state and sends nothing: the To: field and composer beneath are OpenBot's. The stylesheet + * hides it the moment the page shows a message, so it never sits over a conversation. + */ +export function CoworkerPersona({ botId }: { botId: string }) { + const agents = useQuery(agentListQueryOptions()); + const bot = agents.data?.find((agent) => agent.id === botId); + if (!bot) return null; + return ( +
+
+ {/* OpenBot's own avatar for the Bot; the name below is its label. */} + + + +

+ {bot.name} +

+ {bot.roleDescription ? ( +

+ {bot.roleDescription} +

+ ) : null} + + Edit Bot + +
+
+ ); +} diff --git a/app/src/skins/opendots/place.ts b/app/src/skins/opendots/place.ts new file mode 100644 index 0000000000..b9053bed57 --- /dev/null +++ b/app/src/skins/opendots/place.ts @@ -0,0 +1,171 @@ +import { useQuery } from "@tanstack/react-query"; +import { useRouterState } from "@tanstack/react-router"; +import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; +import { channelQueryOptions } from "@/lib/channels/queries"; +import { appConfig } from "@/lib/generated/application-config"; + +/** Which part of OpenDots' sidebar a page belongs to, so the right row is lit. */ +export type Section = + | "chat" + | "routines" + | "memories" + | "bots" + | "capabilities" + | "connections" + | "other"; + +/** + * What the frame needs to know about the page inside it: its name for the breadcrumbs, and the one + * Bot it is about when it is about exactly one. Read from the URL and the caches the page itself + * fills, so the frame never asks the server anything the page did not. + */ +export type Place = { + section: Section; + /** The page's name, the last breadcrumb. */ + label: string; + /** A sub-page under the label, such as a Bot page's tab. */ + detail?: string; + /** The single Bot this page is about, or undefined. */ + botId?: string; + channelId?: string; + /** + * The page draws OpenBot's own Computer pane, opened by `?watch`. The frame's computer toggle + * drives that pane rather than opening a second one beside it. + */ + ownsWatch: boolean; + watching: boolean; + /** OpenBot's new-conversation page with its Bot already chosen: OpenDots' coworker home. */ + coworkerHome: boolean; +}; + +type Parsed = Omit & { + label: string | ((bot: string | undefined) => string); + agentFromUrl?: string; +}; + +const BOT_TABS: Record = { + setup: "Setup", + access: "Access", + activity: "Activity", + approvals: "Approvals", + memory: "Memory", + reach: "Reach", + responsibilities: "Responsibilities", + routines: "Routines", + sharing: "Sharing", +}; + +/** + * The page a pathname is, as OpenDots names its own views: a Bot's name for a chat with it, + * "Activity" and "Memories" for its two workspace views, and OpenBot's own page names otherwise. + */ +export function parsePlace( + pathname: string, + search: { agent?: unknown }, +): Parsed { + const agent = typeof search.agent === "string" ? search.agent : undefined; + const path = pathname.replace(/\/+$/, "") || "/"; + if (path === "/") { + return { section: "chat", label: "New chat", ownsWatch: false }; + } + if (path === "/channel/new") { + return { + section: "chat", + label: (bot) => bot ?? "New chat", + agentFromUrl: agent, + ownsWatch: false, + }; + } + const channel = path.match(/^\/(channel|group)\/([^/]+)$/); + if (channel) { + return { + section: "chat", + label: (bot) => bot ?? "Conversation", + channelId: decodeURIComponent(channel[2]), + ownsWatch: channel[1] === "channel", + }; + } + if (path === "/bot") { + return { + section: "chat", + label: (bot) => bot ?? "Conversation", + agentFromUrl: agent, + ownsWatch: true, + }; + } + const bot = path.match(/^\/bots\/([^/]+)(?:\/([^/]+))?$/); + if (bot) { + return { + section: "bots", + label: (name) => name ?? "Bot", + detail: bot[2] ? (BOT_TABS[bot[2]] ?? bot[2]) : undefined, + agentFromUrl: decodeURIComponent(bot[1]), + ownsWatch: false, + }; + } + if (path === "/bots" || path === "/agents") { + return { section: "bots", label: "All Bots", ownsWatch: false }; + } + if (path === "/s/routines" || path === "/routines") { + return { section: "routines", label: "Routines", ownsWatch: false }; + } + if (path === "/s/memories" || path === "/capabilities/memory") { + return { section: "memories", label: "Memories", ownsWatch: false }; + } + if (path.startsWith("/capabilities")) { + return { section: "capabilities", label: "Capabilities", ownsWatch: false }; + } + if (path.startsWith("/connections")) { + return { section: "connections", label: "Connections", ownsWatch: false }; + } + return { + section: "other", + label: appConfig.brand.productName, + ownsWatch: false, + }; +} + +export function botName( + agents: AgentProfile[] | undefined, + id: string | undefined, +): string | undefined { + if (!id) return undefined; + return agents?.find((agent) => agent.id === id)?.name; +} + +export function usePlace(): Place { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const agent = useRouterState({ + select: (state) => (state.location.search as { agent?: unknown }).agent, + }); + const watch = useRouterState({ + select: (state) => + (state.location.search as { watch?: unknown }).watch === true, + }); + const parsed = parsePlace(pathname, { agent }); + const agents = useQuery(agentListQueryOptions()); + // The page reads the same key, so this is the page's own fetch rather than a second one. + const channel = useQuery({ + ...channelQueryOptions(parsed.channelId ?? ""), + enabled: parsed.channelId !== undefined, + }); + const channelBots = parsed.channelId ? channel.data?.agentIds : undefined; + const botId = + parsed.agentFromUrl ?? + (channelBots?.length === 1 ? channelBots[0] : undefined); + const name = + botName(agents.data, botId) ?? + (parsed.channelId ? channel.data?.name : undefined); + return { + section: parsed.section, + label: typeof parsed.label === "string" ? parsed.label : parsed.label(name), + detail: parsed.detail, + botId, + coworkerHome: pathname.replace(/\/+$/, "") === "/channel/new" && !!botId, + channelId: parsed.channelId, + ownsWatch: parsed.ownsWatch, + watching: parsed.ownsWatch && watch, + }; +} diff --git a/app/src/skins/opendots/screens.tsx b/app/src/skins/opendots/screens.tsx new file mode 100644 index 0000000000..03c53335ab --- /dev/null +++ b/app/src/skins/opendots/screens.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import { MemoryList, RememberFact } from "@/components/memory/memories"; +import { RoutinesList } from "@/components/routines/routines-list"; + +/** + * OpenDots' workspace page: a small eyebrow, a large quiet title and one line under it, with the + * page's primary action on the title's right. What sits beneath is OpenBot's own list, so every + * switch, Run now, history and delete OpenBot offers is still there. + */ +function OpenDotsPage({ + eyebrow = "YOUR WORKSPACE", + title, + description, + action, + children, +}: { + eyebrow?: string; + title: string; + description: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+
+
+ + {eyebrow} + +

+ {title} +

+

+ {description} +

+
+ {action ?
{action}
: null} +
+ {children} +
+
+ ); +} + +/** Routines: what each Bot does on a schedule, as OpenBot's routines list. */ +export function RoutinesScreen() { + return ( + + + + ); +} + +/** "Memories": what the person's Bots know about them, as OpenBot's memory. */ +export function MemoriesScreen() { + return ( + } + description="What your Bots know about you." + title="Memories" + > + + + ); +} diff --git a/app/src/skins/opendots/sidebar.tsx b/app/src/skins/opendots/sidebar.tsx new file mode 100644 index 0000000000..7011d53dca --- /dev/null +++ b/app/src/skins/opendots/sidebar.tsx @@ -0,0 +1,629 @@ +import { + IconBook, + IconClockHour3, + IconDots, + IconLogout, + IconMessageCircle, + IconPin, + IconPinnedOff, + IconPlus, + IconSearch, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { Link, type LinkProps, useNavigate } from "@tanstack/react-router"; +import { type ReactNode, useRef, useState } from "react"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import { + isUnread, + matchingChannels, + pinnedFirst, +} from "@/components/app-sidebar/app-sidebar"; +import { ChannelPagination } from "@/components/app-sidebar/channel-pagination"; +import { DeleteConversationDialog } from "@/components/channels/delete-conversation-dialog"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Input } from "@/components/ui/input"; +import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; +import { groupRoster } from "@/lib/agents/roster"; +import { signOutMutationOptions } from "@/lib/auth/mutations"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; +import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations"; +import { + type ChannelSummary, + channelListQueryOptions, +} from "@/lib/channels/queries"; +import { appConfig } from "@/lib/generated/application-config"; +import { memoriesQueryOptions } from "@/lib/memory"; +import { useNavigationItems } from "@/lib/navigation"; +import { routinesQueryOptions } from "@/lib/routines/queries"; +import { cn } from "@/lib/utils"; +import { queryClient } from "@/query-client"; +import type { Place } from "./place"; + +/** OpenDots' sidebar row: a quiet line of text that lights lavender when it is the page. */ +export const navItem = + "flex w-full min-w-0 items-center gap-[11px] rounded-[7px] px-3.5 py-3 text-left text-[13px] leading-snug text-(--opendots-nav-ink) outline-none transition-colors hover:bg-(--opendots-hover) focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-[17px] [&_svg]:shrink-0 [&_svg]:stroke-[1.65]"; +export const navItemActive = + "bg-(--opendots-active) text-(--opendots-active-ink) hover:bg-(--opendots-active) [&_svg]:stroke-2"; + +/** The small grey heading over a group of rows, with its one or two actions on the right. */ +function NavLabel({ + children, + actions, + className, +}: { + children: ReactNode; + actions?: ReactNode; + className?: string; +}) { + return ( +
+ {children} + {actions ? ( + {actions} + ) : null} +
+ ); +} + +/** OpenDots' bare icon button: no frame, a grey glyph, a soft square on hover. */ +export function IconAction({ + label, + children, + className, + ...props +}: { + label: string; + children: ReactNode; + className?: string; +} & Omit, "children">) { + return ( + + ); +} + +/** + * The deployment's own name, set in OpenDots' wordmark type, which is also the way home. The name + * is `brand.yaml`'s, the same one OpenBot's sidebar header shows; a skin changes the look, never + * whose product it is. + */ +function Wordmark({ onNavigate }: { onNavigate: () => void }) { + const productName = appConfig.brand.productName; + return ( + + {productName} + + ); +} + +/** + * The most recent conversation this person has with one Bot alone, from the roster already loaded. + * Choosing a Dot in OpenDots reopens where you left off with it, and only starts fresh when there + * is nowhere to go back to. + */ +export function latestChannelWith( + channels: ChannelSummary[] | undefined, + agentId: string, +): ChannelSummary | undefined { + let latest: ChannelSummary | undefined; + let latestAt = ""; + for (const channel of channels ?? []) { + if (channel.agentIds.length !== 1 || channel.agentIds[0] !== agentId) + continue; + const at = channel.lastMessageAt ?? channel.createdAt; + if (at > latestAt) { + latest = channel; + latestAt = at; + } + } + return latest; +} + +/** The Bots a person can talk to, pinned and assigned first, then their own, then shared ones. */ +export function dotOrder(agents: readonly AgentProfile[]): AgentProfile[] { + const roster = groupRoster(agents); + return [...roster.pinned, ...roster.yours, ...roster.shared]; +} + +function BotsSection({ + place, + channels, + onNavigate, +}: { + place: Place; + channels: ChannelSummary[] | undefined; + onNavigate: () => void; +}) { + const agents = useQuery(agentListQueryOptions()); + const dots = agents.data ? dotOrder(agents.data) : []; + const bots = useNavigationItems().find((item) => item.id === "bots"); + return ( +
+ ( + + )} + > + + + } + > + BOTS + + {agents.isPending ? null : agents.error ? ( +

+ Couldn't load your Bots. Reload the page. +

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

+ No Bots yet. Use + to create one. +

+ ) : ( + + )} + {bots ? ( + // OpenBot's Bots destination, from the shared list, closing the list of Bots above it. + + + All {bots.label} + + ) : null} +
+ ); +} + +function ThreadRow({ + channel, + names, + open, + onNavigate, +}: { + channel: ChannelSummary; + names: string; + open: boolean; + onNavigate: () => void; +}) { + const navigate = useNavigate(); + const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient)); + const [confirming, setConfirming] = useState(false); + const [pinProblem, setPinProblem] = useState(null); + const unread = isUnread(channel, open ? channel.id : undefined); + const title = channel.summary || channel.lastMessage || channel.name; + const link: LinkProps = + channel.agentIds.length > 1 + ? { to: "/group/$channelId", params: { channelId: channel.id } } + : { to: "/channel/$channelId", params: { channelId: channel.id } }; + return ( + <> + + + + + + {title} + + {names} + + + {channel.pinned ? ( + + ) : null} + {unread ? ( + + ) : null} + + + + { + setPinProblem(null); + setPinned.mutate( + { channelId: channel.id, pinned: !channel.pinned }, + { onError: (thrown) => setPinProblem(thrown.message) }, + ); + }} + > + {channel.pinned ? : } + {channel.pinned ? "Unpin conversation" : "Pin conversation"} + + setConfirming(true)} + variant="destructive" + > + + Delete conversation… + + + + {pinProblem ? ( +

+ {pinProblem} +

+ ) : null} + navigate({ to: "/" }) : undefined} + name={channel.name} + onOpenChange={setConfirming} + open={confirming} + /> + + ); +} + +function RecentChats({ + place, + newChat, + onNavigate, +}: { + place: Place; + newChat: LinkProps; + onNavigate: () => void; +}) { + const agents = useQuery(agentListQueryOptions()); + const channels = useInfiniteQuery(channelListQueryOptions()); + const [searching, setSearching] = useState(false); + const [search, setSearch] = useState(""); + const scrollRoot = useRef(null); + const visible = pinnedFirst(matchingChannels(channels.data, search)); + const nameOf = (id: string) => + agents.data?.find((agent) => agent.id === id)?.name; + const closeSearch = () => { + setSearching(false); + setSearch(""); + }; + return ( +
+ + (searching ? closeSearch() : setSearching(true))} + > + {searching ? : } + + } + > + + + + } + className="mt-[18px]" + > + RECENT CHATS + + {searching ? ( +
+ setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") closeSearch(); + }} + placeholder="Find a conversation…" + value={search} + /> +
+ ) : null} +
+ {channels.isPending ? null : channels.error ? ( +

+ Couldn't load your conversations. Reload the page. +

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

+ {search.trim() + ? `No conversation has “${search.trim()}” in its name or latest message.` + : "No conversations yet. Start one with New chat."} +

+ ) : ( + visible.map((channel) => ( + nameOf(id)) + .filter(Boolean) + .join(", ")} + onNavigate={onNavigate} + open={place.channelId === channel.id} + /> + )) + )} + {/* OpenBot's own pager: it loads older conversations as the list scrolls to its end. */} + 0} + /> +
+
+ ); +} + +function Count({ value }: { value: number | undefined }) { + if (!value) return null; + return ( + + {value} + + ); +} + +function WorkspaceLinks({ + place, + onNavigate, +}: { + place: Place; + onNavigate: () => void; +}) { + const routines = useQuery(routinesQueryOptions()); + const memories = useQuery(memoriesQueryOptions()); + // OpenDots keeps these rows in the chrome's olive ink; the row you are on only gains the tint. + const row = (section: string) => + cn( + navItem, + "text-(--opendots-dot-ink)", + place.section === section && + "bg-(--opendots-active) hover:bg-(--opendots-active) [&_svg]:stroke-2", + ); + const current = (section: Place["section"]) => + place.section === section ? ("page" as const) : undefined; + /* + * OpenDots' sidebar ends on a few rows. OpenBot's primary destinations and the person's own menu + * are in the rail at desktop width, so they join these rows only in the phone drawer, where there + * is no rail. Bots is the row under the list of Bots above. + */ + const drawerOnly = "md:hidden"; + const navigation = useNavigationItems().filter((item) => item.id !== "bots"); + return ( + + ); +} + +export function initialsOf(user: { name?: string | null; email: string }) { + return ( + user.name + ?.trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || user.email.slice(0, 2).toUpperCase() + ); +} + +/** Who is signed in, and the way out. OpenBot's sidebar keeps this in its user menu. */ +function UserRow() { + const user = useQuery(currentUserQueryOptions()); + const navigate = useNavigate(); + const signOut = useMutation(signOutMutationOptions(queryClient)); + if (!user.data) return null; + const name = user.data.name?.trim() || user.data.email; + const initials = initialsOf(user.data); + return ( +
+ + {initials} + + + {name} + + { + await signOut.mutateAsync(); + await navigate({ to: "/sign" }); + }} + > + + +
+ ); +} + +/** + * OpenDots' sidebar over OpenBot's data: the Bots as Dots, the conversations as recent chats, and + * the workspace views beneath. Every OpenBot surface the default sidebar reaches is here too. + */ +export function OpenDotsSidebar({ + place, + newChat, + onNavigate, +}: { + place: Place; + newChat: LinkProps; + onNavigate: () => void; +}) { + const channels = useInfiniteQuery(channelListQueryOptions()); + return ( +
+ + + + New chat + + + + +
+ ); +} diff --git a/app/src/skins/opendots/skin.css b/app/src/skins/opendots/skin.css new file mode 100644 index 0000000000..caaf719ffa --- /dev/null +++ b/app/src/skins/opendots/skin.css @@ -0,0 +1,325 @@ +/* + * OpenDots tokens: `:root[data-skin="opendots"]` for light and `:root[data-skin="opendots"].dark` + * for dark. + * + * Light is OpenDots' own compact chrome: white canvas, one ink accent (#242424), a pale grey rail + * and sidebar, lavender for the row you are on, and teal for unread chats and your own messages. + * Dark is what OpenDots' build-time theme makes of the same colours (src/build/dark-theme.ts flips + * OKLab lightness, keeps hue, and lifts text to AA), with two departures where the flip loses + * hierarchy: cards sit a step above the canvas, and the breadcrumb keeps a dim and a bright half. + * + * Every text colour meets WCAG AA (4.5:1) on the surface it sits on, and every icon 3:1, in both + * themes. Where OpenDots' own grey fell short, it is darkened along its own hue until it passes. + */ +:root[data-skin="opendots"] { + color-scheme: light; + --background: #ffffff; + --foreground: #333641; + --card: #ffffff; + --card-foreground: #333641; + --popover: #ffffff; + --popover-foreground: #333641; + --primary: #242424; + --primary-foreground: #ffffff; + --secondary: #f1f1f1; + --secondary-foreground: #242424; + --muted: #f5f5f5; + --muted-foreground: #6b6b6b; + --accent: #f1f1f1; + --accent-foreground: #242424; + --destructive: #a23d35; + --success: #3d7e75; + --border: #e7e7e7; + --input: #dedede; + --ring: #242424; + --chart-1: #3d7e75; + --chart-2: #7285d5; + --chart-3: #c98a4b; + --chart-4: #9b82c9; + --chart-5: #8cac91; + --radius: 0.5625rem; + --sidebar: #f9f9f9; + --sidebar-foreground: #242424; + --sidebar-primary: #242424; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #eeeff7; + --sidebar-accent-foreground: #596aa9; + --sidebar-border: #e7e7e7; + --sidebar-ring: #242424; + + --opendots-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --opendots-rail: #f5f5f5; + --opendots-sidebar: #f9f9f9; + --opendots-topbar: #ffffff; + --opendots-pane: #fafafa; + --opendots-line: #e7e7e7; + --opendots-hover: #f4f4f9; + --opendots-active: #eeeff7; + --opendots-active-ink: #596aa9; + --opendots-tag: #edf1e7; + --opendots-tag-ink: #526249; + --opendots-nav-ink: #6c6d77; + --opendots-dot-ink: #58634f; + --opendots-new-chat-ink: #53634d; + --opendots-label: #646b61; + --opendots-muted: #707070; + --opendots-faint: #646d5d; + --opendots-icon: #878a99; + --opendots-rail-icon: #555555; + --opendots-teal: #438c81; + --opendots-crumb: #636d5b; + --opendots-crumb-ink: #686b76; + --opendots-toggle-ink: #575e59; + --opendots-pause-ink: #53624d; + --opendots-menu-ink: #333333; + --opendots-tab: #ededed; + --opendots-eyebrow: #71747f; + --opendots-scrim: #2f34464d; + --opendots-pane-shadow: -8px 0 28px #20263418; + --opendots-user-bubble: #3d7e75; + --opendots-user-bubble-ink: #ffffff; + --opendots-assistant-ink: #373737; + --opendots-composer-line: #dedede; + --opendots-composer-shadow: 0 3px 14px #00000006; + --opendots-composer-focus: #c8c8c8; + --opendots-send-off: #ececec; + --opendots-send-off-ink: #9a9a9a; + --opendots-note: #72737d; + --opendots-persona-ink: #747474; + --opendots-text-button: #707484; + --opendots-avatar: #eeeae1; + --opendots-avatar-line: #e2dfd8; + --opendots-avatar-ink: #6d675d; + --opendots-error-ink: #8b6a61; +} + +:root[data-skin="opendots"].dark { + color-scheme: dark; + --background: #1b1b1b; + --foreground: #b2b5c1; + --card: #222222; + --card-foreground: #b2b5c1; + --popover: #242424; + --popover-foreground: #b2b5c1; + --primary: #c5c5c5; + --primary-foreground: #1b1b1b; + --secondary: #242424; + --secondary-foreground: #c5c5c5; + --muted: #212121; + --muted-foreground: #a4a4a4; + --accent: #262626; + --accent-foreground: #c5c5c5; + --destructive: #e4877d; + --success: #7bb1a8; + --border: #2b2b2b; + --input: #313131; + --ring: #c5c5c5; + --chart-1: #7bb1a8; + --chart-2: #91a2db; + --chart-3: #d9a06a; + --chart-4: #b39ddb; + --chart-5: #92ad96; + --radius: 0.5625rem; + --sidebar: #1e1e1e; + --sidebar-foreground: #c5c5c5; + --sidebar-primary: #c5c5c5; + --sidebar-primary-foreground: #1b1b1b; + --sidebar-accent: #242429; + --sidebar-accent-foreground: #91a2db; + --sidebar-border: #2b2b2b; + --sidebar-ring: #c5c5c5; + + --opendots-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --opendots-rail: #212121; + --opendots-sidebar: #1e1e1e; + --opendots-topbar: #1b1b1b; + --opendots-pane: #1e1e1e; + --opendots-line: #2b2b2b; + --opendots-hover: #212124; + --opendots-active: #242429; + --opendots-active-ink: #f2f2f2; + --opendots-tag: transparent; + --opendots-tag-ink: #9caa93; + --opendots-nav-ink: #a3a3ad; + --opendots-dot-ink: #9ea996; + --opendots-new-chat-ink: #9baa96; + --opendots-label: #a1a79e; + --opendots-muted: #a4a4a4; + --opendots-faint: #9fa899; + --opendots-icon: #a1a4b1; + --opendots-rail-icon: #9c9c9c; + --opendots-teal: #76b2a8; + --opendots-crumb: #9fa898; + --opendots-crumb-ink: #c7c9d1; + --opendots-toggle-ink: #a0a6a1; + --opendots-pause-ink: #9ca996; + --opendots-menu-ink: #b8b8b8; + --opendots-tab: #272727; + --opendots-eyebrow: #a1a4ae; + --opendots-scrim: #00000080; + --opendots-pane-shadow: -8px 0 28px #00000026; + --opendots-user-bubble: #5c9189; + --opendots-user-bubble-ink: #1b1b1b; + --opendots-assistant-ink: #b5b5b5; + --opendots-composer-line: #313131; + --opendots-composer-shadow: 0 3px 14px #0000000a; + --opendots-composer-focus: #4a4a4a; + --opendots-send-off: #272727; + --opendots-send-off-ink: #646464; + --opendots-note: #a1a2ab; + --opendots-persona-ink: #a4a4a4; + --opendots-text-button: #a1a5b4; + --opendots-avatar: #2b2823; + --opendots-avatar-line: #32302b; + --opendots-avatar-ink: #a9a49b; + --opendots-error-ink: #bb9d94; +} + +/* OpenDots sets its type in the system face, not Inter, at a 14px base. */ +:root[data-skin="opendots"], +:root[data-skin="opendots"] body { + font-family: var(--opendots-font); + font-synthesis: none; +} + +/* + * OpenBot's pages inside the frame, drawn the OpenDots way. Each rule reaches only into + * `[data-opendots-main]`, the frame's page slot, so Settings and Admin keep OpenBot's own layout in + * OpenDots' colours. + */ + +/* Your own message is OpenDots' teal bubble with its one squared corner. */ +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="bubble"][data-align="end"] + > [data-slot="bubble-content"] { + background: var(--opendots-user-bubble); + color: var(--opendots-user-bubble-ink); + border-radius: 17px 17px 5px 17px; + padding: 11px 16px; + font-size: 14px; + line-height: 1.75; +} + +/* A Bot's message is plain prose on the canvas, a shade softer than the ink. */ +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="bubble"][data-align="start"][data-variant="ghost"] + > [data-slot="bubble-content"] { + color: var(--opendots-assistant-ink); + font-size: 14px; + line-height: 1.75; +} + +/* + * OpenBot's pages are reached through the `data-slot` hooks they publish for skins (`composer`, + * `composer-send`, `conversation-header`, `composer-column`, `detail-pane*`), never through + * their Tailwind classes, so restyling a page cannot quietly break the skin. + */ + +/* The composer is OpenDots' rounded card on the canvas: a 22px radius, a hairline and a breath of shadow. */ +:root[data-skin="opendots"] [data-opendots-main] [data-slot="composer"] { + background: var(--background); + border-radius: 22px; + border-color: var(--opendots-composer-line); + box-shadow: var(--opendots-composer-shadow); +} + +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="composer"]:focus-within { + border-color: var(--opendots-composer-focus); + box-shadow: var(--opendots-composer-shadow); +} + +/* Until there is something to send, OpenDots' send disc is a pale grey with a grey arrow, not a dimmed ink. */ +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="composer-send"]:disabled { + background: var(--opendots-send-off); + color: var(--opendots-send-off-ink); + opacity: 1; +} + +/* + * A conversation's own header row sits directly under the top bar. OpenDots draws no rule under + * its chat header, so the two read as one band instead of two stacked bars. + */ +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="conversation-header"] { + border-bottom-color: transparent; +} + +/* The coworker home steps aside as soon as the conversation has a message to show. */ +:root[data-skin="opendots"] + [data-opendots-main]:has([data-slot="message"]) + [data-opendots-persona] { + display: none; +} + +/* + * Where the conversation column is no wider than its composer, OpenDots still keeps the composer + * and its notices off the edges of the pane; OpenBot's runs them flush. 972px is the widest window + * at which the 672px column fills the pane beside the rail and sidebar. + */ +@media (max-width: 972px) { + :root[data-skin="opendots"] + [data-opendots-main] + [data-slot="composer-column"] { + padding-inline: 16px; + } +} + +/* + * A conversation's own Computer pane, which the top bar's computer toggle opens there, dressed as + * OpenDots' right pane: its grey ground, a ruled 56px header and the title as the selected tab. + * Only the Computer gives the pane a title; Bot settings leave it empty and keep a plain header. + */ +:root[data-skin="opendots"] [data-opendots-main] [data-slot="detail-pane"] { + background: var(--opendots-pane); + border-left-color: var(--opendots-line); +} + +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="detail-pane-header"] { + height: 56px; + padding-inline: 16px; + border-bottom: 1px solid var(--opendots-line); + background: var(--opendots-pane); +} + +:root[data-skin="opendots"] + [data-opendots-main] + [data-slot="detail-pane-title"]:not(:empty) { + width: auto; + flex: none; + border-radius: 9px; + padding: 7px 9px; + background: var(--opendots-tab); + color: var(--foreground); + font-size: 13px; +} + +/* + * Reduced motion. The frame's own chrome (rail, sidebar, top bar, computer pane, phone drawer) and + * the menus, tooltips, sheets and dialogs it opens stop moving: no transitions, no animations. + * OpenBot's orb in the rail already stills itself through `useReducedMotion`. No `!important`: + * this rule sits in no cascade layer, so it outranks every Tailwind utility as it is. + */ +@media (prefers-reduced-motion: reduce) { + :root[data-skin="opendots"] [data-opendots-chrome], + :root[data-skin="opendots"] [data-opendots-chrome] *, + :root[data-skin="opendots"] [data-slot="tooltip-content"], + :root[data-skin="opendots"] [data-slot="dropdown-menu-content"], + :root[data-skin="opendots"] [data-slot="context-menu-content"], + :root[data-skin="opendots"] [data-slot="select-content"], + :root[data-skin="opendots"] [data-slot="sheet-content"], + :root[data-skin="opendots"] [data-slot="sheet-overlay"], + :root[data-skin="opendots"] [data-slot="dialog-content"], + :root[data-skin="opendots"] [data-slot="dialog-overlay"] { + animation: none; + transition: none; + } +} diff --git a/app/src/skins/openduet/frame.tsx b/app/src/skins/openduet/frame.tsx new file mode 100644 index 0000000000..8b8acc7d84 --- /dev/null +++ b/app/src/skins/openduet/frame.tsx @@ -0,0 +1,102 @@ +import { useRouterState } from "@tanstack/react-router"; +import { type CSSProperties, type ReactNode, useState } from "react"; +import { BotsNavBadge } from "@/components/bot-profile/attention"; +import { SelfHostBanner } from "@/components/layout/self-host-banner"; +import { useChannelEvents } from "@/lib/channels/use-channel-events"; +import { cn } from "@/lib/utils"; +import { PageHeader, TabHeader } from "./header"; +import { MenuSheet } from "./menu-sheet"; +import { columnFor, isSkinScreen, tabForPath } from "./places"; +import { TabBar } from "./tab-bar"; + +/** The height the floating tab bar covers at the column's foot: its 64px and its 22px lift. */ +const TAB_SPACE = 86; + +/** How wide each kind of column may grow; see `columnFor`. */ +const COLUMN_WIDTH = { + phone: "max-w-[430px]", + pane: "max-w-[830px]", + page: "max-w-none", +} as const; + +/** + * OpenDuet's frame around every OpenBot page. + * + * Chat and Tasks are one column the width of a phone, centred on a wide screen, with the header + * floating at the top (the menu, the orb and the person's initial) and the frosted tab bar at the + * foot. When a conversation opens its Bot's details or computer, the column grows by OpenBot's own + * 400px pane, so the computer sits beside the conversation as it does in OpenBot. Every other page + * opens over the app with a back disc and keeps OpenBot's own width, so a Bot's page or Admin is as + * roomy as OpenBot made it, in OpenDuet's colours and type. + * + * The pages inside are OpenBot's own, so its chat, its tools and its approvals run unchanged. The + * frame keeps the two things OpenBot's sidebar did beyond drawing: the one socket that keeps the + * conversation list live, and the browser notification when a Bot starts needing the person. + */ +export function OpenDuetFrame({ children }: { children: ReactNode }) { + const location = useRouterState({ select: (state) => state.location }); + const pathname = location.pathname; + const tab = tabForPath(pathname); + const column = columnFor(pathname, location.search); + const ownScreen = isSkinScreen(pathname); + const [menuOpen, setMenuOpen] = useState(false); + // Any navigation closes the menu, including a conversation picked from inside it. + const [shownPath, setShownPath] = useState(pathname); + if (shownPath !== pathname) { + setShownPath(pathname); + setMenuOpen(false); + } + // One socket for the app: it keeps the conversation list and its working dots live. + useChannelEvents(); + const [main, setMain] = useState(null); + + return ( +
+ {/* + * OpenBot's Bots badge is what sends the browser notification when a Bot starts needing the + * person. The menu draws its own badge from the same count, so this one is mounted unseen, + * only to keep that notification coming. + */} + + +
+ {tab ? ( + setMenuOpen(true)} + /> + ) : ( + + )} +
+ {children} +
+ {tab ? : null} +
+ +
+ ); +} diff --git a/app/src/skins/openduet/header.tsx b/app/src/skins/openduet/header.tsx new file mode 100644 index 0000000000..85e81c0618 --- /dev/null +++ b/app/src/skins/openduet/header.tsx @@ -0,0 +1,300 @@ +import { IconChevronLeft } from "@tabler/icons-react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { + Link, + useCanGoBack, + useNavigate, + useParams, + useRouter, +} from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import type { AIState } from "@/components/agents/orb/ai-core"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; +import { + type ChannelWork, + channelListQueryOptions, +} from "@/lib/channels/queries"; +import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries"; +import { appConfig } from "@/lib/generated/application-config"; +import { useNavigationItems } from "@/lib/navigation"; +import { cn } from "@/lib/utils"; +import { IconMenuShort } from "./icons"; +import { DISC, FOCUS } from "./kit"; +import { + EDITOR_SELECTOR, + pressOrb, + pressOrbWhenReady, + useComposerPresence, +} from "./orb-dictation"; + +/** The header's side discs: 46px, past the 44px a finger needs. */ +const SIDE = 46; +/** The orb's footprint on a tab. */ +const ORB = 62; + +/** The person's initial, from their name, or their email when they have none. */ +export function initialOf(user: { name?: string | null; email: string }) { + const source = user.name?.trim() || user.email; + return (source.match(/\p{L}|\p{N}/u)?.[0] ?? "?").toUpperCase(); +} + +/** + * What the open conversation is doing, from the roster OpenBot keeps live over its socket: a turn + * running, or work it waits on between turns. + */ +function useOpenConversation() { + const channelId = useParams({ + strict: false, + select: (params) => (params as { channelId?: string }).channelId, + }); + const roster = useInfiniteQuery({ + ...channelListQueryOptions(), + enabled: channelId !== undefined, + }); + const channel = channelId + ? roster.data?.find((row) => row.id === channelId) + : undefined; + const work: ChannelWork[] = channel?.work ?? []; + return { busy: (channel?.busy ?? false) || work.length > 0, work }; +} + +/** What the Bot is doing, in a few words, for the pill under the orb. */ +export function doingLine(busy: boolean, work: ChannelWork[]): string | null { + const first = work[0]; + if (first?.kind === "handoff") + return `Asking ${first.botName ?? "another Bot"}`; + if (first?.kind === "routine") return "Running a routine"; + return busy ? "Thinking" : null; +} + +/** + * OpenDuet's header on a tab: the menu in a disc on the left, an orb in the centre, the person's + * initial in a disc on the right (their settings). The orb is OpenBot's own (`AgentOrb`, as sign-in + * and onboarding draw it), so the frame carries the deployment's identity, not OpenDuet's. It is the + * way to talk: pressed, it + * starts the composer's own dictation, or, on a screen with no composer, opens Chat and starts it + * there. It looks down while the person types or talks, and thinks while the Bot works, with a pill + * under it saying what the Bot is doing. + * + * `floating` lays it over a screen that scrolls under it, behind a fade. Otherwise it is a bar in + * the column's flow, over an OpenBot page that owns its own scroller. + */ +export function TabHeader({ + floating, + menuOpen, + onMenu, + main, +}: { + floating: boolean; + menuOpen: boolean; + onMenu: () => void; + /** The frame's content, where the composer the orb talks through is drawn. */ + main: HTMLElement | null; +}) { + const { data: user } = useQuery(currentUserQueryOptions()); + // Settings, from OpenBot's one navigation list, behind the person's initial. + const settings = useNavigationItems().find((item) => item.id === "settings"); + const capabilities = useQuery(deploymentCapabilitiesQueryOptions()); + const canDictate = capabilities.data?.transcription === true; + const presence = useComposerPresence(main); + const conversation = useOpenConversation(); + const navigate = useNavigate(); + const [goingToTalk, setGoingToTalk] = useState<(() => void) | null>(null); + useEffect(() => () => goingToTalk?.(), [goingToTalk]); + + // Working is the composer's own Stop control or the roster's word for a run nobody here started. + const working = presence.working || conversation.busy; + const state: AIState = presence.recording + ? "listening" + : working + ? "thinking" + : presence.typing + ? "listening" + : "idle"; + + const press = () => { + if (!main) return; + if (canDictate && pressOrb(main) !== "none") return; + // A composer that cannot take dictation right now still takes typing. + const editor = main.querySelector(EDITOR_SELECTOR); + if (editor) { + editor.focus(); + return; + } + void navigate({ to: "/" }).then(() => { + if (canDictate) setGoingToTalk(() => pressOrbWhenReady(() => main)); + }); + }; + + const label = !canDictate + ? "Chat" + : presence.recording + ? "Stop and add to your message" + : `Talk to ${appConfig.brand.productName}`; + + return ( +
+ + {/* The span between the discs, so the pill under the orb never reaches under either. */} +
+ + +
+ + + {user ? initialOf(user) : null} + + +
+ ); +} + +/** + * What the Bot is doing, in a glass pill under the orb. Always mounted, so the live region is there + * before the first words arrive and it can fade out as well as in. + */ +function DoingPill({ line }: { line: string | null }) { + const [held, setHeld] = useState(line); + if (line !== null && line !== held) setHeld(line); + const shown = line !== null; + return ( +
+

+ {held} +

+
+ ); +} + +/** What a page drawn under the chrome says about itself. */ +export type PageFacts = { + /** The page's own title, from its `page-title` hook. */ + title: string | null; + /** Where the page's own back link goes, and what it calls it, when the page has one. */ + back: { href: string; label: string } | null; +}; + +/** + * Read the page's title and its own back link, for the chrome to show in their place. Only read: + * the page is OpenBot's, rendered by React, and nothing here writes to it. The back link sits in the + * bar `PageShell` draws above the scroller that holds the page's header. + */ +export function readPageFacts(root: ParentNode): PageFacts { + const header = root.querySelector('[data-slot="page-header"]'); + const title = + root.querySelector('[data-slot="page-title"]')?.textContent?.trim() || null; + const bar = header?.parentElement?.parentElement?.previousElementSibling; + const link = bar?.querySelector("a[href]"); + const href = link?.getAttribute("href"); + return { + title, + back: href ? { href, label: link?.textContent?.trim() ?? "" } : null, + }; +} + +function usePageFacts(root: HTMLElement | null): PageFacts { + const [facts, setFacts] = useState({ title: null, back: null }); + useEffect(() => { + if (!root) return; + const read = () => + setFacts((before) => { + const now = readPageFacts(root); + return now.title === before.title && + now.back?.href === before.back?.href && + now.back?.label === before.back?.label + ? before + : now; + }); + read(); + const observer = new MutationObserver(read); + observer.observe(root, { + childList: true, + subtree: true, + characterData: true, + }); + return () => observer.disconnect(); + }, [root]); + return facts; +} + +/** + * The chrome for a page opened over the app: a back disc on the left, the page's title in a glass + * pill in the middle, and nothing on the right. No menu, no avatar and no tab bar, so a page reads + * as somewhere you came to and go back from. Back goes where the page's own back link goes when it + * has one (a Bot's page goes to Bots), back through history otherwise, and to Chat with neither. + */ +export function PageHeader({ main }: { main: HTMLElement | null }) { + const router = useRouter(); + const canGoBack = useCanGoBack(); + const { title, back } = usePageFacts(main); + return ( +
+ +
+ {title ? ( +
+ {/* The page's own title stays for screen readers; this is what the eye reads. */} +

+ {title} +

+
+ ) : null} +
+ +
+ ); +} diff --git a/app/src/skins/openduet/icons.ts b/app/src/skins/openduet/icons.ts new file mode 100644 index 0000000000..afa80cacec --- /dev/null +++ b/app/src/skins/openduet/icons.ts @@ -0,0 +1,41 @@ +import { createReactComponent } from "@tabler/icons-react"; + +/* + * Two of OpenDuet's glyphs have no Tabler twin, and they are the two a person sees on every screen: + * the menu (two long lines over a short one) and the Tasks tab (a box, a check, three lines). They + * are made as Tabler icons, on Tabler's 24-unit grid with its round caps and props, so they sit in + * a row of Tabler icons at the same weight. Everything else in the skin is Tabler's own. + */ + +export const IconMenuShort = createReactComponent( + "outline", + "menu-short", + "MenuShort", + [["path", { d: "M4 7h16M4 12h16M4 17h10", key: "menu-short-lines" }]], +); + +export const IconTasksList = createReactComponent( + "outline", + "tasks-list", + "TasksList", + [ + [ + "rect", + { + x: "3", + y: "4", + width: "6", + height: "6", + rx: "1.5", + key: "tasks-list-box", + }, + ], + [ + "path", + { + d: "m3 17 2 2 4-4M13 5h8M13 12h8M13 19h8", + key: "tasks-list-lines", + }, + ], + ], +); diff --git a/app/src/skins/openduet/index.ts b/app/src/skins/openduet/index.ts new file mode 100644 index 0000000000..9e2315b19a --- /dev/null +++ b/app/src/skins/openduet/index.ts @@ -0,0 +1,13 @@ +import type { Skin } from "../types"; +import { OpenDuetFrame } from "./frame"; +import { TASKS_SCREEN } from "./places"; +import { TasksScreen } from "./tasks-screen"; + +export const openduet: Skin = { + id: "openduet", + name: "OpenDuet", + description: + "Bots that keep you in the loop, in a column shaped like a phone.", + Frame: OpenDuetFrame, + screens: { [TASKS_SCREEN]: TasksScreen }, +}; diff --git a/app/src/skins/openduet/kit.tsx b/app/src/skins/openduet/kit.tsx new file mode 100644 index 0000000000..3fe27cc2bc --- /dev/null +++ b/app/src/skins/openduet/kit.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils"; + +/* + * The two pieces of OpenDuet's chrome the frame's parts share: the focus ring and the glass disc. + * Everything a screen lists is OpenBot's own component; these only dress the frame around it. + */ + +/** The focus ring every control in the frame draws: the accent, soft. */ +export const FOCUS = + "outline-none focus-visible:ring-3 focus-visible:ring-openduet-accent/40"; + +/** + * A round glass control over the screen: the header's discs. It gives a little when pressed, + * unless the person asked for less motion. + */ +export const DISC = cn( + "openduet-glass grid shrink-0 place-items-center rounded-full text-openduet-ink shadow-openduet-glass transition-transform duration-[120ms] active:scale-95 motion-reduce:transition-none motion-reduce:active:scale-100", + FOCUS, +); diff --git a/app/src/skins/openduet/menu-sheet.tsx b/app/src/skins/openduet/menu-sheet.tsx new file mode 100644 index 0000000000..8e106eb067 --- /dev/null +++ b/app/src/skins/openduet/menu-sheet.tsx @@ -0,0 +1,436 @@ +import { + IconChevronRight, + IconLogout, + IconPin, + IconPinFilled, + IconPinnedOff, + IconPlus, + IconStack2, + IconTrash, +} from "@tabler/icons-react"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { type ReactNode, useId, useRef, useState } from "react"; +import { isUnread, pinnedFirst } from "@/components/app-sidebar/app-sidebar"; +import { ChannelPagination } from "@/components/app-sidebar/channel-pagination"; +import { needsInput } from "@/components/bot-profile/attention"; +import { ChannelAvatar } from "@/components/channels/avatar"; +import { DeleteConversationDialog } from "@/components/channels/delete-conversation-dialog"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { signOutMutationOptions } from "@/lib/auth/mutations"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; +import { botAttentionQueryOptions } from "@/lib/bot-lifecycle/queries"; +import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations"; +import { + type ChannelSummary, + channelListQueryOptions, +} from "@/lib/channels/queries"; +import { appConfig } from "@/lib/generated/application-config"; +import { useNavigationItems } from "@/lib/navigation"; +import { relativeTime } from "@/lib/relative-time"; +import { useMessageListEmphasis } from "@/lib/settings/message-list"; +import { cn } from "@/lib/utils"; +import { queryClient } from "@/query-client"; +import { FOCUS } from "./kit"; + +/** Whether Manage was left open, kept in this browser between visits. */ +export const MANAGE_STORAGE_KEY = "openduet.menu-manage"; + +function rememberedOpen(key: string): boolean { + try { + return window.localStorage.getItem(key) === "open"; + } catch { + return false; + } +} + +function rememberOpen(key: string, open: boolean) { + try { + window.localStorage.setItem(key, open ? "open" : "closed"); + } catch { + // Storage can be refused. The group still opens; it just starts closed next time. + } +} + +/** A row in the menu: a line glyph and the place's name, at least 48px tall. */ +const ROW = cn( + "openduet-type-nav flex min-h-12 w-full items-center gap-3 rounded-[22px] px-3 font-medium text-openduet-ink transition-colors duration-[120ms] motion-reduce:transition-none hover:bg-openduet-tile active:bg-openduet-tile [&_svg]:shrink-0 [&>svg]:text-openduet-ink-2", + FOCUS, +); +/** A row with a disc, a name and a line under it: Chat and each conversation. */ +const TALL_ROW = cn( + "flex min-h-[52px] w-full items-center gap-3 rounded-[24px] px-2.5 py-1.5 text-left hover:bg-openduet-tile", + FOCUS, +); +const HERE = { className: "bg-openduet-tile" }; + +/** + * The drawer behind the header's left disc. From the top: the product's name and who is signed in; + * Chat, where a conversation starts; every conversation OpenBot keeps for this person, pinned first + * and newest after, joined to Chat by a hairline; New conversation; then the pages that set things + * up, under Manage, closed until somebody opens it; and Settings on its own, never folded, because + * the skin picker is there. + * + * The conversations take the height that is left and scroll by themselves, so the rows above and + * below stay put, and older ones load as the list nears its end, as OpenBot's sidebar does. + */ +export function MenuSheet({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const navigate = useNavigate(); + const { data: user } = useQuery(currentUserQueryOptions()); + const signOut = useMutation(signOutMutationOptions(queryClient)); + const channels = useInfiniteQuery(channelListQueryOptions()); + const [manageOpen, setManageOpen] = useState(() => + rememberedOpen(MANAGE_STORAGE_KEY), + ); + const manageId = useId(); + const scrollRoot = useRef(null); + const close = () => onOpenChange(false); + const conversations = pinnedFirst(channels.data ?? []); + // OpenBot's one navigation list, for this person: Settings on its own, the rest under Manage. + const navigation = useNavigationItems(); + const settings = navigation.find((item) => item.id === "settings"); + const places = navigation.filter((item) => item.id !== "settings"); + + return ( + + {/* No close button: the drawer closes on the screen beside it, on Escape, and on a row. */} + + + + {appConfig.brand.productName} + + + {user?.name || user?.email} + + + + + + ); +} + +/** + * What waits on the person across their Bots, from OpenBot's own count (the number its sidebar's + * Bots badge shows), in OpenDuet's one badge style: the accent, with what sits on it. A dot when the + * only news is something unread. + */ +function AccentBadge() { + const attention = useQuery(botAttentionQueryOptions()); + const bots = attention.data ?? []; + const waiting = bots.reduce((total, bot) => total + needsInput(bot), 0); + if (waiting > 0) + return ( + + {waiting} + + ); + if (bots.some((bot) => bot.unread > 0)) + return ( + + ); + return null; +} + +/** A name in the row's ink, and one line under it, cut short. */ +function RowText({ + name, + line, + strong = false, +}: { + name: ReactNode; + line: ReactNode; + strong?: boolean; +}) { + return ( + + + {name} + + {line ? ( + + {line} + + ) : null} + + ); +} + +/** + * One conversation: its Bots' avatars on a disc (OpenBot's, from each Bot's seed), then its title and its Bot, in the order the person + * chose in OpenBot's preferences. A dot when a Bot said something they have not seen; the faces + * type while a Bot works there. Held down or right-clicked, it pins or deletes, as OpenBot's roster + * does. + */ +function ConversationRow({ + channel, + onOpen, +}: { + channel: ChannelSummary; + onOpen: () => void; +}) { + const emphasis = useMessageListEmphasis(); + const navigate = useNavigate(); + const bots = useQuery(agentListQueryOptions()); + // Each Bot's own avatar, drawn from its seed as OpenBot's Bot pages draw it. + const seeds = channel.agentIds.map( + (id) => bots.data?.find((bot) => bot.id === id)?.avatarSeed ?? id, + ); + const isOpen = useParams({ + strict: false, + select: (params) => + (params as { channelId?: string }).channelId === channel.id, + }); + const unread = isUnread(channel, isOpen ? channel.id : undefined); + const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient)); + const [confirming, setConfirming] = useState(false); + const [problem, setProblem] = useState(null); + const title = channel.summary || channel.lastMessage || "New conversation"; + const when = channel.lastMessageAt + ? relativeTime(channel.lastMessageAt) + : null; + const busy = (channel.busy ?? false) || (channel.work?.length ?? 0) > 0; + const [name, line] = + emphasis === "agent" + ? [channel.name, title] + : [title, when ? `${channel.name} · ${when}` : channel.name]; + const group = channel.agentIds.length > 1; + + return ( + <> + + + + + + + + {channel.pinned ? ( + + ) : null} + {unread ? ( + + ) : null} + + + + { + setProblem(null); + setPinned.mutate( + { channelId: channel.id, pinned: !channel.pinned }, + { onError: (thrown) => setProblem(thrown.message) }, + ); + }} + > + {channel.pinned ? : } + {channel.pinned ? "Unpin conversation" : "Pin conversation"} + + setConfirming(true)} + variant="destructive" + > + + Delete conversation… + + + + {problem ? ( +

+ {problem} +

+ ) : null} + navigate({ to: "/" }) : undefined} + name={channel.name} + onOpenChange={setConfirming} + open={confirming} + /> + + ); +} diff --git a/app/src/skins/openduet/orb-dictation.ts b/app/src/skins/openduet/orb-dictation.ts new file mode 100644 index 0000000000..997645d562 --- /dev/null +++ b/app/src/skins/openduet/orb-dictation.ts @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; + +/* + * THE ORB TALKS THROUGH THE COMPOSER'S OWN DICTATION, IT DOES NOT HAVE ONE OF ITS OWN. + * + * OpenBot's composer owns dictation end to end (`components/channels/composer/use-dictation.ts`): + * the recording, the transcription, the waveform, Retry, and where the words land, which is that + * composer's draft. A second session in the header would have to put text into an editor it does + * not own, and would race the composer's own. So the orb presses the composer's own control, by + * the name OpenBot gives it, and reads the composer's own controls back to know it is listening. + * Everything a person sees while they talk is OpenBot's. + * + * Every part of the composer is found by the `data-slot` hooks OpenBot gives skins, never by its + * classes or its words; `tests/skins-openduet-orb.test.tsx` renders the real composer and holds + * every one of these to it. + */ + +/** The composer's mic button, drawn only where this deployment can transcribe. */ +export const DICTATE_SELECTOR = '[data-slot="composer-dictate"]'; +/** Drawn by the composer for as long as a recording is open. */ +export const RECORDING_SELECTOR = '[data-slot="composer-dictate-cancel"]'; +/** Ends the recording and puts the words in the draft. */ +export const STOP_SELECTOR = '[data-slot="composer-dictate-stop"]'; +/** + * Drawn by the composer for as long as the Bot's turn runs in this conversation: the most direct + * word OpenBot has that the Bot is working, and it needs no round trip through the server. + */ +export const WORKING_SELECTOR = '[data-slot="composer-stop"]'; +/** + * How long the Stop control has to stay before the orb reads the Bot as working. Opening a + * conversation shows it for a moment while OpenBot checks for a turn already running, and an orb + * that thought for that moment on every open would be a flicker, not news. A real turn runs far + * longer than this. + */ +export const WORKING_SETTLE_MS = 700; +/** The composer's input, around its editor. */ +export const INPUT_SELECTOR = '[data-slot="composer-input"]'; +/** The editor itself, which takes the focus. */ +export const EDITOR_SELECTOR = `${INPUT_SELECTOR} [contenteditable="true"]`; + +function enabled(button: Element | null): HTMLButtonElement | null { + return button instanceof HTMLButtonElement && !button.disabled + ? button + : null; +} + +export type OrbPress = "started" | "stopped" | "none"; + +/** + * Press the orb over `root`: stop a recording that is open, start one in the composer on screen, or + * report that there is no composer here to talk into. + */ +export function pressOrb(root: ParentNode): OrbPress { + const stop = enabled(root.querySelector(STOP_SELECTOR)); + if (stop) { + stop.click(); + return "stopped"; + } + if (root.querySelector(RECORDING_SELECTOR)) return "none"; + const dictate = enabled(root.querySelector(DICTATE_SELECTOR)); + if (dictate) { + dictate.click(); + return "started"; + } + return "none"; +} + +/** + * Start dictating in the composer once it is drawn: after the orb took the person to Chat from a + * screen with no composer, the next one to mount is the one they meant. Gives up after `timeoutMs`, + * leaving them in Chat with the composer in front of them. + */ +export function pressOrbWhenReady( + root: () => ParentNode | null, + timeoutMs = 3_000, +): () => void { + const until = Date.now() + timeoutMs; + let frame = 0; + const attempt = () => { + const node = root(); + if (node && pressOrb(node) === "started") return; + if (Date.now() < until) frame = requestAnimationFrame(attempt); + }; + frame = requestAnimationFrame(attempt); + return () => cancelAnimationFrame(frame); +} + +/** + * Whether the composer inside `root` is listening, whether the Bot it talks to is working, and + * whether the person is typing in it. + */ +export function useComposerPresence(root: HTMLElement | null) { + const [recording, setRecording] = useState(false); + const [working, setWorking] = useState(false); + const [typing, setTyping] = useState(false); + + useEffect(() => { + if (!root) return; + // The transcript mutates on every streamed word; only a change in either is news. + let lastRecording: boolean | null = null; + let lastWorking: boolean | null = null; + let settle: ReturnType | undefined; + const read = () => { + const isRecording = root.querySelector(RECORDING_SELECTOR) !== null; + const isWorking = root.querySelector(WORKING_SELECTOR) !== null; + if (isRecording !== lastRecording) { + lastRecording = isRecording; + setRecording(isRecording); + } + if (isWorking !== lastWorking) { + lastWorking = isWorking; + clearTimeout(settle); + if (isWorking) + settle = setTimeout(() => setWorking(true), WORKING_SETTLE_MS); + else setWorking(false); + } + }; + read(); + const observer = new MutationObserver(read); + observer.observe(root, { childList: true, subtree: true }); + const inEditor = (target: EventTarget | null) => + target instanceof Element && target.closest(INPUT_SELECTOR) !== null; + const onFocusIn = (event: FocusEvent) => { + if (inEditor(event.target)) setTyping(true); + }; + const onFocusOut = (event: FocusEvent) => { + if (inEditor(event.target)) setTyping(false); + }; + root.addEventListener("focusin", onFocusIn); + root.addEventListener("focusout", onFocusOut); + return () => { + observer.disconnect(); + clearTimeout(settle); + root.removeEventListener("focusin", onFocusIn); + root.removeEventListener("focusout", onFocusOut); + }; + }, [root]); + + return { recording, working, typing }; +} diff --git a/app/src/skins/openduet/places.ts b/app/src/skins/openduet/places.ts new file mode 100644 index 0000000000..a8ca874e6f --- /dev/null +++ b/app/src/skins/openduet/places.ts @@ -0,0 +1,75 @@ +import type { LinkOptions } from "@tanstack/react-router"; + +/* + * The skin's own places: its tabs, the column each path gets, and its Tasks screen. Where else a + * person can go is OpenBot's one navigation list (`lib/navigation.ts`), which the menu draws. + */ + +/* + * Where things are in OpenDuet's frame, as plain data, so the frame, the menu and the tests read + * one list. Nothing here is a new route: every path is one OpenBot already serves, or the skin's own + * screen at `/s/`. + */ + +/** The skin's own screen, served by OpenBot's `/s/$screen`. */ +export const TASKS_SCREEN = "tasks"; +export const TASKS_PATH = `/s/${TASKS_SCREEN}`; + +export type OpenDuetTab = "chat" | "tasks"; + +/** + * Which tab a path belongs to, or null for a page opened over the app. + * + * Chat is every conversation OpenBot draws: home, where a conversation starts, a conversation with + * one Bot or several, and the default Bot's own screen. Tasks is the skin's screen. Everything else + * (Bots, Connections, Skills, Routines and the rest) is a page with a back disc and no tab bar, as + * OpenDuet opens them. + */ +export function tabForPath(pathname: string): OpenDuetTab | null { + const path = pathname.replace(/\/+$/, "") || "/"; + if (path === "/" || path === "/bot") return "chat"; + if (/^\/(channel|group)\/[^/]+$/.test(path)) return "chat"; + if (path === TASKS_PATH) return "tasks"; + return null; +} + +/** + * Whether the screen under the frame is one of the skin's own, which scrolls under the floating + * header and tab bar by itself. OpenBot's pages own their scrollers, so the frame keeps its chrome + * clear of them instead. + */ +export function isSkinScreen(pathname: string): boolean { + return pathname.startsWith("/s/"); +} + +/** + * How wide the frame's column is. `phone` holds Chat and Tasks to a phone's width. `pane` is a + * conversation with its Bot's details or computer open: the phone column plus OpenBot's 400px pane + * beside it (`DetailPanel` lays the pane out beside the conversation once there is room for both). + * `page` is every other page, at the width OpenBot gave it. + */ +export type OpenDuetColumn = "phone" | "pane" | "page"; + +export function columnFor( + pathname: string, + search: Record = {}, +): OpenDuetColumn { + const tab = tabForPath(pathname); + if (!tab) return "page"; + if (tab === "chat" && (search.watch === true || search.settings === true)) + return "pane"; + return "phone"; +} + +/** The Tasks tab, at the skin's own screen. */ +export const TASKS_LINK = { + to: "/s/$screen", + params: { screen: TASKS_SCREEN }, +} satisfies LinkOptions; + +/** The conversation a roster row opens: a group has its own shared transcript. */ +export function conversationPath(channel: { id: string; agentIds: string[] }) { + return channel.agentIds.length > 1 + ? (`/group/${channel.id}` as const) + : (`/channel/${channel.id}` as const); +} diff --git a/app/src/skins/openduet/skin.css b/app/src/skins/openduet/skin.css new file mode 100644 index 0000000000..333cfcfea7 --- /dev/null +++ b/app/src/skins/openduet/skin.css @@ -0,0 +1,321 @@ +/* + * OpenDuet: neutral greys with a cool bias, one violet accent used in a few fixed places, four + * status colours for how work stands, frosted glass for the chrome that floats over a screen, and + * Inter set with the single-storey a and flat-topped 3. The values are OpenDuet's own + * (`openduet/openbot/app/src/styles.css`), so a screen drawn here reads as the same product. + * + * Light is `:root[data-skin="openduet"]` and dark is `:root[data-skin="openduet"].dark`, and every + * token is set in both: the skin's selector outranks OpenBot's `.dark`, so a token set only for + * light would leak into dark. + */ + +:root[data-skin="openduet"] { + color-scheme: light; + + /* OpenBot's shadcn tokens, so every OpenBot page drawn inside the frame wears OpenDuet. */ + --background: #f6f6f8; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --popover: #ffffff; + --popover-foreground: #18181b; + --primary: #111114; + --primary-foreground: #ffffff; + --secondary: #f1f1f4; + --secondary-foreground: #18181b; + --muted: #f1f1f4; + --muted-foreground: #55555e; + --accent: #f1f1f4; + --accent-foreground: #18181b; + --destructive: #b91c1c; + --success: #147a3a; + --border: rgb(24 24 27 / 9%); + --input: rgb(24 24 27 / 12%); + --ring: #6430ab; + --radius: 0.875rem; + --sidebar: #f6f6f8; + --sidebar-foreground: #18181b; + --sidebar-primary: #111114; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #ececf0; + --sidebar-accent-foreground: #18181b; + --sidebar-border: rgb(24 24 27 / 9%); + --sidebar-ring: #6430ab; + + /* OpenDuet's own roles. */ + --openduet-surface: #f6f6f8; + --openduet-card: #ffffff; + --openduet-bubble: #ececf0; + --openduet-ink: #18181b; + --openduet-ink-2: #55555e; + --openduet-ink-3: #6b6b75; + --openduet-hair: rgb(24 24 27 / 9%); + --openduet-hair-2: rgb(24 24 27 / 6%); + --openduet-tile: #f1f1f4; + /* The one accent: the Tasks badge, the unread dot, the focus ring, the Chat row's glyph. */ + --openduet-accent: #6430ab; + --openduet-accent-soft: #f0ebfa; + --openduet-on-accent: #ffffff; + /* How work stands: done, needs you, working, couldn't finish. Only on status. */ + --openduet-done: #147a3a; + --openduet-done-soft: #e6f4ea; + --openduet-needs: #a34a06; + --openduet-needs-soft: #fdf3e1; + --openduet-working: #1d4ed8; + --openduet-working-soft: #eaf0fd; + --openduet-problem: #b91c1c; + --openduet-problem-soft: #fbeaea; + /* The primary button: near-black on light, white on dark. */ + --openduet-black: #111114; + --openduet-on-black: #ffffff; + --openduet-shadow-float: + 0 0 0 0.5px rgb(1 5 7 / 10%), 0 1px 1.5px rgb(1 5 7 / 6%), + 0 4px 12px -4px rgb(1 5 7 / 12%); + --openduet-shadow-card: + 0 0 0 1px rgb(24 24 27 / 9%), 0 6px 18px -12px rgb(1 5 7 / 18%); + --openduet-shadow-raised: + 0 0 0 1px rgb(24 24 27 / 9%), 0 10px 26px -14px rgb(1 5 7 / 20%); + /* Glass: frosted chrome over a scrolling screen, about 80% so text under it never competes. */ + --openduet-glass: rgb(255 255 255 / 80%); + --openduet-veil: rgb(246 246 248 / 74%); + --openduet-shadow-glass: + inset 0 0.5px 0 rgb(255 255 255 / 80%), 0 0 0 0.5px rgb(1 5 7 / 10%), + 0 1px 1.5px rgb(1 5 7 / 6%), 0 4px 12px -4px rgb(1 5 7 / 12%); + /* The fade behind the floating header, from the surface to nothing. */ + --openduet-fade: linear-gradient(#f6f6f8 55%, rgb(246 246 248 / 0%)); +} + +:root[data-skin="openduet"].dark { + color-scheme: dark; + + --background: #111113; + --foreground: #f4f4f5; + --card: #1c1c1f; + --card-foreground: #f4f4f5; + --popover: #1c1c1f; + --popover-foreground: #f4f4f5; + --primary: #f4f4f5; + --primary-foreground: #111114; + --secondary: #26262b; + --secondary-foreground: #f4f4f5; + --muted: #26262b; + --muted-foreground: #a1a1aa; + --accent: #26262b; + --accent-foreground: #f4f4f5; + --destructive: #f87171; + --success: #4ade80; + --border: rgb(255 255 255 / 10%); + --input: rgb(255 255 255 / 14%); + --ring: #b49ae8; + --radius: 0.875rem; + --sidebar: #111113; + --sidebar-foreground: #f4f4f5; + --sidebar-primary: #f4f4f5; + --sidebar-primary-foreground: #111114; + --sidebar-accent: #26262b; + --sidebar-accent-foreground: #f4f4f5; + --sidebar-border: rgb(255 255 255 / 10%); + --sidebar-ring: #b49ae8; + + /* Near-black and neutral, as OpenDuet samples it; the primary action turns light. */ + --openduet-surface: #111113; + --openduet-card: #1c1c1f; + --openduet-bubble: #26262b; + --openduet-ink: #f4f4f5; + --openduet-ink-2: #a1a1aa; + --openduet-ink-3: #8e8e98; + --openduet-hair: rgb(255 255 255 / 10%); + --openduet-hair-2: rgb(255 255 255 / 7%); + --openduet-tile: #26262b; + --openduet-accent: #b49ae8; + --openduet-accent-soft: #2a2140; + /* Dark text on the light lilac: white on it is 2.4:1. */ + --openduet-on-accent: #1a1033; + --openduet-done: #4ade80; + --openduet-done-soft: #14301f; + --openduet-needs: #fbbf24; + --openduet-needs-soft: #3a2a0c; + --openduet-working: #7aa2ff; + --openduet-working-soft: #18233d; + --openduet-problem: #f87171; + --openduet-problem-soft: #3a1717; + --openduet-black: #f4f4f5; + --openduet-on-black: #111114; + --openduet-shadow-float: + 0 0 0 0.5px rgb(255 255 255 / 10%), 0 4px 12px -4px rgb(0 0 0 / 50%); + --openduet-shadow-card: 0 0 0 1px rgb(255 255 255 / 10%); + --openduet-shadow-raised: + 0 0 0 1px rgb(255 255 255 / 10%), 0 10px 26px -14px rgb(0 0 0 / 60%); + --openduet-glass: rgb(28 28 31 / 82%); + --openduet-veil: rgb(17 17 19 / 72%); + --openduet-shadow-glass: + inset 0 0.5px 0 rgb(255 255 255 / 8%), 0 0 0 0.5px rgb(255 255 255 / 10%), + 0 4px 12px -4px rgb(0 0 0 / 50%); + --openduet-fade: linear-gradient(#111113 55%, rgb(17 17 19 / 0%)); +} + +/* + * The roles as Tailwind colours, so the skin's own components write `bg-openduet-card`. `inline` + * emits no variables of its own: each utility reads the `--openduet-*` token above, which only + * exists while this skin is active. + */ +@theme inline { + --color-openduet-surface: var(--openduet-surface); + --color-openduet-card: var(--openduet-card); + --color-openduet-bubble: var(--openduet-bubble); + --color-openduet-ink: var(--openduet-ink); + --color-openduet-ink-2: var(--openduet-ink-2); + --color-openduet-ink-3: var(--openduet-ink-3); + --color-openduet-hair: var(--openduet-hair); + --color-openduet-hair-2: var(--openduet-hair-2); + --color-openduet-tile: var(--openduet-tile); + --color-openduet-accent: var(--openduet-accent); + --color-openduet-accent-soft: var(--openduet-accent-soft); + --color-openduet-on-accent: var(--openduet-on-accent); + --color-openduet-done: var(--openduet-done); + --color-openduet-done-soft: var(--openduet-done-soft); + --color-openduet-needs: var(--openduet-needs); + --color-openduet-needs-soft: var(--openduet-needs-soft); + --color-openduet-working: var(--openduet-working); + --color-openduet-working-soft: var(--openduet-working-soft); + --color-openduet-problem: var(--openduet-problem); + --color-openduet-problem-soft: var(--openduet-problem-soft); + --color-openduet-black: var(--openduet-black); + --color-openduet-on-black: var(--openduet-on-black); + --shadow-openduet-float: var(--openduet-shadow-float); + --shadow-openduet-card: var(--openduet-shadow-card); + --shadow-openduet-raised: var(--openduet-shadow-raised); + --shadow-openduet-glass: var(--openduet-shadow-glass); +} + +/* Inter as OpenDuet sets it: the single-storey a (cv11) and the flat-topped 3 (ss03). */ +:root[data-skin="openduet"] body { + font-feature-settings: "cv11", "ss03"; +} + +@layer utilities { + /* + * OpenDuet's five sizes, in rem so they follow the person's text size. Weights are not part of + * a size: 600 for titles, 500 for rows, chips and buttons, 400 otherwise. In the utilities layer, + * and scoped to the skin, so a size given to one of OpenBot's primitives outranks the primitive's + * own `text-*`. + */ + [data-skin="openduet"] .openduet-type-display { + font-size: 1.5rem; + line-height: 1.75rem; + letter-spacing: -0.018em; + } + [data-skin="openduet"] .openduet-type-nav { + font-size: 1.125rem; + line-height: 1.375rem; + letter-spacing: -0.011em; + } + [data-skin="openduet"] .openduet-type-body { + font-size: 1.0625rem; + line-height: 1.4375rem; + letter-spacing: -0.011em; + } + [data-skin="openduet"] .openduet-type-sub { + font-size: 0.9375rem; + line-height: 1.25rem; + letter-spacing: -0.006em; + } + [data-skin="openduet"] .openduet-type-caption { + font-size: 0.8125rem; + line-height: 1rem; + letter-spacing: 0; + } +} + +@layer components { + /* + * Frosted chrome. The blur is what makes the translucency readable; a browser that cannot blur + * gets the opaque card instead, never a see-through bar. + */ + [data-skin="openduet"] .openduet-glass { + background-color: var(--openduet-card); + } + [data-skin="openduet"] .openduet-veil { + background-color: var(--openduet-surface); + } + @supports (backdrop-filter: blur(1px)) or + (-webkit-backdrop-filter: blur(1px)) { + [data-skin="openduet"] .openduet-glass { + background-color: var(--openduet-glass); + -webkit-backdrop-filter: blur(22px) saturate(180%); + backdrop-filter: blur(22px) saturate(180%); + } + [data-skin="openduet"] .openduet-veil { + background-color: var(--openduet-veil); + -webkit-backdrop-filter: blur(28px) saturate(160%); + backdrop-filter: blur(28px) saturate(160%); + } + } +} + +/* + * Inside the frame, OpenBot's own controls take OpenDuet's shape: buttons are pills, and every + * control is at least 44px to tap, the smallest thing a finger can hit. The primitives mark + * themselves with `data-slot`, so one rule covers every page without touching any of them. A link + * drawn as a button sits inside a sentence and keeps its line height. + */ +[data-skin="openduet"] [data-openduet-frame] [data-slot="button"] { + border-radius: 9999px; +} +[data-skin="openduet"] + [data-openduet-page] + [data-slot="button"]:not([data-variant="link"]) { + min-height: 44px; +} +[data-skin="openduet"] [data-openduet-page] [data-slot="select-trigger"] { + min-height: 44px; +} +[data-skin="openduet"] [data-openduet-page] [data-slot="input"] { + min-height: 44px; +} +[data-skin="openduet"] [data-openduet-page] [data-slot="input-group"] { + min-height: 44px; +} + +/* + * A conversation's composer floats inset from the column's edges, as OpenDuet's does, with its + * focus ring off the screen's edge. OpenBot draws it in a column with no side padding. + */ +[data-skin="openduet"] [data-openduet-frame] [data-slot="composer-column"] { + padding-inline: 12px; +} + +/* + * A page opened over the app names itself in the chrome's title pill, read from the page's own + * title, which stays for screen readers only, as OpenDuet draws a page. The page then starts right + * under the chrome, and its one action keeps its place on the right of the title's line. + */ +[data-skin="openduet"] [data-openduet-page] [data-slot="page-title"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +[data-skin="openduet"] [data-openduet-page] [data-slot="page-header"] > div { + justify-content: flex-end; +} +[data-skin="openduet"] + [data-openduet-page] + div:has(> [data-slot="page-header"]) { + padding-top: 0.5rem; +} + +/* + * The bar a page draws above its scroller holds its own back link. The chrome's back disc goes + * where that link goes (`header.tsx`), so the bar is not drawn twice. + */ +[data-skin="openduet"] + [data-openduet-page] + div:has(+ div > div > [data-slot="page-header"]) { + display: none; +} diff --git a/app/src/skins/openduet/tab-bar.tsx b/app/src/skins/openduet/tab-bar.tsx new file mode 100644 index 0000000000..a11f4cf054 --- /dev/null +++ b/app/src/skins/openduet/tab-bar.tsx @@ -0,0 +1,97 @@ +import { IconMessageCircle } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { approvalInboxOptions } from "@/lib/approvals"; +import { cn } from "@/lib/utils"; +import { IconTasksList } from "./icons"; +import { FOCUS } from "./kit"; +import { type OpenDuetTab, TASKS_LINK } from "./places"; +import { waitingCount } from "./tasks-model"; + +const TAB = + "relative flex flex-1 flex-col items-center justify-center gap-px rounded-[27px] transition-colors motion-reduce:transition-none"; + +/** + * The frosted pill at the foot of the app: Chat and Tasks, the current one on a grey capsule. + * Tasks carries the number of things waiting on the person, counted the way its Needs input section + * lists them, so the badge and the screen never disagree. + */ +export function TabBar({ current }: { current: OpenDuetTab }) { + const inbox = useQuery(approvalInboxOptions()); + const waiting = waitingCount(inbox.data); + const tabs = [ + { id: "chat" as const, label: "Chat", Glyph: IconMessageCircle, badge: 0 }, + { + id: "tasks" as const, + label: "Tasks", + Glyph: IconTasksList, + badge: waiting, + }, + ]; + return ( + + ); +} diff --git a/app/src/skins/openduet/tasks-model.ts b/app/src/skins/openduet/tasks-model.ts new file mode 100644 index 0000000000..71c8d00485 --- /dev/null +++ b/app/src/skins/openduet/tasks-model.ts @@ -0,0 +1,15 @@ +import type { ApprovalInboxData } from "@/lib/approvals"; + +/** + * How many things wait on the person, for the Tasks tab's badge and the Needs input heading: the + * pending approvals and the questions OpenBot's `WaitingForYou` lists, counted the way it filters + * them, so the badge and the rows never disagree. `tests/skins-openduet-model.test.ts` renders + * `WaitingForYou` and holds the two to the same number. + */ +export function waitingCount(inbox: ApprovalInboxData | undefined): number { + if (!inbox) return 0; + const pending = inbox.requests.filter( + (request) => request.status === "pending", + ).length; + return pending + inbox.questions.length; +} diff --git a/app/src/skins/openduet/tasks-screen.tsx b/app/src/skins/openduet/tasks-screen.tsx new file mode 100644 index 0000000000..a1e80fc693 --- /dev/null +++ b/app/src/skins/openduet/tasks-screen.tsx @@ -0,0 +1,183 @@ +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useState } from "react"; +import { WaitingForYou } from "@/components/approvals/waiting"; +import { BotResponsibilities } from "@/components/responsibilities/responsibilities"; +import { RoutinesList } from "@/components/routines/routines-list"; +import { SuggestionsInbox } from "@/components/suggestions/proactive-panel"; +import { approvalInboxOptions } from "@/lib/approvals"; +import { routinesQueryOptions } from "@/lib/routines/queries"; +import { cn } from "@/lib/utils"; +import { FOCUS } from "./kit"; +import { waitingCount } from "./tasks-model"; + +/** Which list Tasks shows: what waits on you and what your Bots carry, or what to start next. */ +export type TasksView = "active" | "up_next"; + +/** Which list was open last, kept in this browser between visits. */ +export const TASKS_VIEW_STORAGE_KEY = "openduet.tasks-view"; + +function rememberedView(): TasksView { + try { + return window.localStorage.getItem(TASKS_VIEW_STORAGE_KEY) === "up_next" + ? "up_next" + : "active"; + } catch { + return "active"; + } +} + +function rememberView(view: TasksView) { + try { + window.localStorage.setItem(TASKS_VIEW_STORAGE_KEY, view); + } catch { + // Storage can be refused. The list still switches; it opens on Active next time. + } +} + +/** + * OpenDuet's Tasks: its two big words, Active and Up next, over OpenBot's own screens. + * + * Every list here is OpenBot's component, unchanged: `WaitingForYou` for approvals and questions, + * `BotResponsibilities` for responsibilities, `RoutinesList` for routines and `SuggestionsInbox` + * for suggested next steps. So every choice, field and confirmation OpenBot offers is here, and a + * new one arrives without this screen changing. The skin adds only the switch, the headings and + * the layout around them. + */ +export function TasksScreen() { + const [view, setView] = useState(rememberedView); + const show = (next: TasksView) => { + setView(next); + rememberView(next); + }; + + return ( +
+
+ +
+ {view === "active" ? : } +
+
+
+ ); +} + +/** + * Active and Up next as two large words, the one showing in ink and the other grey. It sticks + * under the header while the list scrolls, solid up to the discs and fading under. + */ +function TasksSwitch({ + view, + onView, +}: { + view: TasksView; + onView: (view: TasksView) => void; +}) { + return ( +
+
+ + Active + + + Up next + +
+
+ ); +} + +function SwitchTab({ + id, + view, + onView, + children, +}: { + id: TasksView; + view: TasksView; + onView: (view: TasksView) => void; + children: ReactNode; +}) { + const on = id === view; + return ( + + ); +} + +/** + * Active: what waits on the person, decided in OpenBot's own rows, then the responsibilities and + * routines their Bots carry, in OpenBot's own lists. Needs input is left out when nothing waits. + */ +function ActiveView() { + const inbox = useQuery(approvalInboxOptions()); + const routines = useQuery(routinesQueryOptions()); + const waiting = waitingCount(inbox.data); + + return ( +
+ {waiting > 0 ? ( +
+ +
+ ) : null} + +
+ +
+
+ ); +} + +/** A section heading in OpenDuet's grey, with how many, over an OpenBot list. */ +function Section({ + label, + count, + children, +}: { + label: string; + /** Left off until the list has loaded, so the heading never states a wrong number. */ + count: number | undefined; + children: ReactNode; +}) { + return ( +
+

+ {label} + {count === undefined ? null : ( + {count} + )} +

+ {children} +
+ ); +} diff --git a/app/src/skins/openmuse/bots-sheet.tsx b/app/src/skins/openmuse/bots-sheet.tsx new file mode 100644 index 0000000000..2afc3564eb --- /dev/null +++ b/app/src/skins/openmuse/bots-sheet.tsx @@ -0,0 +1,150 @@ +import { + IconBolt, + IconCheck, + IconPlus, + IconUserCircle, +} from "@tabler/icons-react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import { + attentionSummary, + needsInput, +} from "@/components/bot-profile/attention"; +import { Item, ItemContent, ItemTitle } from "@/components/ui/item"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { botAttentionQueryOptions } from "@/lib/bot-lifecycle/queries"; +import { channelListQueryOptions } from "@/lib/channels/queries"; +import { appConfig } from "@/lib/generated/application-config"; +import { cn } from "@/lib/utils"; +import { chooseBot } from "./chosen-bot"; +import { useMuse } from "./context"; +import { mainChannel, tabFor } from "./model"; +import { Divider, ErrorNotice, LinkRow, MuseSheet, museText } from "./ui"; + +/** + * Who sits at the centre of the frame. This look has one agent; OpenBot has a roster, so this is the + * one place the difference shows, and it stays a sheet behind the name rather than a fixture. + */ +export function BotsSheet({ onClose }: { onClose: () => void }) { + const { bot: centre, userId } = useMuse(); + const agents = useQuery(agentListQueryOptions()); + const attention = useQuery(botAttentionQueryOptions()); + const channels = useInfiniteQuery(channelListQueryOptions()); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const navigate = useNavigate(); + + const choose = (agentId: string) => { + if (userId) chooseBot(userId, agentId); + onClose(); + // On Chat, choosing a Bot means talking to it; elsewhere the tab stays and follows the Bot. + if (tabFor(pathname) !== "chat") return; + const main = mainChannel(agentId, channels.data); + void (main + ? navigate({ to: "/channel/$channelId", params: { channelId: main.id } }) + : navigate({ to: "/channel/new", search: { agent: agentId } })); + }; + + return ( + +
+ {agents.isPending ? null : agents.error ? ( + + ) : agents.data.length === 0 ? ( +

You don't have a Bot yet.

+ ) : ( +
+ {agents.data.map((agent) => { + const waiting = attention.data?.find( + (entry) => entry.agentId === agent.id, + ); + const asks = waiting ? needsInput(waiting) : 0; + const summary = waiting ? attentionSummary(waiting) : ""; + const selected = agent.id === centre?.id; + return ( + choose(agent.id)} type="button" /> + } + > + + + + {agent.name} + + + {asks + ? `Needs you · ${summary}` + : summary || agent.title || agent.roleDescription} + + + {selected ? ( + + ); + })} +
+ )} + +
+ {centre ? ( + + } + title={`${centre.name}'s page`} + /> + ) : null} + } + tint="plain" + title="All Bots" + /> + + } + tint="plain" + title="New Bot" + /> +
+
+
+ ); +} diff --git a/app/src/skins/openmuse/chosen-bot.ts b/app/src/skins/openmuse/chosen-bot.ts new file mode 100644 index 0000000000..8ae26c4e3e --- /dev/null +++ b/app/src/skins/openmuse/chosen-bot.ts @@ -0,0 +1,57 @@ +import { useSyncExternalStore } from "react"; + +/* + * Which Bot each person put at the centre of the frame. Kept in this browser, and kept per signed-in + * person: the key carries their user id, so the next person on a shared machine starts from OpenBot's + * own default Bot rather than the last person's choice. Choosing a Bot changes nothing on the server. + */ +export function chosenBotKey(userId: string): string { + return `openmuse-bot:${userId}`; +} + +const choices = new Map(); +const listeners = new Set<() => void>(); + +function read(userId: string): string | null { + if (!choices.has(userId)) { + let stored: string | null = null; + // localStorage throws in some privacy modes; the frame then starts from the default Bot. + try { + stored = window.localStorage.getItem(chosenBotKey(userId)); + } catch {} + choices.set(userId, stored); + } + return choices.get(userId) ?? null; +} + +export function chooseBot(userId: string, agentId: string) { + if (read(userId) === agentId) return; + choices.set(userId, agentId); + // The choice still holds for this visit when the browser refuses to store it. + try { + window.localStorage.setItem(chosenBotKey(userId), agentId); + } catch {} + for (const listener of listeners) listener(); +} + +/** Forgets every choice read into memory, as a new page load would. */ +export function forgetChosenBots() { + choices.clear(); + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** The Bot this person chose, or null before they choose one or while nobody is known yet. */ +export function useChosenBotId(userId: string | undefined): string | null { + return useSyncExternalStore( + subscribe, + () => (userId ? read(userId) : null), + () => null, + ); +} diff --git a/app/src/skins/openmuse/context.ts b/app/src/skins/openmuse/context.ts new file mode 100644 index 0000000000..4a365771a3 --- /dev/null +++ b/app/src/skins/openmuse/context.ts @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import type { AgentProfile } from "@/lib/agents/queries"; + +export type MuseSheetKind = "menu" | "notifications" | "bots"; + +export type MuseContextValue = { + /** The Bot at the centre of the frame, or undefined while the roster loads or when there is none. */ + bot: AgentProfile | undefined; + /** The signed-in person, whose choice of centre Bot this browser remembers. */ + userId: string | undefined; + openSheet: (sheet: MuseSheetKind) => void; +}; + +export const MuseContext = createContext(null); + +export function useMuse(): MuseContextValue { + const context = useContext(MuseContext); + if (!context) + throw new Error("useMuse must be used inside the skin's frame."); + return context; +} diff --git a/app/src/skins/openmuse/frame.tsx b/app/src/skins/openmuse/frame.tsx new file mode 100644 index 0000000000..5108ce9f4b --- /dev/null +++ b/app/src/skins/openmuse/frame.tsx @@ -0,0 +1,300 @@ +import { + IconBell, + IconBulb, + IconChevronDown, + IconMenu2, + IconMessageCircle, + IconSquareCheck, + IconTable, + IconTriangleSquareCircle, +} from "@tabler/icons-react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link, useParams, useRouterState } from "@tanstack/react-router"; +import { type ReactNode, useEffect, useState } from "react"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { BotsNavBadge } from "@/components/bot-profile/attention"; +import { SelfHostBanner } from "@/components/layout/self-host-banner"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { approvalInboxOptions } from "@/lib/approvals"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; +import { botAttentionQueryOptions } from "@/lib/bot-lifecycle/queries"; +import { + channelListQueryOptions, + channelQueryOptions, +} from "@/lib/channels/queries"; +import { useChannelEvents } from "@/lib/channels/use-channel-events"; +import { appConfig } from "@/lib/generated/application-config"; +import { cn } from "@/lib/utils"; +import { BotsSheet } from "./bots-sheet"; +import { chooseBot, useChosenBotId } from "./chosen-bot"; +import { MuseContext, type MuseSheetKind } from "./context"; +import { MainChatLink } from "./links"; +import { MenuSheet } from "./menu-sheet"; +import { + centreBot, + inColumn, + type MuseTab, + statusLine, + tabFor, + waitingOnYou, +} from "./model"; +import { NotificationsSheet } from "./notifications-sheet"; +import { type MuseIcon, RoundButton } from "./ui"; + +/** The deployment's own name, from its brand.yaml: the name OpenBot's sidebar shows. */ +const PRODUCT = appConfig.brand.productName; + +const TABS: { id: MuseTab; label: string; icon: MuseIcon }[] = [ + { id: "chat", label: "Chat", icon: IconMessageCircle }, + { id: "activity", label: "Activity", icon: IconTable }, + { id: "ideas", label: "Ideas", icon: IconBulb }, + { id: "goals", label: "Goals", icon: IconSquareCheck }, + { id: "apps", label: "Apps", icon: IconTriangleSquareCircle }, +]; + +/** + * OpenMuse's shell around every OpenBot page: one centred column, the agent at the top, a bell for + * what waits on you, and a floating pill of five tabs. The page itself is OpenBot's, untouched — + * conversations are OpenBot's own chat routes, rendered as `children`. + */ +export function OpenMuseFrame({ children }: { children: ReactNode }) { + // The roster's socket. OpenBot opens it in its sidebar, which this frame replaces. + useChannelEvents(); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const search = useRouterState({ + select: (state) => + state.location.search as { + agent?: unknown; + watch?: unknown; + settings?: unknown; + }, + }); + const addressed = search.agent; + const params = useParams({ strict: false }) as { + channelId?: string; + agentId?: string; + }; + const agents = useQuery(agentListQueryOptions()); + const channels = useInfiniteQuery(channelListQueryOptions()); + const conversation = useQuery({ + ...channelQueryOptions(params.channelId ?? ""), + enabled: Boolean(params.channelId), + }); + const attention = useQuery(botAttentionQueryOptions()); + const inbox = useQuery(approvalInboxOptions()); + const user = useQuery(currentUserQueryOptions()); + const userId = user.data?.id; + const chosenId = useChosenBotId(userId); + const [sheet, setSheet] = useState(null); + + /* + * The Bot this screen is about, when it is about one: the Bot of a one-to-one conversation, the Bot + * a new conversation is addressed to, or the Bot whose own page is open. + */ + const onScreenBot = + conversation.data && conversation.data.agentIds.length === 1 + ? conversation.data.agentIds[0] + : pathname === "/channel/new" && typeof addressed === "string" + ? addressed + : params.agentId; + const bot = centreBot(agents.data, chosenId, onScreenBot); + + // Talking to a Bot puts it at the centre, and it stays there after the conversation closes. + useEffect(() => { + if ( + userId && + onScreenBot && + agents.data?.some((agent) => agent.id === onScreenBot) + ) { + chooseBot(userId, onScreenBot); + } + }, [userId, onScreenBot, agents.data]); + + // A sheet belongs to the screen it was opened on. + // biome-ignore lint/correctness/useExhaustiveDependencies: closes on navigation, by design. + useEffect(() => { + setSheet(null); + }, [pathname]); + + const tab = tabFor(pathname); + // A conversation's computer or Bot settings, open beside the transcript. + const sidePanelOpen = search.watch === true || search.settings === true; + const column = inColumn(pathname, sidePanelOpen); + const waiting = waitingOnYou(inbox.data).count; + const status = statusLine({ + attention: attention.data?.find((entry) => entry.agentId === bot?.id), + channels: channels.data, + agentId: bot?.id, + }); + + return ( + +
+ +
+
+
+ setSheet("menu")} + > + {/* OpenBot's own "a Bot needs you" mark, worn as OpenMuse's dot. */} + + + + +
+
+ +
+
+ setSheet("notifications")} + > + {waiting > 0 ? ( + +
+
+
+ {/* + * A tab's own screen sits in OpenMuse's 760px column. Any other OpenBot page, and a conversation + * with its side panel open, takes OpenBot's own width on a wide screen; the bars stay centred. + */} +
+ {children} +
+
+ +
+
+ {sheet === "menu" ? setSheet(null)} /> : null} + {sheet === "notifications" ? ( + setSheet(null)} /> + ) : null} + {sheet === "bots" ? setSheet(null)} /> : null} +
+ ); +} + +/** Activity, Ideas and Goals are this skin's own screens; Apps is OpenBot's Connections. */ +function TabLink({ + tab, + label, + active, + className, + children, +}: { + tab: Exclude; + label: string; + active: boolean; + className: string; + children: ReactNode; +}) { + const shared = { + "aria-current": active ? ("page" as const) : undefined, + "aria-label": label, + className, + }; + return tab === "apps" ? ( + + {children} + + ) : ( + + {children} + + ); +} diff --git a/app/src/skins/openmuse/index.ts b/app/src/skins/openmuse/index.ts new file mode 100644 index 0000000000..68e170c51e --- /dev/null +++ b/app/src/skins/openmuse/index.ts @@ -0,0 +1,24 @@ +import type { Skin } from "../types"; +import { OpenMuseFrame } from "./frame"; +import { ActivityScreen } from "./screens/activity"; +import { GoalsScreen } from "./screens/goals"; +import { IdeasScreen } from "./screens/ideas"; + +/** + * OpenMuse's look: one Bot at the centre of a single phone-shaped column and five tabs in a floating + * pill. Its identity is the deployment's: the brand.yaml name and each Bot's own OpenBot avatar. + * Chat is OpenBot's own conversation, and Apps is OpenBot's own Connections page. Activity, Ideas + * and Goals put OpenBot's own sections for the centre Bot under OpenMuse's titles. + */ +export const openmuse: Skin = { + id: "openmuse", + name: "OpenMuse", + description: + "One Bot at the centre of a phone-shaped column, with five tabs.", + Frame: OpenMuseFrame, + screens: { + activity: ActivityScreen, + ideas: IdeasScreen, + goals: GoalsScreen, + }, +}; diff --git a/app/src/skins/openmuse/links.tsx b/app/src/skins/openmuse/links.tsx new file mode 100644 index 0000000000..71e7807a97 --- /dev/null +++ b/app/src/skins/openmuse/links.tsx @@ -0,0 +1,49 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type * as React from "react"; +import { channelListQueryOptions } from "@/lib/channels/queries"; +import { mainChannel } from "./model"; + +type AnchorProps = Omit, "href">; + +/** + * A link to a Bot's main chat: its one-to-one conversation when it has one, otherwise a new + * conversation already addressed to it. Both are OpenBot's own chat routes. + */ +export function MainChatLink({ + agentId, + ...props +}: AnchorProps & { agentId: string | undefined }) { + const channels = useInfiniteQuery(channelListQueryOptions()); + const main = mainChannel(agentId, channels.data); + if (main) { + return ( + + ); + } + return ( + + ); +} + +/** A new conversation with this Bot: OpenMuse's "new side chat". */ +export function NewChatLink({ + agentId, + ...props +}: AnchorProps & { agentId: string | undefined }) { + return ( + + ); +} diff --git a/app/src/skins/openmuse/menu-sheet.tsx b/app/src/skins/openmuse/menu-sheet.tsx new file mode 100644 index 0000000000..9f8b767aef --- /dev/null +++ b/app/src/skins/openmuse/menu-sheet.tsx @@ -0,0 +1,294 @@ +import { + IconHome, + IconLogout, + IconMessageCircle, + IconPin, + IconPinFilled, + IconPinnedOff, + IconPlus, + IconSearch, + IconTrash, + IconUsersGroup, +} from "@tabler/icons-react"; +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { + isUnread, + matchingChannels, + pinnedFirst, +} from "@/components/app-sidebar/app-sidebar"; +import { ChannelPagination } from "@/components/app-sidebar/channel-pagination"; +import { ChannelAvatar } from "@/components/channels/avatar"; +import { DeleteConversationDialog } from "@/components/channels/delete-conversation-dialog"; +import { Item, ItemContent, ItemTitle } from "@/components/ui/item"; +import { agentListQueryOptions } from "@/lib/agents/queries"; +import { signOutMutationOptions } from "@/lib/auth/mutations"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; +import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations"; +import { + type ChannelSummary, + channelListQueryOptions, +} from "@/lib/channels/queries"; +import { appConfig } from "@/lib/generated/application-config"; +import { useNavigationItems } from "@/lib/navigation"; +import { relativeTime } from "@/lib/relative-time"; +import { cn } from "@/lib/utils"; +import { queryClient } from "@/query-client"; +import { useMuse } from "./context"; +import { MainChatLink, NewChatLink } from "./links"; +import { + Divider, + ErrorNotice, + LinkRow, + MuseButton, + MuseLinkButton, + MuseSheet, + museText, +} from "./ui"; + +/** + * OpenMuse's menu: the main chat, a new chat, every conversation OpenBot keeps, and a way to every + * other OpenBot surface. Nothing here is OpenMuse's own data; each row opens an OpenBot route. + */ +export function MenuSheet({ onClose }: { onClose: () => void }) { + const { bot } = useMuse(); + const user = useQuery(currentUserQueryOptions()); + const channels = useInfiniteQuery(channelListQueryOptions()); + const navigate = useNavigate(); + const signOut = useMutation(signOutMutationOptions(queryClient)); + const navigation = useNavigationItems(); + const [search, setSearch] = useState(""); + const body = useRef(null); + const searching = search.trim().length > 0; + const shown = pinnedFirst(matchingChannels(channels.data, search)); + const botName = bot?.name ?? "your Bot"; + + return ( + +
+ } + title="Main chat" + /> + } + > + New chat + +
+

Conversations

+ } + small + > + New group + +
+ + {channels.isPending ? null : channels.error ? ( + + ) : shown.length === 0 ? ( +

+ {searching + ? `No conversation has “${search.trim()}” in its name or latest message.` + : `Start a conversation with ${botName} and it shows up here.`} +

+ ) : ( +
+ {shown.map((channel) => ( + + ))} +
+ )} + +

+ A conversation stays with the Bots in it. Pin one to keep it at the + top. +

+ +
+ } + title="Home" + /> + {/* OpenBot's own destinations, from the one list its sidebar draws. */} + {navigation.map((item) => ( + } + tint={item.section === "account" ? "plain" : "sky"} + title={item.label} + /> + ))} +
+ { + await signOut.mutateAsync(); + await navigate({ to: "/sign" }); + }} + small + > + Sign out + + +
+
+ ); +} + +/** + * One conversation, laid out as this look lists a side chat: its Bots' faces and title, then its actions as pills. + * Pin and delete are the same two OpenBot offers on its sidebar row. + */ +function ConversationRow({ + channel, + onOpen, +}: { + channel: ChannelSummary; + onOpen: () => void; +}) { + const agents = useQuery(agentListQueryOptions()); + const navigate = useNavigate(); + const openId = useParams({ + strict: false, + select: (params) => (params as { channelId?: string }).channelId, + }); + const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient)); + const [confirming, setConfirming] = useState(false); + const group = channel.agentIds.length > 1; + const unread = isUnread(channel, openId); + const busy = channel.busy || (channel.work?.length ?? 0) > 0; + const title = + channel.summary?.trim() || + channel.lastMessage?.trim() || + "New conversation"; + const who = group + ? channel.agentIds + .map((id) => agents.data?.find((agent) => agent.id === id)?.name) + .filter(Boolean) + .join(", ") || channel.name + : channel.name; + const when = channel.lastMessageAt ? relativeTime(channel.lastMessageAt) : ""; + + return ( +
+ + } + > + {/* The same faces OpenBot's own conversation list draws: the Bot, or the group's Bots. */} + + + + + + {title} + + + + {who} + {when ? ` · ${when}` : ""} + + {busy ? ( + + · working + + ) : null} + + + {unread ? ( + + ) : null} + {channel.pinned ? ( + + ) : null} + +
+ + setPinned.mutate({ channelId: channel.id, pinned: !channel.pinned }) + } + small + > + {channel.pinned ? "Unpin" : "Pin"} + + setConfirming(true)} small> + Delete + +
+ + navigate({ to: "/" }) : undefined} + name={channel.name} + onOpenChange={setConfirming} + open={confirming} + /> +
+ ); +} diff --git a/app/src/skins/openmuse/model.ts b/app/src/skins/openmuse/model.ts new file mode 100644 index 0000000000..74e477c8c7 --- /dev/null +++ b/app/src/skins/openmuse/model.ts @@ -0,0 +1,132 @@ +import { defaultAgentProfile } from "@/lib/agents/default-agent"; +import type { AgentProfile } from "@/lib/agents/queries"; +import type { ApprovalInboxData } from "@/lib/approvals"; +import type { BotAttention } from "@/lib/bot-lifecycle/queries"; +import type { ChannelSummary } from "@/lib/channels/queries"; + +/* + * The rules OpenMuse's frame runs on, kept apart from the components so each one is a plain function + * of OpenBot's own data. None of them decides anything the server already decided: they only choose + * which of OpenBot's records to put where. + */ + +export type MuseTab = "chat" | "activity" | "ideas" | "goals" | "apps"; + +/** + * The Bot at the centre of the frame. + * + * OpenMuse is one agent. OpenBot has many, so the centre follows, in order: the Bot of the + * conversation on screen, the Bot the person last chose, and OpenBot's own default Bot — the same + * `defaultAgentProfile` the home screen and `/bot` fall back to. + */ +export function centreBot( + agents: readonly AgentProfile[] | undefined, + chosenId: string | null, + conversationBotId: string | undefined, +): AgentProfile | undefined { + if (!agents?.length) return undefined; + return ( + agents.find((agent) => agent.id === conversationBotId) ?? + agents.find((agent) => agent.id === chosenId) ?? + defaultAgentProfile(agents) + ); +} + +/** + * A Bot's main chat: its one-to-one conversation the roster already lists first. + * + * The roster arrives pinned first and newest first, so the first one-to-one channel with this Bot is + * the one the person is most likely to mean. A group is never a Bot's main chat. + */ +export function mainChannel( + agentId: string | undefined, + channels: readonly ChannelSummary[] | undefined, +): ChannelSummary | undefined { + if (!agentId) return undefined; + return channels?.find( + (channel) => + channel.active !== false && + channel.agentIds.length === 1 && + channel.agentIds[0] === agentId, + ); +} + +/** + * The tab whose own screen this path is, or null on any other OpenBot page. Chat is the conversation + * routes; Apps is OpenBot's Connections, the apps a Bot uses as you. A Bot's page, Routines, + * Capabilities and the rest light no tab, because they are not that tab's screen. + */ +export function tabFor(pathname: string): MuseTab | null { + if (pathname === "/s/activity") return "activity"; + if (pathname === "/s/ideas") return "ideas"; + if (pathname === "/s/goals") return "goals"; + if (pathname === "/connections" || pathname.startsWith("/connections/")) { + return "apps"; + } + if ( + pathname === "/" || + pathname === "/channel" || + pathname.startsWith("/channel/") || + pathname.startsWith("/group/") + ) { + return "chat"; + } + return null; +} + +/** + * Whether the page stays in OpenMuse's 760px column on a wide screen. A tab's own screen does. Every + * other OpenBot page, and a conversation with its Bot's computer or settings open beside it, gets + * OpenBot's own width, so its wide layouts and side panels have the room they were built for. + */ +export function inColumn(pathname: string, sidePanelOpen: boolean): boolean { + return tabFor(pathname) !== null && !sidePanelOpen; +} + +const plural = (count: number, word: string) => + `${count} ${word}${count === 1 ? "" : "s"}`; + +/** + * The line under the Bot's name: what it is doing right now, in OpenMuse's own words. + * + * Something waiting on the person outranks work in progress, because it is the one thing on this + * line they can act on. Work comes from the roster the channel socket keeps live, so the line + * changes while a turn runs without anybody refreshing. + */ +export function statusLine(input: { + attention: BotAttention | undefined; + channels: readonly ChannelSummary[] | undefined; + agentId: string | undefined; +}): string { + const { attention, channels, agentId } = input; + if (attention?.approvals) { + return `Ready to review · ${plural(attention.approvals, "approval")}`; + } + if (attention?.questions) { + return `Needs your input · ${plural(attention.questions, "question")}`; + } + if (attention?.handoffs) return "Waiting on a hand-off"; + const working = agentId + ? channels?.find( + (channel) => + channel.agentIds.includes(agentId) && + (channel.busy || (channel.work?.length ?? 0) > 0), + ) + : undefined; + if (working) { + const routine = working.work?.find((work) => work.kind === "routine"); + if (routine && !working.busy) return "Running a routine…"; + return `Working on ${working.summary?.trim() || working.name}…`; + } + if (attention?.paused) return "Paused"; + return "Ready when you are"; +} + +/** Approvals and questions the person has not answered yet, across every Bot. */ +export function waitingOnYou(inbox: ApprovalInboxData | undefined) { + const requests = (inbox?.requests ?? []).filter( + (request) => request.status === "pending", + ); + const questions = inbox?.questions ?? []; + return { requests, questions, count: requests.length + questions.length }; +} diff --git a/app/src/skins/openmuse/notifications-sheet.tsx b/app/src/skins/openmuse/notifications-sheet.tsx new file mode 100644 index 0000000000..e63d9e1205 --- /dev/null +++ b/app/src/skins/openmuse/notifications-sheet.tsx @@ -0,0 +1,43 @@ +import { IconBell, IconHandStop } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { WaitingForYou } from "@/components/approvals/waiting"; +import { approvalInboxOptions } from "@/lib/approvals"; +import { waitingOnYou } from "./model"; +import { LinkRow, MuseEmpty, MuseSheet } from "./ui"; + +/** + * The bell: what every Bot is waiting on the person for. The list is OpenBot's own `WaitingForYou`, + * so every question, approval, choice and detail is exactly the one OpenBot's Bot pages show. + */ +export function NotificationsSheet({ onClose }: { onClose: () => void }) { + const inbox = useQuery(approvalInboxOptions()); + const nothing = + inbox.data !== undefined && waitingOnYou(inbox.data).count === 0; + return ( + +
+ {nothing ? ( + + ) : ( + + )} + } + tint="lavender" + title="Approval rules" + /> +
+
+ ); +} diff --git a/app/src/skins/openmuse/screens/activity.tsx b/app/src/skins/openmuse/screens/activity.tsx new file mode 100644 index 0000000000..43b4c2bc72 --- /dev/null +++ b/app/src/skins/openmuse/screens/activity.tsx @@ -0,0 +1,35 @@ +import { IconListCheck } from "@tabler/icons-react"; +import { BotNeedsYou } from "@/components/approvals/waiting"; +import { BotActivitySections } from "@/components/bot-profile/activity"; +import { useMuse } from "../context"; +import { MuseEmpty, MuseScreen } from "../ui"; + +/** + * Activity: what the centre Bot is waiting on you for, then what it is doing, has scheduled and has + * finished. Both are OpenBot's own sections from the Bot's page, under OpenMuse's title. + */ +export function ActivityScreen() { + const { bot } = useMuse(); + return ( + + {bot ? ( +
+ + +
+ ) : ( + + )} +
+ ); +} + +export function NoBot() { + return ( + + ); +} diff --git a/app/src/skins/openmuse/screens/goals.tsx b/app/src/skins/openmuse/screens/goals.tsx new file mode 100644 index 0000000000..24308f68ba --- /dev/null +++ b/app/src/skins/openmuse/screens/goals.tsx @@ -0,0 +1,44 @@ +import { BotResponsibilities } from "@/components/responsibilities/responsibilities"; +import { RoutinesList } from "@/components/routines/routines-list"; +import { cn } from "@/lib/utils"; +import { useMuse } from "../context"; +import { Divider, MuseScreen, museText, StatusDot } from "../ui"; +import { NoBot } from "./activity"; + +/** + * Goals: what the centre Bot keeps doing for the person. OpenMuse's "Tracking" is OpenBot's routines, + * and its "Goals" are OpenBot's responsibilities, which OpenBot itself titles Goals on a Bot's page. + * Both lists are OpenBot's own, with every switch, Run now, history and Delete they offer. + */ +export function GoalsScreen() { + const { bot } = useMuse(); + if (!bot) { + return ( + + + + ); + } + return ( + +
+
+ +

+ Routines +

+
+ +
+ +
+ +
+
+ ); +} diff --git a/app/src/skins/openmuse/screens/ideas.tsx b/app/src/skins/openmuse/screens/ideas.tsx new file mode 100644 index 0000000000..09f88b5ae9 --- /dev/null +++ b/app/src/skins/openmuse/screens/ideas.tsx @@ -0,0 +1,46 @@ +import { IconBulb } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { + ProactiveResearchSettings, + SuggestionsInbox, +} from "@/components/suggestions/proactive-panel"; +import { proactiveSuggestionsQueryOptions } from "@/lib/proactive"; +import { useMuse } from "../context"; +import { MuseEmpty, MuseScreen } from "../ui"; +import { NoBot } from "./activity"; + +/** + * Ideas: the next steps the centre Bot's background research suggested, and the research itself. + * Both are OpenBot's own sections, so starting, dismissing and running research behave as on the + * Bot's page. + */ +export function IdeasScreen() { + const { bot } = useMuse(); + const suggestions = useQuery(proactiveSuggestionsQueryOptions()); + if (!bot) { + return ( + + + + ); + } + const none = + suggestions.data !== undefined && + !suggestions.data.some((suggestion) => suggestion.agentId === bot.id); + return ( + +
+ {none ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/app/src/skins/openmuse/skin.css b/app/src/skins/openmuse/skin.css new file mode 100644 index 0000000000..81720ecfbf --- /dev/null +++ b/app/src/skins/openmuse/skin.css @@ -0,0 +1,338 @@ +/* + * OpenMuse. Light is OpenMuse's own palette from its `ui.tsx`: a near-white canvas, white cards, a + * pale sky blue for the primary action and the person's own words, and soft pastel tiles. OpenMuse + * ships no dark theme, so dark is drawn in its spirit: the same airy shapes at night, a deep slate + * canvas with a blue cast, cards one step up, and the sky blue deepened into a night blue so the + * person's bubble and the primary pill still read as the one warm accent. + * + * Every token is set for both. A skin's `:root[data-skin]` outranks OpenBot's `.dark`, so a token set + * only for light would leak into dark. + */ + +:root[data-skin="openmuse"] { + color-scheme: light; + --background: #fcfcfc; + --foreground: #11191c; + --card: #ffffff; + --card-foreground: #11191c; + --popover: #ffffff; + --popover-foreground: #11191c; + --primary: #c8e7ff; + --primary-foreground: #11191c; + --secondary: #f1f2f3; + --secondary-foreground: #11191c; + --muted: #f0f1f2; + --muted-foreground: #697176; + --accent: #edf7fd; + --accent-foreground: #11191c; + --destructive: #aa4a45; + --success: #189a58; + --border: #eeeef0; + --input: #e6e8ea; + --ring: #7fbde9; + --chart-1: #1473c8; + --chart-2: #6aaee0; + --chart-3: #24a46b; + --chart-4: #6866a6; + --chart-5: #e59a2b; + --radius: 1rem; + --sidebar: #f6f7f8; + --sidebar-foreground: #11191c; + --sidebar-primary: #1473c8; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #edeef0; + --sidebar-accent-foreground: #11191c; + --sidebar-border: #eeeef0; + --sidebar-ring: #7fbde9; + + --openmuse-font: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; + --font-sans: var(--openmuse-font); + --openmuse-accent: #1473c8; + --openmuse-blue: #c8e7ff; + --openmuse-sky: #edf7fd; + --openmuse-green: #e3f3e8; + --openmuse-lavender: #f0eefa; + --openmuse-orange: #fdf0df; + --openmuse-chip: #f3f4f5; + --openmuse-danger-tint: #fbefed; + --openmuse-pressed: #e9eaec; + --openmuse-primary-hover: #b5ddfb; + --openmuse-task: #f0f1f2; + --openmuse-tab-active: #f0f1f2; + --openmuse-tab-hover: #f7f8f8; + --openmuse-pill: #ffffff; + --openmuse-pill-border: #f8f8f8; + --openmuse-pill-shadow: rgba(19, 38, 49, 0.07); + --openmuse-scrim: rgba(35, 48, 44, 0.25); + --openmuse-handle: #d8dbde; + --openmuse-group: #f3f4f5; + --openmuse-group-line: #e5e7e9; + --openmuse-group-soft: #f4f5f6; + --openmuse-quiet: #a3a6a8; + --openmuse-quiet-text: #666a6d; + --openmuse-green-dot: #24a46b; + --openmuse-green-ring: #d9f1e2; + --openmuse-green-text: #189a58; + --openmuse-blue-dot: #3d9bde; + --openmuse-blue-ring: #d7e9fa; + --openmuse-amber: #e59a2b; + --openmuse-focus: #c7e4f9; + --openmuse-user-bubble: #c8e7ff; + --openmuse-bot-bubble: #eeeef0; + --openmuse-composer: #ffffff; + --openmuse-composer-border: #eef0f2; + --openmuse-composer-shadow: rgba(24, 56, 75, 0.06); + --openmuse-send-idle: #f3f5f6; +} + +:root[data-skin="openmuse"].dark { + color-scheme: dark; + --background: #0e1316; + --foreground: #e8edef; + --card: #161c20; + --card-foreground: #e8edef; + --popover: #161c20; + --popover-foreground: #e8edef; + --primary: #1f4766; + --primary-foreground: #dcefff; + --secondary: #1d2428; + --secondary-foreground: #e8edef; + --muted: #1b2226; + --muted-foreground: #8d989e; + --accent: #142530; + --accent-foreground: #e8edef; + --destructive: #e07a73; + --success: #4cc38a; + --border: #232b30; + --input: #2a3338; + --ring: #3f7fae; + --chart-1: #6fb6f0; + --chart-2: #3d9bde; + --chart-3: #4cc38a; + --chart-4: #a19fe0; + --chart-5: #f0b45a; + --radius: 1rem; + --sidebar: #12181b; + --sidebar-foreground: #e8edef; + --sidebar-primary: #6fb6f0; + --sidebar-primary-foreground: #0e1316; + --sidebar-accent: #1d2428; + --sidebar-accent-foreground: #e8edef; + --sidebar-border: #232b30; + --sidebar-ring: #3f7fae; + + --openmuse-font: + -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; + --font-sans: var(--openmuse-font); + --openmuse-accent: #6fb6f0; + --openmuse-blue: #2a5576; + --openmuse-sky: #13242e; + --openmuse-green: #142a1f; + --openmuse-lavender: #1f1d33; + --openmuse-orange: #2d2215; + --openmuse-chip: #1f272b; + --openmuse-danger-tint: #2e1716; + --openmuse-pressed: #232b30; + --openmuse-primary-hover: #275678; + --openmuse-task: #182025; + --openmuse-tab-active: #232c31; + --openmuse-tab-hover: #1b2226; + --openmuse-pill: #161c20; + --openmuse-pill-border: #222a2f; + --openmuse-pill-shadow: rgba(0, 0, 0, 0.45); + --openmuse-scrim: rgba(4, 8, 10, 0.6); + --openmuse-handle: #3a444a; + --openmuse-group: #161c20; + --openmuse-group-line: #232b30; + --openmuse-group-soft: #161c20; + --openmuse-quiet: #5f6a70; + --openmuse-quiet-text: #a3adb2; + --openmuse-green-dot: #4cc38a; + --openmuse-green-ring: #173827; + --openmuse-green-text: #5fd39b; + --openmuse-blue-dot: #5aaee8; + --openmuse-blue-ring: #17344a; + --openmuse-amber: #f0b45a; + --openmuse-focus: #2f6a95; + --openmuse-user-bubble: #1f4766; + --openmuse-bot-bubble: #1b2226; + --openmuse-composer: #161c20; + --openmuse-composer-border: #242d32; + --openmuse-composer-shadow: rgba(0, 0, 0, 0.35); + --openmuse-send-idle: #1f272b; +} + +/* + * OpenMuse is set in the device's own system font. `--font-sans` above is the token OpenBot's + * headings read; the page itself takes it here, because OpenBot names Inter on `html` directly. + */ +:root[data-skin="openmuse"] { + font-family: var(--font-sans); +} + +/* + * OpenBot's own pages, worn the OpenMuse way. Every rule below reaches OpenBot's components only + * through the `data-slot` hooks they publish for this, so restyling a component inside OpenBot cannot + * silently break the skin. + */ + +/* OpenMuse's buttons are pills. */ +:root[data-skin="openmuse"] [data-slot="button"] { + border-radius: 9999px; +} + +/* Page titles at OpenMuse's 25px, semibold and tight. */ +:root[data-skin="openmuse"] .openmuse-stage [data-slot="page-title"] { + font-size: 25px; + font-weight: 600; + letter-spacing: -0.7px; +} + +/* OpenBot's "a Bot needs you" badge, drawn as OpenMuse's small blue dot on the menu button. */ +:root[data-skin="openmuse"] .openmuse-menu-badge [role="img"] { + position: absolute; + top: 7px; + right: 9px; + width: 6px; + height: 6px; + min-width: 0; + margin: 0; + padding: 0; + border-radius: 9999px; + font-size: 0; + line-height: 0; + background: var(--openmuse-accent); + color: transparent; +} + +/* Chat sits in OpenMuse's padded column: 17px on a phone, 42px on a desktop. */ +:root[data-skin="openmuse"] + .openmuse-frame[data-openmuse-tab="chat"] + .openmuse-stage { + padding-inline: 17px; +} + +@media (min-width: 900px) { + :root[data-skin="openmuse"] + .openmuse-frame[data-openmuse-tab="chat"] + .openmuse-stage { + padding-inline: 42px; + } +} + +:root[data-skin="openmuse"] + .openmuse-stage + [data-slot="message-scroller-content"] { + padding-inline: 0; + gap: 13px; +} + +/* A conversation's own header row carries no rule beneath it; the frame above already separates. */ +:root[data-skin="openmuse"] .openmuse-stage [data-slot="conversation-header"] { + border-bottom-color: transparent; + padding-inline: 0; +} + +/* With the computer or settings beside it on a wide screen, the conversation takes OpenBot's own layout. */ +@media (min-width: 1100px) { + :root[data-skin="openmuse"] + .openmuse-frame[data-openmuse-tab="chat"] + .openmuse-stage[data-openmuse-width="page"] { + padding-inline: 0; + } + + :root[data-skin="openmuse"] + .openmuse-stage[data-openmuse-width="page"] + [data-slot="conversation-header"] { + padding-inline: 12px; + } +} + +/* The person's words: OpenMuse's pale-blue bubble with its tucked corner. */ +:root[data-skin="openmuse"] + .openmuse-stage + [data-slot="bubble"][data-align="end"] + > [data-slot="bubble-content"] { + background: var(--openmuse-user-bubble); + color: var(--foreground); + border-radius: 22px 22px 7px 22px; + padding: 13px 16px; + font-size: 16px; + line-height: 24px; +} + +/* The Bot's words: OpenMuse's soft grey bubble, tucked on the other side. */ +:root[data-skin="openmuse"] + .openmuse-stage + [data-slot="bubble"][data-variant="ghost"][data-align="start"] + > [data-slot="bubble-content"] { + background: var(--openmuse-bot-bubble); + border-radius: 22px 22px 22px 7px; + padding: 13px 16px; + font-size: 15px; + line-height: 23px; +} + +/* The composer: OpenMuse's white pill with a soft shadow, a "+" on the left and a round send. */ +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer"] { + min-height: 62px; + padding: 8px; + border: 1px solid var(--openmuse-composer-border); + border-radius: 32px; + background: var(--openmuse-composer); + box-shadow: 0 4px 20px var(--openmuse-composer-shadow); +} + +:root[data-skin="openmuse"] + .openmuse-stage + [data-slot="composer"]:focus-within { + border-color: var(--openmuse-focus); + box-shadow: 0 4px 20px var(--openmuse-composer-shadow); +} + +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-input"] > * { + font-size: 17px; + line-height: 24px; +} + +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-attach"], +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-send"], +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-stop"] { + width: 44px; + height: 44px; + border-radius: 9999px; + align-self: flex-end; +} + +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-attach"] svg { + width: 24px; + height: 24px; + stroke-width: 1.5; +} + +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-send"], +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-stop"] { + background: var(--primary); + color: var(--primary-foreground); +} + +:root[data-skin="openmuse"] + .openmuse-stage + [data-slot="composer-send"]:disabled { + background: var(--openmuse-send-idle); + color: var(--openmuse-quiet); + opacity: 1; +} + +:root[data-skin="openmuse"] .openmuse-stage [data-slot="composer-send"] svg { + width: 22px; + height: 22px; +} + +/* A row that opens something reads from its start, as every other row does. */ +:root[data-skin="openmuse"] .openmuse-stage button[data-slot="item"] { + text-align: start; +} diff --git a/app/src/skins/openmuse/ui.tsx b/app/src/skins/openmuse/ui.tsx new file mode 100644 index 0000000000..a32e672d1b --- /dev/null +++ b/app/src/skins/openmuse/ui.tsx @@ -0,0 +1,364 @@ +import { IconChevronRight, type IconProps, IconX } from "@tabler/icons-react"; +import * as React from "react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { cn } from "@/lib/utils"; + +/* + * OpenMuse's primitives, rebuilt on OpenBot's own. Each one is an OpenBot component — Button, Item, + * Dialog — wearing OpenMuse's measurements: 23px cards, pill buttons, 42px icon tiles, a 44px round + * icon button, and the 15/14/11px type steps from its stylesheet. Colours come from the skin's tokens, + * so dark mode follows without a branch here. + */ + +export type MuseIcon = React.ComponentType; + +export const museText = { + body: "text-[15px] leading-[23px] text-foreground", + muted: "text-[14px] leading-[21px] text-muted-foreground", + small: "text-[11px] leading-[17px] text-muted-foreground", + heading: "text-[16px] font-semibold tracking-[-0.25px] text-foreground", + title: "text-[23px] font-semibold tracking-[-0.7px] text-foreground", +} as const; + +type MuseButtonLook = { primary?: boolean; small?: boolean; danger?: boolean }; + +/** OpenMuse's pill: pale blue when it is the one thing to do, soft grey otherwise. */ +export function museButtonClass({ + primary = false, + small = false, + danger = false, +}: MuseButtonLook = {}) { + return cn( + buttonVariants({ variant: "secondary" }), + "h-auto min-h-[42px] gap-2 rounded-full border-0 px-[17px] py-2.5 font-semibold text-[14px] shadow-none active:translate-y-0 active:scale-[0.98] motion-reduce:transition-none motion-reduce:active:scale-100 [&_svg]:size-[15px]", + primary + ? "bg-primary text-primary-foreground hover:bg-[var(--openmuse-primary-hover)]" + : "bg-secondary text-secondary-foreground hover:bg-[var(--openmuse-pressed)]", + danger && "text-destructive", + small && "min-h-[38px] px-[13px] py-[7px]", + ); +} + +export function MuseButton({ + children, + className, + icon: Icon, + primary, + small, + danger, + ...props +}: React.ComponentProps & + MuseButtonLook & { + icon?: MuseIcon; + }) { + return ( + + ); +} + +/** + * A pill that goes somewhere. Base UI's Button gives an anchor the button role, so a destination is + * an anchor of its own wearing the same pill, and keeps its link role. + */ +export function MuseLinkButton({ + children, + className, + icon: Icon, + primary, + small, + danger, + render, +}: MuseButtonLook & { + children: React.ReactNode; + className?: string; + icon?: MuseIcon; + render: React.ReactElement<{ + className?: string; + children?: React.ReactNode; + }>; +}) { + return React.cloneElement(render, { + className: cn(museButtonClass({ primary, small, danger }), className), + children: ( + <> + {Icon ?