diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f9d3f16f4..ce88b38731 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,6 +78,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
`COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent`
and `computers.tabIdleMs`) keep the old behaviour.
+### The default look: an inset page, slate greys and quieter headings
+
+Each page now sits in a panel inset from the window, on a darker frame shared with the sidebar,
+with white cards on a light grey page; admin and settings scroll inside it like the rest of the
+app. The greys are Tailwind's slate rather than neutral, and page and section headings are lighter.
+Rows show an app's logo or initial instead of the same plug; Home shows Bots as compact cards; a
+Bot's page leads with its own one-liner; Connections tiles no longer cut their text off; and the
+Composio directory shows its rows loading. Empty states carry a faint dot pattern whose file,
+colour and strength are theme tokens (`--empty-pattern*` in `theme.css`), so a deployment can
+replace it or turn it off. Skins are unchanged.
+
### A computer closes browsers nobody is using when its memory runs high
A computer runs one browser for each Bot that is browsing, and the only limits were a count and half
diff --git a/app/PRODUCT.md b/app/PRODUCT.md
new file mode 100644
index 0000000000..fe5c7cd442
--- /dev/null
+++ b/app/PRODUCT.md
@@ -0,0 +1,59 @@
+# Product
+
+
+
+## Platform
+
+web
+
+## Users
+
+Two audiences use the same app.
+
+- **Company employee (daily end user).** Usually not technical. Asks Bots to do jobs: read an expense claim, turn a meeting note into follow-ups, research a topic. Expects the ease of ChatGPT, Claude or Grok.
+- **IT owner (buyer and administrator).** Chooses and deploys OpenBot. Adds Bots, grants tools and connections, answers approvals, and reads the audit log. Needs trust and control.
+
+## Product Purpose
+
+OpenBot is an AI assistant that a company runs on its own infrastructure and can change completely. Any agent that speaks AG-UI arrives as a coworker. Success: employees use it as their everyday assistant, and the IT owner is confident about what each Bot can do.
+
+## Positioning
+
+Bots are coworkers, and you own them. Each Bot has a computer of its own (a real browser with its own logins, files and only the tools you grant). It runs on your infrastructure, with your PostgreSQL and your model choice. A hosted chatbot cannot truthfully say either.
+
+## Operating Context
+
+- The web app in `app/` is the main surface. A Tauri desktop app and a mobile client use the same frame.
+- Main areas: chat with a chosen Bot, the Bot's own computer and browser, Bots, skills, routines, connections, memory, approvals, audit, admin and settings.
+- OpenBot is a template. Companies clone the repository and rename it through `brand.yaml`.
+- A person can pick a skin per account: the default `openbot` skin, OpenDuet, OpenDots or OpenMuse.
+
+## Capabilities and Constraints
+
+- Light and dark mode must both ship finished.
+- The skin system stays. This redesign replaces the default `openbot` skin. The other skins stay as alternates.
+- Desktop and mobile must both work at native quality.
+- Dense admin screens (audit log, approvals, tables, settings) must stay easy to scan.
+- Stack: React 19, Vite, Tailwind 4, shadcn and Base UI components, Tabler icons, Motion.
+
+## Brand Commitments
+
+- The new look is opinionated. When a company renames OpenBot, it swaps the name and logo. The visual world stays.
+- The design must move away from the generic shadcn look (gray neutrals, Inter, default components).
+- The user named Grok and Muse as references for simplicity, polish and elegance. The result must be unique, not a copy of either.
+- Binding brand input (pinned 2026-10-28): take the pixelation and color schemes of copilotkit.ai. Pixelation means square-pixel dither fields. The colors are periwinkle `#7480F1` to mint `#23D5A2`, sky `#8DC1F1` to violet `#686FDF`, lilac `#BEC2FF`, pale lavender `#EDEDF5` and deep plum `#35295F`. The rest of that website is not a reference.
+- Direction rounds 1 and 2 were rejected as generic. The user likes bold directions, except extreme ones such as a hand-drawn newsprint catalog.
+
+## Evidence on Hand
+
+- Real product copy in `README.md` and `docs/`.
+- Real Bots: General Assistant, Knowledge, Risk Analyst, and ten catalog Bots in `examples/fintech/catalog/`.
+- No customer logos, testimonials or usage numbers. Do not invent them.
+
+## Product Principles
+
+1. A Bot is a colleague, not a chat widget. It has a name, a computer and a record of its work.
+2. Ownership feels calm and solid. Nothing in the interface suggests a hosted service.
+3. The conversation leads. Chrome that does not help the current job stays out of the way.
+4. Control is visible but quiet. Approvals and audit are part of the product, not an alarm system.
+5. Every detail is finished. Rough spacing, type and motion break trust.
diff --git a/app/package.json b/app/package.json
index b5be1f7ac8..3c5d42e0da 100644
--- a/app/package.json
+++ b/app/package.json
@@ -18,6 +18,8 @@
"@base-ui/react": "^1.6.0",
"@better-auth/sso": "^1.7.1",
"@copilotkit/react-core": "1.83.0",
+ "@fontsource-variable/atkinson-hyperlegible-mono": "^5.3.0",
+ "@fontsource-variable/atkinson-hyperlegible-next": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@openbot/shared": "workspace:*",
"@shadcn/react": "^0.3.0",
diff --git a/app/src/assets/patterns/dot-matrix.svg b/app/src/assets/patterns/dot-matrix.svg
new file mode 100644
index 0000000000..be9834f09a
--- /dev/null
+++ b/app/src/assets/patterns/dot-matrix.svg
@@ -0,0 +1,136 @@
+
+
diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx
index 2b4a7d5d6c..62286f8498 100644
--- a/app/src/components/admin/admin-sidebar.tsx
+++ b/app/src/components/admin/admin-sidebar.tsx
@@ -1,15 +1,16 @@
import {
IconArrowLeft,
IconBuildingBank,
+ IconBulb,
IconChecks,
IconCode,
IconDeviceDesktop,
- IconFileText,
IconKey,
IconLayoutGrid,
IconListDetails,
IconPuzzle,
IconShieldCheck,
+ IconSlash,
IconUsers,
} from "@tabler/icons-react";
import { Link, type LinkOptions } from "@tanstack/react-router";
@@ -79,12 +80,12 @@ const GROUPS: {
},
{
title: "Skills",
- icon: IconFileText,
+ icon: IconSlash,
linkOptions: { to: "/admin/skills" },
},
{
title: "Automatic learning",
- icon: IconFileText,
+ icon: IconBulb,
linkOptions: { to: "/admin/learning" },
},
{
@@ -130,7 +131,7 @@ export function AdminSidebar({
...props
}: React.ComponentProps) {
return (
-
+
{/*
* Pinned to the same height as the app sidebar's header. Left to itself this one is 60px
* against the app's 45px — `p-2` around a `size="lg"` button rather than a fixed height — so
diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx
index 9ca9845070..8b06b59165 100644
--- a/app/src/components/agents/abstract-avatar.tsx
+++ b/app/src/components/agents/abstract-avatar.tsx
@@ -1,5 +1,11 @@
import Avatar from "boring-avatars";
+import { BotOrb } from "@/components/agents/bot-orb";
+import { DEFAULT_SKIN, useSkinId } from "@/skins/store";
+/**
+ * A Bot's avatar. OpenBot's own look draws the Bot as its orb; a skin keeps the face it was
+ * designed with.
+ */
export function AbstractAvatar({
name,
seed,
@@ -9,6 +15,11 @@ export function AbstractAvatar({
seed: string;
size?: number;
}) {
+ const skin = useSkinId();
+ if (skin === DEFAULT_SKIN) {
+ return ;
+ }
+
return (
+ /*
+ * The avatar beside the name rather than above it: a tall card left most of its height empty
+ * between the two, and a row reads the name and what the Bot is for in one glance.
+ */
+
-
+
- {/* Bottom-aligned, where the text sat on the earlier artwork card; the avatar holds the top. */}
-
-
+
+
{agent.name}
-
+
{agent.roleDescription}
diff --git a/app/src/components/agents/bot-orb.tsx b/app/src/components/agents/bot-orb.tsx
new file mode 100644
index 0000000000..2c7c2c7ea5
--- /dev/null
+++ b/app/src/components/agents/bot-orb.tsx
@@ -0,0 +1,83 @@
+import type { CSSProperties } from "react";
+import AgentOrb from "@/components/agents/orb/agent-orb";
+import { orbPalette } from "@/lib/orb-palette";
+
+/** At this size and smaller, an idle orb is drawn still. */
+const STATIC_MAX_SIZE = 32;
+
+/*
+ * The still orb: the live orb's base sphere (a lit radial in the palette's own stops) with one
+ * conic sweep over it for the mesh, and a soft highlight on top. Drawn from the custom properties
+ * set on the element, so the fill is written once and the palette is the only thing that varies.
+ */
+const STATIC_FILL = [
+ "radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.28), transparent 34%)",
+ "conic-gradient(from 200deg at 70% 30%, var(--c1), transparent 30% 65%, var(--c1))",
+ "radial-gradient(circle at 38% 34%, var(--c4) 0%, var(--c2) 42%, var(--c3) 100%)",
+].join(", ");
+
+/**
+ * A Bot drawn as its orb, in the palette its seed picks (`orbPalette`), so renaming a Bot keeps
+ * its color.
+ *
+ * At avatar sizes an idle Bot gets a still orb drawn with CSS alone: twenty animated orbs in a
+ * sidebar would each run a blur and a backdrop filter on every frame. A larger orb, or one whose
+ * Bot is working, is the live `AgentOrb`. Neither moves under reduced motion: `AgentOrb` honours
+ * it, and the still orb has nothing to move.
+ */
+export function BotOrb({
+ seed,
+ name,
+ size = 32,
+ state = "idle",
+}: {
+ seed: string;
+ name: string;
+ size?: number;
+ state?: "idle" | "working" | "waiting";
+}) {
+ const colors = orbPalette(seed);
+
+ if (size <= STATIC_MAX_SIZE && state === "idle") {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Hidden so the Bot is announced once, by name. A waiting Bot idles: the next move is the
+ person's, not the Bot's. */}
+
+
+
+
+ );
+}
diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx
index 6a693c4910..90ad484fdd 100644
--- a/app/src/components/agents/handoff-panel.tsx
+++ b/app/src/components/agents/handoff-panel.tsx
@@ -1,4 +1,4 @@
-import { IconArrowsExchange, IconLock, IconRobot } from "@tabler/icons-react";
+import { IconArrowsExchange, IconBolt, IconLock } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Fragment } from "react";
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
@@ -102,7 +102,7 @@ export function HandoffPanel({ agentId }: { agentId: string }) {
) : null}
{grantable && total === 0 ? (
-
+
When there are more, you can let this Bot ask them here.
) : null}
diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx
index 27fe657b18..0d003e8b03 100644
--- a/app/src/components/app-sidebar/app-sidebar.tsx
+++ b/app/src/components/app-sidebar/app-sidebar.tsx
@@ -3,7 +3,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, type LinkOptions, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type * as React from "react";
-import { useRef, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -26,16 +26,18 @@ import {
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
+import { approvalInboxOptions } from "@/lib/approvals";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
type ChannelSummary,
channelListQueryOptions,
} from "@/lib/channels/queries";
import { useChannelEvents } from "@/lib/channels/use-channel-events";
+import { waitingThreadIds } from "@/lib/channels/waiting";
import { appConfig } from "@/lib/generated/application-config";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { navigationFor } from "@/lib/navigation";
-import { relativeTime } from "@/lib/relative-time";
+import { relativeTimeShort } from "@/lib/relative-time";
import {
type MessageListEmphasis,
useMessageListEmphasis,
@@ -153,10 +155,12 @@ function ChannelRow({
channel,
animateOrder,
emphasis,
+ waiting,
}: {
channel: ChannelSummary;
animateOrder: boolean;
emphasis: MessageListEmphasis;
+ waiting: boolean;
}) {
const shouldReduceMotion = useReducedMotion();
// Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen`
@@ -186,13 +190,14 @@ function ChannelRow({
lastMessage={channel.lastMessage ?? undefined}
lastMessageAt={
channel.lastMessageAt
- ? relativeTime(channel.lastMessageAt)
+ ? relativeTimeShort(channel.lastMessageAt)
: undefined
}
pinned={channel.pinned}
unread={unread}
// A turn running, or work the conversation waits on between turns: a hop, a routine.
busy={(channel.busy ?? false) || (channel.work?.length ?? 0) > 0}
+ waiting={waiting}
/>
);
@@ -205,6 +210,9 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
const channels = useInfiniteQuery(channelListQueryOptions());
// One socket for the app, opened where the roster is kept live.
useChannelEvents();
+ // The approvals inbox, polled: a conversation stops waiting on the refresh after it is answered.
+ const inbox = useQuery(approvalInboxOptions());
+ const waiting = useMemo(() => waitingThreadIds(inbox.data), [inbox.data]);
const [search, setSearch] = useState("");
const scrollRoot = useRef(null);
const searching = search.trim().length > 0;
@@ -220,7 +228,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
const navigation = navigationFor(currentUser?.role);
return (
-
+
@@ -311,6 +319,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
key={channel.id}
animateOrder={animateOrder}
channel={channel}
+ waiting={waiting.has(channel.threadId)}
/>
))}
diff --git a/app/src/components/app-sidebar/channel-item-content.tsx b/app/src/components/app-sidebar/channel-item-content.tsx
index ece35115c2..4b4183eb13 100644
--- a/app/src/components/app-sidebar/channel-item-content.tsx
+++ b/app/src/components/app-sidebar/channel-item-content.tsx
@@ -1,8 +1,15 @@
-import { IconPinFilled } from "@tabler/icons-react";
+import { IconHandStop, IconPinFilled } from "@tabler/icons-react";
import { ChannelAvatar } from "@/components/channels/avatar";
import type { MessageListEmphasis } from "@/lib/settings/message-list";
+import { cn } from "@/lib/utils";
-/** Shared by live conversations and the appearance preference previews. */
+/**
+ * Shared by live conversations and the appearance preference previews.
+ *
+ * The second line says the one thing most worth knowing. A conversation that waits on you says so,
+ * in orange, which means nothing else in OpenBot. One whose Bot is working shows the working dots
+ * in place of its summary, so the avatar carries no badge of its own. Otherwise it is the summary.
+ */
export function ChannelItemContent({
participantIds,
name,
@@ -14,6 +21,7 @@ export function ChannelItemContent({
unread = false,
pinned = false,
revealing = false,
+ waiting = false,
}: {
participantIds: string[];
name: string;
@@ -25,18 +33,16 @@ export function ChannelItemContent({
unread?: boolean;
pinned?: boolean;
revealing?: boolean;
+ /** An approval or a question in this conversation waits on the person. */
+ waiting?: boolean;
}) {
- const primaryText = `text-[0.9rem] leading-5 tracking-[-1%] ${unread ? "font-semibold" : "font-medium"}`;
+ const primaryText = `text-sm leading-5 tracking-[-1%] ${unread ? "font-semibold" : "font-medium"}`;
const secondaryText = "text-[12px] leading-4 text-muted-foreground";
return (
-
+
-
+
@@ -56,16 +62,42 @@ export function ChannelItemContent({
) : null}
{handOff ? "This needs you" : "Waiting for your approval"}:{" "}
{request.action.toolRef
.replace(/^computer_|^host\//, "")
@@ -58,14 +71,16 @@ export function InlineApproval({
on {request.action.scope}
{request.action.policy ? (
-
{request.action.policy.reason}
+
+ {request.action.policy.reason}
+
) : null}
{decision.error ? (
{decision.error.message}
) : null}
-
+
{choices.map(([choice, label], index) => (
)
}
- icon={IconRobot}
+ icon={IconBolt}
title={
yoursPinned
? "All pinned"
diff --git a/app/src/components/channels/avatar.tsx b/app/src/components/channels/avatar.tsx
index 44487201b5..9a45c6d737 100644
--- a/app/src/components/channels/avatar.tsx
+++ b/app/src/components/channels/avatar.tsx
@@ -1,6 +1,8 @@
import Avatar from "boring-avatars";
-import { memo } from "react";
+import { memo, type ReactNode } from "react";
+import { BotOrb } from "@/components/agents/bot-orb";
import { cn } from "@/lib/utils";
+import { DEFAULT_SKIN, useSkinId } from "@/skins/store";
/**
* Memoized roster avatar. Row updates usually change preview/timestamp only, and
@@ -11,6 +13,10 @@ import { cn } from "@/lib/utils";
*
* `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel
* whose agent is mid-turn reads as busy from the roster without moving the row's layout.
+ *
+ * OpenBot's own look draws each Bot as its orb, seeded by its id; a skin keeps the faces. The orbs
+ * are hidden from screen readers: the row beside the avatar names the conversation, and an id read
+ * aloud would name nothing.
*/
export const ChannelAvatar = memo(function ChannelAvatar({
participantIds,
@@ -21,11 +27,18 @@ export const ChannelAvatar = memo(function ChannelAvatar({
size?: number;
typing?: boolean;
}) {
+ const orbs = useSkinId() === DEFAULT_SKIN;
+ const face = orbs ? orbFace : boringFace;
const avatar =
participantIds.length === 1 ? (
-
+ face(participantIds[0], size)
) : (
-
+
);
return (
@@ -55,20 +68,50 @@ const LAYOUTS = {
},
} as const;
+type Layout = {
+ diameter: number;
+ at: readonly (readonly [number, number])[];
+};
+
+/** The orbs' group: the first two Bots side by side, overlapping by a third of an orb. */
+const ORB_PAIR: Layout = {
+ diameter: 0.6,
+ at: [
+ [0, 0.2],
+ [0.4, 0.2],
+ ],
+};
+
+function boringFace(id: string, size: number) {
+ return ;
+}
+
+function orbFace(id: string, size: number) {
+ return (
+
+
+
+ );
+}
+
/**
- * A group's first two or three Bots, overlapping inside the avatar's own square so the row around
- * it keeps its layout. Each face is cut out where a later one covers it, leaving a gap that reads
+ * A group's first Bots, as many as the layout places, overlapping inside the avatar's own square so
+ * the row around it keeps its layout. Each face is cut out where a later one covers it, leaving a gap that reads
* on any background — selected, hovered, or the conversation's header.
*/
function GroupFaces({
+ face,
+ layout: chosen,
participantIds,
size,
}: {
+ face: (id: string, size: number) => ReactNode;
+ layout: keyof typeof LAYOUTS | Layout;
participantIds: string[];
size: number;
}) {
- const shown = participantIds.slice(0, 3);
- const layout = LAYOUTS[shown.length === 2 ? 2 : 3];
+ const layout: Layout = typeof chosen === "number" ? LAYOUTS[chosen] : chosen;
+ const shown = participantIds.slice(0, layout.at.length);
const diameter = Math.round(size * layout.diameter);
const gap = Math.max(1, size * 0.05);
const at = layout.at.map(([x, y]) => [x * size, y * size]);
@@ -97,7 +140,7 @@ function GroupFaces({
WebkitMaskComposite: "source-in",
}}
>
-
+ {face(id, diameter)}
);
})}
diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx
index 317647c970..6ef1fb6b9d 100644
--- a/app/src/components/channels/channel-chat.tsx
+++ b/app/src/components/channels/channel-chat.tsx
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ChannelWorkRows } from "@/components/channels/channel-work";
import { attachmentModality } from "@/components/channels/chat-messages";
import { toAgentOptions } from "@/components/channels/composer";
+import { ConversationIntro } from "@/components/channels/conversation-intro";
import { ConversationView } from "@/components/channels/conversation-view";
import {
seedMessage,
@@ -30,15 +31,15 @@ import {
type ChannelSummary,
channelKeys,
} from "@/lib/channels/queries";
+import {
+ onTurnFinishedAway,
+ turnFinishedAway,
+} from "@/lib/channels/turns-finished-away";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { observeApprovalAgent } from "@/lib/copilot/approval-context";
import { ConversationProvider } from "@/lib/copilot/conversation";
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
-import {
- onTurnFinishedAway,
- turnFinishedAway,
-} from "@/lib/channels/turns-finished-away";
import { stoppedReason } from "@/lib/copilot/stopped-turn";
import { readThreadMessages } from "@/lib/copilot/thread-messages";
import { useSkillCommands } from "@/lib/plugins/skill-commands";
@@ -326,6 +327,9 @@ export function ChannelChat({
const { copilotkit } = useCopilotKit();
// Mentions are scoped to the channel's permitted agents.
const { data: agentProfiles } = useQuery(agentListQueryOptions());
+ const botProfile = agentProfiles?.find(
+ (profile) => profile.id === runtimeAgentId,
+ );
const channelAgentId = `channel:${channel.id}`;
const { agent, isReady } = useAgent({
agentId: channelAgentId,
@@ -1045,6 +1049,10 @@ export function ChannelChat({
}
agents={toAgentOptions(agentProfiles, channel.agentIds)}
channelId={channel.id}
+ intro={
+ /* From the roster this screen already reads; nothing while it loads. */
+ botProfile ? : undefined
+ }
/*
* THE TURN, not the run. `say` waits for the runtime agent and the join before a run starts,
* and `agent.isRunning` alone leaves that gap unmarked — which is the one moment the
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index ba7002775d..c92fdc9bac 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -5,7 +5,6 @@ import {
useRenderActivityMessage,
useRenderToolCall,
} from "@copilotkit/react-core/v2";
-import { useQuery } from "@tanstack/react-query";
import {
readConnectPrompt,
SHOW_APP_ACCESS_TOOL,
@@ -18,6 +17,7 @@ import {
IconFile,
IconX,
} from "@tabler/icons-react";
+import { useQuery } from "@tanstack/react-query";
import { motion, useReducedMotion } from "motion/react";
import {
createContext,
@@ -59,6 +59,7 @@ import { readResponsibilityTurn } from "@/lib/channels/responsibility-turn";
import { readFiring } from "@/lib/channels/routine-firing";
import type { CardPlace } from "@/lib/copilot/carry-on";
import { ToolPlaceContext } from "@/lib/copilot/tool-place";
+import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
import { markdownComponents } from "@/lib/markdown";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { readToolName } from "@/lib/plugins/tool-name";
@@ -71,7 +72,6 @@ import {
import { readTeamBotConsent } from "@/lib/team-bots";
import { cn } from "@/lib/utils";
import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive";
-import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries";
import { AppAccessCard } from "./app-access-card";
import { isBrowserStep } from "./browser-activity";
import {
@@ -94,6 +94,7 @@ import {
useDrawnTool,
} from "./tool-activity";
import { ToolRenderBoundary, toolCallFinal } from "./tool-boundary";
+import { ToolLine } from "./tool-line";
import { shouldDrawEarlier, useTranscriptPages } from "./transcript-pages";
import { TranscriptRail } from "./transcript-rail";
import {
@@ -103,13 +104,17 @@ import {
type Turn,
turnsOf,
} from "./transcript-turns";
-import { ToolLine } from "./tool-line";
import { VoiceChatCard } from "./voice-chat-card";
type ChatTranscriptProps = {
busy?: boolean;
/** Comma-separated `/` command names, used to tell a skill chip from a leading slash. */
commandNames?: string;
+ /**
+ * Drawn above the first message, as the first thing in the scroll content, so it scrolls away
+ * with the conversation. Only once the earliest page is drawn: see where it is rendered.
+ */
+ intro?: React.ReactNode;
messages: ReadonlyArray>;
/**
* Typed while the Bot had the turn, and waiting for it to finish. Empty on a screen that does not
@@ -1812,6 +1817,7 @@ function TranscriptRenderersWatch({
export function ChatTranscript({
busy = false,
commandNames = "",
+ intro,
messages,
onRemoveQueued,
queued = EMPTY_QUEUE,
@@ -1820,6 +1826,12 @@ export function ChatTranscript({
working,
}: ChatTranscriptProps) {
const isDrawn = useDrawnTool();
+ /*
+ * The transcript's column. OpenBot's own look widens it to a centered 720px in openbot.css,
+ * through `data-transcript-column`, an attribute of its own because the scroller's content keeps
+ * its `data-slot`.
+ */
+ const column = "mx-auto w-full max-w-2xl px-4";
/*
* NOT MEMOISED, AND THAT IS DELIBERATE. `useMemo` keyed on `messages` looks obviously right and
* silently broke the transcript: the agent hands back the SAME array and mutates it, so the
@@ -2100,7 +2112,10 @@ export function ChatTranscript({
if (restoring) {
return (
-
+
{/*
* A message typed while history loads is parked, not lost, and must be seen to be: a
@@ -2204,9 +2219,18 @@ export function ChatTranscript({
>
+ {/*
+ * THE INTRO IS THE FIRST CHILD, AND ONLY ONCE THE EARLIEST PAGE IS DRAWN. The scroller
+ * reads "the first child moved" as rows prepended and holds the reader's place (see the
+ * block below). Drawn while older pages are still to come, the intro would stay the
+ * first child and every page drawn under it would be read as appended rows instead.
+ * Drawn in the same commit as the earliest page, it is the prepend.
+ */}
+ {pages.from === 0 ? intro : null}
{/*
* DRAWN LAST, WRITTEN FIRST, AND THE SCROLLER IS WHY. Three reviewers have now arrived
* at this block, worked the mechanism out from the library's bundle, and reached the
diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx
index f33ca00e3a..d9091d1f88 100644
--- a/app/src/components/channels/composer/composer.tsx
+++ b/app/src/components/channels/composer/composer.tsx
@@ -119,6 +119,8 @@ const MAX_HEIGHT_PX = 220;
*/
const COMPACT_MIN_HEIGHT_PX = 19;
const COMPACT_MAX_HEIGHT_PX = 96;
+/** A raised composer's editor starts three lines tall. */
+const RAISED_MIN_HEIGHT_PX = 72;
export type ComposerProps = {
className?: string;
@@ -128,6 +130,14 @@ export type ComposerProps = {
* `text-lg`) goes through here, where tailwind-merge lets it beat the built-in `text-sm`.
*/
editorClassName?: string;
+ /** What the empty editor says. Home steps it through prompts built from the person's Bots. */
+ placeholder?: string;
+ /**
+ * `raised` lifts the full composer off the page with the card edge and shadow and gives its editor
+ * room for a few lines, for a composer that is the page's one job, like Home's. The compact one
+ * ignores it.
+ */
+ variant?: "default" | "raised";
compact?: boolean;
voiceCall?: { active: boolean; supported: boolean; onStart(): void };
/** Agents that `@` can address. Empty means the mention menu reports an empty channel. */
@@ -312,6 +322,8 @@ const UNACCEPTED_DROP_REASON = {
export function Composer({
className,
editorClassName,
+ placeholder = "Ask anything",
+ variant = "default",
compact = false,
voiceCall,
agents = [],
@@ -1561,7 +1573,7 @@ export function Composer({
onChange={handleChange}
onImagePaste={canAttach ? stagePastedImage : undefined}
onSubmit={submitDraft}
- placeholder="Ask anything"
+ placeholder={placeholder}
ref={promptAreaRef}
triggers={triggers}
value={value}
@@ -1591,7 +1603,11 @@ export function Composer({
{tooManyStagedNotice}
+ {/* In OpenBot's own look, openbot.css widens this column to the transcript's centered 720px. */}
;
return (
-
+
{/*
* Native owns expansion because SDK tool renderers can be re-invoked independently
* of this component's React state.
diff --git a/app/src/components/component-preview.tsx b/app/src/components/component-preview.tsx
index 57d991c949..fad1047c31 100644
--- a/app/src/components/component-preview.tsx
+++ b/app/src/components/component-preview.tsx
@@ -36,7 +36,7 @@ function PreviewNotice({ children }: { children: ReactNode }) {
return (
{/* The artwork stays pale in both themes, so this text must stay dark. */}
-
{children}
+
{children}
);
}
diff --git a/app/src/components/layout/page-parts.tsx b/app/src/components/layout/page-parts.tsx
index 7fa01f8dba..72a1e9d637 100644
--- a/app/src/components/layout/page-parts.tsx
+++ b/app/src/components/layout/page-parts.tsx
@@ -17,7 +17,7 @@ import {
*
* `full` exists for one page. An audit log is a table you scan, and forcing it into the prose column
* would wrap every row. The page is exactly the window's height and does not scroll: the title sits
- * at the pane's left edge, lined up with the table's first column rather than centred above a table
+ * at the pane's left SURFACE_EDGE, lined up with the table's first column rather than centred above a table
* that is not, and the children fill what is left, so the table can be the one thing that scrolls.
* It is not a licence for anything else to be wide. It is no column, so `PageShell` draws it itself.
*/
@@ -40,20 +40,13 @@ export function PageColumn({
return (
<>
{/*
- * The scroller, and it has to live here rather than in either shell, because the two shells
- * this frame is used under disagree about who scrolls.
- *
- * Under `_authed` — admin, settings — the document scrolls, so a page taller than the window
- * has always just worked. Under `_authed/_app` it does not: that shell is `h-svh
- * overflow-hidden` on purpose ("one viewport, never scrolls: panes scroll inside it") and its
- * `main` is `overflow-hidden` too, so a PageShell taller than the window was silently CLIPPED
- * — 246px of Skills sat below the fold with no way to reach it, by keyboard or otherwise.
+ * The scroller. Every shell — app, admin, settings — is exactly the window's height and holds
+ * the page in an inset panel that never grows past it, so a page taller than the window
+ * scrolls here, inside the panel, and the panel's rounded corners stay on screen.
*
* `min-h-0` is the load-bearing half of `min-h-0 flex-1`: a flex item's default `min-height:
- * auto` refuses to shrink below its content, so `flex-1` alone would grow this past the pane
- * and clip exactly as before. Under `_authed` both are inert — the parent is not a flex
- * container — and `overflow-y-auto` on an auto-height element shows no scrollbar, so those
- * eighteen screens are unaffected.
+ * auto` refuses to shrink below its content, so `flex-1` alone would grow this past the panel
+ * and the overflow would be clipped instead of scrolled.
*
* Separate from the centred column below so the scrollbar rides the pane's edge instead of
* appearing inside the measure with content either side of it.
@@ -157,6 +150,12 @@ export function PageSection({
* them. Rendering nothing when there is nothing is deliberate — an empty bordered box reads as a
* thing that failed to load, where absence reads as nothing to see yet.
*/
+/*
+ * Rows and cards take the firmer edge and soft lift from the `--card-*` tokens in theme.css; a skin
+ * sets them back to its plain hairline in skins.css.
+ */
+const SURFACE_EDGE = "border-card-border shadow-card";
+
export function PageRows({
children,
className,
@@ -176,9 +175,10 @@ export function PageRows({
*/
{children}
{footer ? (
@@ -243,7 +240,10 @@ export function PageEmpty({
title?: string;
}) {
return (
-
+ // `isolate` gives the pattern its own stacking context, so it sits behind the text, not on it.
+
+ {/* The pattern is decoration: see `.empty-pattern` in styles.css and its tokens in theme.css. */}
+
{Icon ? (
diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx
index 073ed9e42e..73c4c01727 100644
--- a/app/src/components/layout/page-shell.tsx
+++ b/app/src/components/layout/page-shell.tsx
@@ -83,10 +83,10 @@ export function PageShell({
if (width === "full") {
return (
/*
- * One viewport, never scrolls. `min-w-0` lets a table wider than the pane scroll inside its own
- * container instead of pushing the page sideways.
+ * Exactly the panel's height, never scrolls. `min-w-0` lets a table wider than the pane scroll
+ * inside its own container instead of pushing the page sideways.
*/
-
+
{barRow}
{header}
diff --git a/app/src/components/layout/sidebar-shell.tsx b/app/src/components/layout/sidebar-shell.tsx
index 177b61db78..d3493714ab 100644
--- a/app/src/components/layout/sidebar-shell.tsx
+++ b/app/src/components/layout/sidebar-shell.tsx
@@ -6,6 +6,15 @@ import {
parseStoredSidebarOpen,
SIDEBAR_STORAGE_KEY,
} from "@/lib/sidebar";
+import { cn } from "@/lib/utils";
+import { DEFAULT_SKIN, useSkinId } from "@/skins/store";
+
+/**
+ * OpenBot's own sidebar gutters: the header, content and footer already sit 8px in from the edge;
+ * every item inside them starts 10px further in, so the search icon, the orbs, the nav icons and the
+ * avatar share one left edge. A skin keeps the spacing it was designed with.
+ */
+const OPENBOT_FRAME = "[&_[data-sidebar=menu-button]]:px-2.5";
/**
* The frame the three shells — app, admin, settings — hang their sidebar in.
@@ -28,6 +37,7 @@ export function SidebarShell({
width: string;
}) {
// Read before the first paint, so a shell somebody left collapsed never flashes open.
+ const openbot = useSkinId() === DEFAULT_SKIN;
const [open, setOpen] = useState(() =>
parseStoredSidebarOpen(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)),
);
@@ -40,7 +50,7 @@ export function SidebarShell({
return (
-
+
-
-
+
+
);
diff --git a/app/src/components/plugins/broker-directory.tsx b/app/src/components/plugins/broker-directory.tsx
index 7137bb66e3..4f9b97b8a7 100644
--- a/app/src/components/plugins/broker-directory.tsx
+++ b/app/src/components/plugins/broker-directory.tsx
@@ -7,6 +7,7 @@ import {
PageSection,
PageShell,
} from "@/components/layout/page-shell";
+import { AppInitial } from "@/components/plugins/plugin-logo";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -18,6 +19,7 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
import {
consentBreadth,
coverageWarning,
@@ -56,6 +58,9 @@ import { queryClient } from "@/query-client";
* here branches on which one it is — a `provider === "composio"` test inside a screen is the same
* mistake wearing a conditional.
*/
+/** Placeholder rows while the directory loads: enough to fill the view, not to suggest a count. */
+const LOADING_ROWS = ["a", "b", "c", "d", "e", "f"];
+
export function BrokerDirectory({ provider }: { provider: PluginsProvider }) {
const { name, directoryLabel } = providerVocabulary(provider);
const [search, setSearch] = React.useState("");
@@ -146,8 +151,31 @@ export function BrokerDirectory({ provider }: { provider: PluginsProvider }) {
value={search}
/>
- {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */}
- {apps.isPending ? null : apps.error ? (
+ {/*
+ * Pending, error, empty, rows. Pending draws placeholder rows: the full directory takes a
+ * couple of seconds to arrive, and a bare search box over blank space read as a page with
+ * nothing on it. They assert nothing, so no sentence claims anything mid-fetch.
+ */}
+ {apps.isPending ? (
+
{/*
* The server's own sentence, as the enable failure above already does. A keyless
@@ -167,64 +195,72 @@ export function BrokerDirectory({ provider }: { provider: PluginsProvider }) {
: `${name} returned no apps.`}
) : (
-
- {listed.map((entry, index) => {
- // One mutation serves every row, so which rows are mid-request is tracked beside it
- // rather than read off it. See `adding` above.
- const isAdding = adding.has(entry.slug);
+ <>
+ {/* How many there are, before the list: with no search, this is the whole directory. */}
+
+ {query
+ ? `${listed.length.toLocaleString()} ${listed.length === 1 ? "match" : "matches"}`
+ : `${listed.length.toLocaleString()} apps. Search by name to narrow them.`}
+
+
+ {listed.map((entry, index) => {
+ // One mutation serves every row, so which rows are mid-request is tracked beside it
+ // rather than read off it. See `adding` above.
+ const isAdding = adding.has(entry.slug);
- return (
-
- {/* The test id names the screen, not the vendor: this module draws whichever
+ return (
+
+ {/* The test id names the screen, not the vendor: this module draws whichever
broker is active, and the id is not user-visible. */}
-
- {/* The vendor's own mark where the broker supplies one, and a plug where it does
- not, so a list of third parties still has a fixed left edge. */}
-
- {entry.logo ? (
-
- ) : (
-
- )}
-
-
- {entry.name}
- {entry.description}
- {entry.enabled ? null : }
- {entry.enabled ? null : }
-
-
- {/* The size of the decision, stated before it is made: an app is not one
+
+ {/* The vendor's own mark where the broker supplies one, and its initial where it
+ does not, so a list of third parties still has a fixed left edge. */}
+
+ {entry.logo ? (
+
+ ) : (
+
+ )}
+
+
+ {entry.name}
+ {entry.description}
+ {entry.enabled ? null : }
+ {entry.enabled ? null : }
+
+
+ {/* The size of the decision, stated before it is made: an app is not one
tool, and "167 actions" is what says so. */}
-
- {entry.actionCount} actions
-
- {/*
- * An app this deployment already has says so rather than offering Add again.
- * A second Add would ask the server to record a connector it already holds,
- * and there is nothing on the row to suggest that would be harmless.
- */}
- {entry.enabled ? (
- Added
+ {entry.actionCount} actions
- ) : (
-
- )}
-
-
- {index !== listed.length - 1 && }
-
- );
- })}
-
+ {/*
+ * An app this deployment already has says so rather than offering Add again.
+ * A second Add would ask the server to record a connector it already holds,
+ * and there is nothing on the row to suggest that would be harmless.
+ */}
+ {entry.enabled ? (
+
+ Added
+
+ ) : (
+
+ )}
+
+
+ {index !== listed.length - 1 && }
+
+ );
+ })}
+
+ >
)}
diff --git a/app/src/components/plugins/plugin-logo.tsx b/app/src/components/plugins/plugin-logo.tsx
index c457a11b0c..d2c279a25f 100644
--- a/app/src/components/plugins/plugin-logo.tsx
+++ b/app/src/components/plugins/plugin-logo.tsx
@@ -1,9 +1,24 @@
import { IconPlug } from "@tabler/icons-react";
import { useState } from "react";
-export function PluginLogo({ logo }: { logo?: string | null }) {
+/**
+ * An app's logo, or its initial where it has none.
+ *
+ * The initial rather than a generic plug because a list of apps that all show the same plug is a
+ * column of identical tiles, which tells nobody anything; "T" and "G" at least tell Todoist from
+ * GitHub at a glance. The plug is left only for a caller with no name to take an initial from.
+ */
+export function PluginLogo({
+ logo,
+ name,
+}: {
+ logo?: string | null;
+ name?: string;
+}) {
const [failedUrl, setFailedUrl] = useState(null);
- if (!logo || failedUrl === logo) return ;
+ if (!logo || failedUrl === logo) {
+ return name ? : ;
+ }
return (
);
}
+
+/** The first letter of an app's name, drawn where its logo would go. Hidden from screen readers: the row's title already says the name. */
+export function AppInitial({ name }: { name: string }) {
+ const initial = name.trim().charAt(0).toUpperCase();
+ return (
+
+ {initial || "?"}
+
+ );
+}
diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx
index 53e5d9bec3..d0694cca64 100644
--- a/app/src/components/settings/settings-sidebar.tsx
+++ b/app/src/components/settings/settings-sidebar.tsx
@@ -84,7 +84,7 @@ export function SettingsSidebar({
...props
}: React.ComponentProps) {
return (
-
+
{/* Matched to the app sidebar's header, as Admin's is. See admin-sidebar.tsx. */}
diff --git a/app/src/components/ui/input-group.tsx b/app/src/components/ui/input-group.tsx
index c5af85eeaa..429c2230a1 100644
--- a/app/src/components/ui/input-group.tsx
+++ b/app/src/components/ui/input-group.tsx
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
- "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+ "group/input-group relative flex h-9 w-full min-w-0 items-center rounded-lg border border-input bg-card transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
diff --git a/app/src/components/ui/input.tsx b/app/src/components/ui/input.tsx
index 11dff01045..2a5363d8f0 100644
--- a/app/src/components/ui/input.tsx
+++ b/app/src/components/ui/input.tsx
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
- "h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+ "h-9 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
diff --git a/app/src/components/ui/item.tsx b/app/src/components/ui/item.tsx
index 0f68d1c1bf..51b336baca 100644
--- a/app/src/components/ui/item.tsx
+++ b/app/src/components/ui/item.tsx
@@ -90,7 +90,7 @@ const itemMediaVariants = cva(
variant: {
default: "bg-transparent",
// A 32px tile behind a 16px glyph, so a column of rows reads as a column of marks.
- icon: "size-8 rounded-md bg-muted text-muted-foreground group-data-[size=xs]/item:size-6 [&_svg:not([class*='size-'])]:size-4",
+ icon: "size-8 rounded-md bg-background text-muted-foreground ring-1 ring-border group-data-[size=xs]/item:size-6 [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
diff --git a/app/src/components/ui/select.tsx b/app/src/components/ui/select.tsx
index f854d1b542..e101c0fb6f 100644
--- a/app/src/components/ui/select.tsx
+++ b/app/src/components/ui/select.tsx
@@ -43,7 +43,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
- "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx
index b5148fffe0..89672ca877 100644
--- a/app/src/components/ui/sidebar.tsx
+++ b/app/src/components/ui/sidebar.tsx
@@ -325,7 +325,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
) {