Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/skills/openbot-screen-layout/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ PageShell the page: header (title, description, action, backButton) then
PageEmpty what a section says when it has nothing to list
```

`PageShell` takes a `width`: `prose` (`max-w-2xl`) is the default because configuration is mostly
reading, and a row of label-and-control has no business being wider than the sentence explaining it.
`PageShell` takes a `width`: `prose` (`max-w-3xl`, 720px here) is the default, and every
configuration screen uses it so no screen is wider than another. Sentences inside it are held to
`max-w-prose`, so the column has room for a label beside its control without stretching the text.
`wide` (`max-w-5xl`) exists for one screen — `admin/audit.tsx`, because an audit log is a table to be
scanned and prose width would wrap every row. It is not a licence for anything else to be wide.

Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,31 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's
team account is kept, with who answered it and when. It starts empty and nothing is backfilled.

### Every settings screen is the same, slightly wider column

Settings, Admin, Connections, Capabilities and each Bot's pages share one column width, now 720px instead of 630px, so
no screen is wider than another and a row has room for its label beside its control. Sentences keep
their comfortable reading width inside it. The audit log stays wider, because it is a table.

### Capabilities and Connections sit beside Bots in the sidebar

The sidebar's Skills entry becomes two, below Bots: **Capabilities** (`/capabilities`), with Skills
(`/capabilities/skills`) and Memory (`/capabilities/memory`) as tabs under its title, Memory leaving
Settings; and **Connections** (`/connections`), the apps your Bots use as you, which leaves Settings
too. On the home page, Explore agents shows each Bot as a white card with its avatar on top, the same
card as elsewhere. The old
addresses — `/skills`, `/settings/connected-accounts` with any account beneath it, and
`/settings/memory` — redirect to the new ones, keeping `?connected=`, `?linked=` and a skill being
written or edited, so a consent begun before upgrading still lands on its page. OAuth, GitHub linking
and the Slack link confirmation now send people to `/connections`.

Connections is one searchable list. Apps you connect, apps on your organisation's account (marked
"Team account") and your GitHub and Slack links are rows of the same kind, with those already in use
first, and each opens its own page. GitHub's page holds your link, the repositories whose events can
start a responsibility (formerly "GitHub events"), and a way to a Composio GitHub app where one is
enabled, so GitHub is listed once; linking GitHub now comes back to that page. The reset for what
you let shared Bots use moves to Settings → Approvals, as **Shared Bots**.

### Each app on a Bot says whether you can use it, and you can ask an administrator for the rest

A Bot's page now says, for every work app the Bot holds, what that app is for the person looking:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,14 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
| `/bots/:id/reach` | Continue a conversation with the Bot in Slack, Microsoft Teams, or by text message. |
| `/bots/:id/memory` | Which connected apps feed the Bot facts, and its background research. |
| `/bots/:id/approvals` | What the Bot may do without asking you first. |
| `/skills` | Create and enable personal skills. |
| `/connections` | Connect your apps so your Bots can work with them. |
| `/capabilities/skills` | Create and enable personal skills. |
| `/capabilities/memory` | What your Bots remember about you, and facts you tell them. |
| `/routines` | See the routines that are standing, and stop one. |
| `/settings` | User preferences. |
| `/settings/connected-accounts` | Connect your apps so your Bots can work with them. |
| `/settings/passwords` | Logins you saved when signing a Bot in to a website. |
| `/settings/notifications` | Where updates go, your devices, and recent deliveries. |
| `/settings/approvals` | Whether your Bots ask before acting, and rules for every Bot. |
| `/settings/memory` | What your Bots remember about you, and facts you tell them. |
| `/admin/credentials` | Store write-only encrypted credentials. |
| `/admin/approvals` | Team approval settings, team rules, and shared account requests. |
| `/admin/computers` | View, stop, and reset Bot computers. |
Expand Down
27 changes: 14 additions & 13 deletions app/src/components/agents/agent-card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { IconArrowRight } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import Avatar from "boring-avatars";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
import { Button } from "@/components/ui/button";
import type { AgentProfile } from "@/lib/agents/queries";
Expand All @@ -10,21 +9,23 @@ export function AgentCard({
appearance = "compact",
}: {
agent: AgentProfile;
appearance?: "compact" | "artwork";
appearance?: "compact" | "tile";
}) {
if (appearance === "artwork") {
if (appearance === "tile") {
return (
<div className="relative h-[180px] w-[144px] overflow-hidden rounded-2xl bg-foreground/10">
<div
aria-hidden="true"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<Avatar name={agent.avatarSeed} size={250} />
<div className="flex h-[180px] w-[144px] flex-col overflow-hidden rounded-2xl border border-border bg-card p-3 transition-colors hover:border-ring dark:border-transparent">
<div aria-hidden="true" className="flex shrink-0">
<AbstractAvatar name={agent.name} seed={agent.avatarSeed} size={36} />
</div>
<div className="absolute top-0 left-0 h-full w-full bg-background/40 dark:bg-background/50" />
<div className="absolute top-0 left-0 flex h-full w-full flex-col justify-end gap-2 p-3">
<span className="line-clamp-1 text-sm font-medium">{agent.name}</span>
<span className="line-clamp-3 text-xs text-foreground dark:text-foreground/80">
{/* Bottom-aligned, where the text sat on the earlier artwork card; the avatar holds the top. */}
<div className="mt-auto flex min-w-0 flex-col gap-2">
<span
className="line-clamp-1 break-words text-sm font-medium leading-5"
title={agent.name}
>
{agent.name}
</span>
<span className="line-clamp-3 break-words text-xs leading-4 text-muted-foreground">
{agent.roleDescription}
</span>
</div>
Expand Down
30 changes: 27 additions & 3 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
IconBolt,
IconBox,
IconLogout,
IconPlug,
IconPlus,
IconSearch,
IconSettings,
Expand Down Expand Up @@ -366,14 +367,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<BotsNavBadge />
</SidebarMenuButton>
</SidebarMenuItem>
{/*
* 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.
*/}
<SidebarMenuItem>
{/* Beside Bots rather than inside Admin: writing a skill is something anybody does. */}
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/skills"
to="/capabilities"
activeProps={{
className: "bg-foreground/5",
}}
Expand All @@ -383,7 +388,26 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="size-[28px] flex items-center justify-center">
<IconBox />
</div>
<span className="text-sm trackint-tight">Skills</span>
<span className="text-sm trackint-tight">Capabilities</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="hover:bg-foreground/5 h-10"
render={(props) => (
<Link
{...props}
to="/connections"
activeProps={{
className: "bg-foreground/5",
}}
/>
)}
>
<div className="size-[28px] flex items-center justify-center">
<IconPlug />
</div>
<span className="text-sm trackint-tight">Connections</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
Expand Down
5 changes: 4 additions & 1 deletion app/src/components/bot-profile/access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ function YourSkills({ agentId }: { agentId: string }) {
) : skills.length === 0 ? (
<PageEmpty>
You have not written a skill yet.{" "}
<Link className="underline underline-offset-4" to="/skills">
<Link
className="underline underline-offset-4"
to="/capabilities/skills"
>
Write one
</Link>
.
Expand Down
60 changes: 60 additions & 0 deletions app/src/components/capabilities/capabilities-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Link, type LinkOptions, useMatchRoute } from "@tanstack/react-router";
import type * as React from "react";
import { PageShell } from "@/components/layout/page-shell";
import { Button } from "@/components/ui/button";

/**
* What a person gives their own Bots beyond the apps they connect: the skills they write and what
* their Bots remember about them. Connections, the apps a Bot reaches as them, is its own page.
*
* Skills used to sit beside Bots and Memory in Settings, though both answer the same question: what
* do my Bots know how to do, and what do they know about me?
*/
const TABS = [
{ label: "Skills", linkOptions: { to: "/capabilities/skills" } },
{ label: "Memory", linkOptions: { to: "/capabilities/memory" } },
] as const satisfies readonly { label: string; linkOptions: LinkOptions }[];

/**
* The tabs are links drawn as buttons. Each page keeps its own address, so it can be bookmarked,
* opened in a new tab and reached by Back. The match is fuzzy so a tab stays lit on a page beneath
* it.
*/
export function CapabilitiesTabs() {
const matchRoute = useMatchRoute();
return (
<nav aria-label="Capabilities" className="mt-6 flex items-center gap-1">
{TABS.map((tab) => (
<Button
key={tab.label}
render={(props) => <Link {...tab.linkOptions} {...props} />}
variant={
matchRoute({ ...tab.linkOptions, fuzzy: true })
? "secondary"
: "ghost"
}
>
{tab.label}
</Button>
))}
</nav>
);
}

/**
* The frame each tab draws itself in: the Capabilities title with the tabs beneath it, and no
* description, since every section beneath them has its own title and says what it is for.
*
* A component each page renders rather than the `/capabilities` layout route drawing it once,
* because Skills wraps its whole page — heading included — in the side panel that writes and edits a
* skill. A heading drawn by the layout would sit outside that panel and stay full width while it is
* open.
*/
export function CapabilitiesShell({ children }: { children: React.ReactNode }) {
return (
<PageShell className="@container" title="Capabilities">
<CapabilitiesTabs />
{children}
</PageShell>
);
}
2 changes: 1 addition & 1 deletion app/src/components/channels/connect-app-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function ConnectAppCard({
<Link
className="underline underline-offset-2"
params={{ key: serverId }}
to="/settings/connected-accounts/$key"
to="/connections/$key"
>
Enter it on {title}'s page
</Link>
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/computer/demonstration-recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ export function DemonstrationRecorder({
{recording.status === "published" && recording.skillSlug ? (
<>
<Link
to="/skills"
to="/capabilities/skills"
search={{ edit: recording.skillSlug }}
className="text-xs underline"
>
Expand Down
144 changes: 144 additions & 0 deletions app/src/components/connections/identity-connection-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { IconBrandGithub, IconChevronRight } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import {
PageRows,
PageSection,
PageShell,
} from "@/components/layout/page-shell";
import { GithubSources } from "@/components/settings/github-sources";
import {
type LinkableProvider,
LinkedAccountSection,
linkableProviderTitle,
} from "@/components/settings/linked-accounts";
import {
linkedNotice,
visibleLinkedNotice,
} from "@/components/settings/linked-notice";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { IDENTITY_SUMMARIES, isBrokeredGithub } from "@/lib/connections/rows";
import { linkedAccountsQueryOptions } from "@/lib/identity/queries";
import {
connectionsQueryOptions,
personalConnections,
pluginsPageQueryOptions,
} from "@/lib/plugins/queries";

/**
* GitHub's or Slack's page under Connections: the person's own account there, and for GitHub what
* hangs off it.
*
* These used to be a "Linked accounts" section and a "GitHub events" section on the Connections
* page itself, beside the apps, so a person had to know a link was a different kind of thing from an
* app before they could find it. Now each is a row like any app, and this is the row's page.
*
* `linked` is what the GitHub callback sends back, `?linked=github` and its failures. It is said
* once and then dropped from the address, so a reload does not repeat the news.
*/
export function IdentityConnectionPage({
linked,
provider,
}: {
linked: string | undefined;
provider: LinkableProvider;
}) {
const title = linkableProviderTitle(provider);
const [arrival] = useState(() => linkedNotice(linked));
const links = useQuery(linkedAccountsQueryOptions());
const notice = visibleLinkedNotice(arrival, links.data);
const navigate = useNavigate();
useEffect(() => {
if (linked !== undefined) {
navigate({
to: ".",
search: (previous: Record<string, unknown>) => {
const { linked: _linked, ...rest } = previous;
return rest;
},
replace: true,
});
}
}, [linked, navigate]);

return (
<PageShell
backButton={{ label: "Connections", linkProps: { to: "/connections" } }}
description={IDENTITY_SUMMARIES[provider]}
title={title}
>
{notice ? (
<p
className={
notice.tone === "error"
? "mt-4 text-destructive text-sm"
: "mt-4 text-muted-foreground text-sm"
}
role={notice.tone === "error" ? "alert" : "status"}
>
{notice.text}
</p>
) : null}
<LinkedAccountSection provider={provider} />
{provider === "github" ? (
<>
<GithubSources />
<GithubTools />
</>
) : null}
</PageShell>
);
}

/**
* A brokered GitHub app, where one is enabled. It is a separate connection — tools that read and
* change repositories as you — and the Connections list leaves its row out so GitHub is listed once,
* which makes this the way to it.
*/
function GithubTools() {
const plugins = useQuery(pluginsPageQueryOptions());
const connections = useQuery(connectionsQueryOptions());
const app = plugins.data?.composioConfigured
? plugins.data.servers.find(
(server) => isBrokeredGithub(server) && server.accountMode !== "shared",
)
: undefined;
if (!app) return null;
const connected = personalConnections(
connections.data?.connections ?? [],
).some((row) => row.serverId === app.id);
return (
<PageSection
description="Lets your Bots read and change your repositories as you. A connection of its own, separate from your account above."
title="Tools"
>
<PageRows>
<Item
render={<Link params={{ key: app.id }} to="/connections/$key" />}
size="sm"
>
<ItemMedia variant="icon">
<IconBrandGithub />
</ItemMedia>
<ItemContent>
<ItemTitle>GitHub tools</ItemTitle>
<ItemDescription>
{connected ? "Connected" : "Not connected"}
</ItemDescription>
</ItemContent>
<ItemActions>
<IconChevronRight className="size-4 text-muted-foreground" />
</ItemActions>
</Item>
</PageRows>
</PageSection>
);
}
Loading
Loading