Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
14e58c8
feat(desktop): run the Intelligence CLI's local commands behind a bou…
dustywusty Oct 21, 2026
528565c
feat(desktop): stop a local Intelligence stack OpenBot created on Sto…
dustywusty Oct 21, 2026
2cb5bc9
feat(desktop): local Intelligence panel, readiness rows and Stop fail…
dustywusty Oct 21, 2026
d593c8a
docs: local evaluation installer requirements v2 and CLI contract req…
dustywusty Oct 21, 2026
c022d87
wip(desktop): local Intelligence start sequence, readiness gate and a…
dustywusty Oct 22, 2026
d391bbf
wip(desktop): choose local or hosted first at step 4; restore the tes…
dustywusty Oct 22, 2026
216f5d9
wip(desktop): three-step local first run with background Intelligence…
dustywusty Oct 22, 2026
e9be1d1
wip(desktop): regression tests for Start after background readiness; …
dustywusty Oct 22, 2026
567cd89
wip(desktop): Open Intelligence dashboard through local login, no ter…
dustywusty Oct 22, 2026
30c9996
wip(desktop): Shutting down screen on Quit, relaunch layout, immediat…
dustywusty Oct 22, 2026
91afa16
wip(desktop): Intelligence sidebar row with instant dashboard open; n…
dustywusty Oct 22, 2026
de1fe49
wip(desktop): Test OpenBot asks through the server so the thread is i…
dustywusty Oct 22, 2026
2bfb7e0
wip(desktop): About OpenBot in both menus, with the commit, branch an…
dustywusty Oct 22, 2026
a102fcf
wip(desktop): Test OpenBot starts on a random question, with a button…
dustywusty Oct 22, 2026
7cb0563
wip(desktop): first screen asks where Intelligence runs, local or Cop…
dustywusty Oct 22, 2026
14cd1e3
wip(desktop): larger setup window that grows to fit a taller step, wi…
dustywusty Oct 22, 2026
a6532d4
wip(desktop): setup type and column a step larger for the 1000x800 wi…
dustywusty Oct 22, 2026
aa5354f
wip(desktop): say 'Opening CopilotKit sign-in' for the managed sign-i…
dustywusty Oct 22, 2026
f7c7238
fix(desktop): install a release from before the toolkit, which has no…
dustywusty Oct 22, 2026
4338ced
wip(desktop): local Intelligence by default at install, with nothing …
dustywusty Oct 22, 2026
d363380
wip(desktop): a finished sign-in on Connect your AI moves on by itself
dustywusty Oct 22, 2026
0451d5c
fix(desktop): start Docker Desktop when it is installed, install it w…
dustywusty Oct 22, 2026
96d6038
fix(desktop): local Start uses the model just chosen, even when the c…
dustywusty Oct 22, 2026
148f77e
wip(desktop): a finished sign-in shows a tick for a second before mov…
dustywusty Oct 22, 2026
394184a
fix(desktop): the sign-in tick sits in the 'Signed in' sentence inste…
dustywusty Oct 23, 2026
aa06925
feat(desktop): local Intelligence set up from the app alone: a bundle…
dustywusty Oct 23, 2026
e4849d8
fix(desktop): a download fails only when it stalls, not when it takes…
dustywusty Oct 23, 2026
5a9aeeb
feat(desktop): CopilotKit sign-in and the evaluation agreement are a …
dustywusty Oct 23, 2026
ca7ce1a
fix(desktop): a quit from the Dock, AppleScript or logout stops OpenB…
dustywusty Oct 23, 2026
dfc3457
feat(desktop): a local first run starts with the CopilotKit account, …
dustywusty Oct 23, 2026
625ff76
fix(desktop): step 1 shows sign-in when signed out, and a blocked Int…
dustywusty Oct 23, 2026
897f704
fix(desktop): an agreement acceptance setup refuses is said once, not…
dustywusty Oct 23, 2026
e2890f1
feat(desktop): a chosen AI provider stands alone, with Change and Esc…
dustywusty Oct 23, 2026
8312676
feat(desktop): OpenBot installs from the welcome click, while the per…
dustywusty Oct 23, 2026
945e816
feat(desktop): the setup window in the CopilotKit visual system
dustywusty Oct 23, 2026
6d568ef
perf(desktop): paint the background once and press buttons instantly
dustywusty Oct 23, 2026
fa85176
fix(desktop): serve the CopilotKit logo from public/ so the App tests…
dustywusty Oct 23, 2026
9e3ea8f
style(desktop): format the setup stylesheet
dustywusty Oct 23, 2026
be568ac
feat(desktop): a dark setup window that follows the system
dustywusty Oct 23, 2026
af6816b
feat(desktop): setup is dark by default, with light kept
dustywusty Oct 23, 2026
3358805
feat(desktop): setup follows the system appearance
dustywusty Oct 23, 2026
172d221
feat(desktop): Intelligence is quiet behind other steps until it need…
dustywusty Oct 23, 2026
88f0570
feat(desktop): name the setup sections instead of counting steps
dustywusty Oct 23, 2026
1846b37
style(desktop): sort imports after adding Sections
dustywusty Oct 23, 2026
4214fe5
feat(desktop): the last screen invites a try, not a test
dustywusty Oct 23, 2026
7372689
feat(desktop): swap the Pop-Tarts preset question for Bulldogs or 'Bama
dustywusty Oct 23, 2026
9ff456a
feat(desktop): add "How you doing today?" as a fifth preset question
dustywusty Oct 23, 2026
400a7b8
feat(desktop): swap the fifth preset question for "Is Ruby really web…
dustywusty Oct 23, 2026
408f5c4
feat(app): reskin OpenBot in the CopilotKit visual system
dustywusty Oct 23, 2026
14f5129
feat: one surface for OpenBot on macOS, its sidebar where the title b…
dustywusty Oct 23, 2026
f5ed364
feat(desktop): a Status window with memory, Restart and Stop per part
dustywusty Oct 23, 2026
87a0a72
feat(desktop): a menu bar menu with a status line and icons
dustywusty Oct 23, 2026
6e17f4b
fix(desktop): show the menu bar menu's icons on macOS 27
dustywusty Oct 23, 2026
c947664
feat(desktop): the menu bar menu names actions, not the app
dustywusty Oct 23, 2026
be17093
feat(desktop): an info icon on About in the menu bar menu
dustywusty Oct 23, 2026
7bec2e4
style(app): no stroke down the sidebar's edge
dustywusty Oct 23, 2026
a6a9b32
Revert "style(app): no stroke down the sidebar's edge"
dustywusty Oct 23, 2026
a7c947b
fix(desktop): the window's outline follows OpenBot's own light or dark
dustywusty Oct 23, 2026
4007f35
feat: Settings → This computer, and the window's outline follows Open…
dustywusty Oct 23, 2026
c3d019e
fix: This computer says Running for an OpenBot it did not start
dustywusty Oct 23, 2026
de823cf
chore(desktop): remove the temporary model credential debug line
dustywusty Oct 23, 2026
563380f
style(app): a 16px root for Plus Jakarta Sans, light text on dark not…
dustywusty Oct 23, 2026
deb3ad2
style(app): Plus Jakarta Sans one weight heavier and less tightly tra…
dustywusty Oct 23, 2026
c04d2e5
feat: a resizable sidebar, and a hidden one no longer puts its toggle…
dustywusty Oct 23, 2026
57a59da
perf: This computer draws at once and fills memory in after
dustywusty Oct 23, 2026
9525da1
fix: compile off macOS and pass the repository lint
dustywusty Oct 26, 2026
4923205
fix(desktop): pass CI off macOS
dustywusty Oct 26, 2026
da8e112
fix(desktop): pick the bundled Node by the target platform, not the b…
dustywusty Oct 26, 2026
0e74978
fix(desktop): keep the setup title bar colours to macOS, the only pla…
dustywusty Oct 26, 2026
ff4c168
fix(desktop): a Windows setup screen people can act on, and administr…
dustywusty Oct 26, 2026
15c57f3
feat(desktop): set up WSL from the Windows setup screen, one prompt a…
dustywusty Oct 26, 2026
4062e4d
fix(desktop): download for the machine's architecture, not OpenBot's,…
dustywusty Oct 26, 2026
03d6475
feat(desktop): one surface at the top of the window on Windows
dustywusty Oct 26, 2026
9ed5f7f
fix(desktop): install Docker Desktop per user, unattended, find it th…
dustywusty Oct 26, 2026
b710986
fix(desktop): stop before Docker on a virtual machine without nested …
dustywusty Oct 26, 2026
bdf7cf1
fix(desktop): Windows on Apple silicon has no nested virtualization i…
dustywusty Oct 26, 2026
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
2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"@copilotkit/a2ui-renderer": "1.77.0",
"@copilotkit/react-core": "1.77.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
"@fontsource-variable/spline-sans-mono": "5.3.0",
"@openbot/shared": "workspace:*",
"@shadcn/react": "^0.3.0",
"@tabler/icons-react": "^3.36.1",
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { Button } from "../ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
import { Channel } from "./channel";
import { ChannelPagination } from "./channel-pagination";
import { IntelligenceRow } from "./intelligence-row";

const appLinkOptions = { to: "/" } satisfies LinkOptions;
const adminLinkOptions = { to: "/admin" } satisfies LinkOptions;
Expand Down Expand Up @@ -410,6 +411,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<span className="text-sm trackint-tight">Connections</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Inside OpenBot Desktop with a local Intelligence stack only; nothing otherwise. */}
<IntelligenceRow />
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
Expand Down
54 changes: 54 additions & 0 deletions app/src/components/app-sidebar/intelligence-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { IconChartDots, IconLoader2 } from "@tabler/icons-react";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import {
desktopShell,
type ShellIpc,
useIntelligenceDashboard,
} from "@/lib/desktop/intelligence-dashboard";

/**
* Intelligence, beside Skills and Agents, inside OpenBot Desktop with a local Intelligence stack.
*
* It opens the dashboard in the browser rather than navigating here, so it is a button, not a link.
* The row says it is opening the moment it is pressed and stays disabled until the browser has it.
*/
export function IntelligenceRow({
shell = desktopShell(),
}: {
shell?: ShellIpc | null;
}) {
const dashboard = useIntelligenceDashboard(shell);
if (!dashboard.available) return null;
return (
<SidebarMenuItem>
<SidebarMenuButton
aria-busy={dashboard.opening}
className="hover:bg-foreground/5 h-10"
disabled={dashboard.opening}
onClick={dashboard.open}
>
<div className="size-[28px] flex items-center justify-center">
{dashboard.opening ? (
<IconLoader2
aria-hidden="true"
className="motion-safe:animate-spin"
/>
) : (
<IconChartDots aria-hidden="true" />
)}
</div>
<span className="text-sm trackint-tight">
{dashboard.opening ? "Opening…" : "Intelligence"}
</span>
</SidebarMenuButton>
{dashboard.failure ? (
<p
className="px-2 pb-1 text-xs text-muted-foreground text-pretty"
role="status"
>
{dashboard.failure}
</p>
) : null}
</SidebarMenuItem>
);
}
48 changes: 42 additions & 6 deletions app/src/components/layout/sidebar-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { type CSSProperties, type ReactNode, useEffect, useState } from "react";
import {
type CSSProperties,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";

import { SidebarProvider } from "@/components/ui/sidebar";
import {
SidebarProvider,
SidebarResizeProvider,
} from "@/components/ui/sidebar";
import {
applySidebarOpen,
parseStoredSidebarOpen,
parseStoredSidebarWidth,
SIDEBAR_STORAGE_KEY,
SIDEBAR_WIDTH_STORAGE_PREFIX,
} from "@/lib/sidebar";

/**
Expand All @@ -15,22 +26,46 @@ import {
* `defaultOpen` to true). Driving it as controlled state is a dozen lines, and three copies of a
* dozen lines is three chances for one shell to forget the preference the other two remember.
*
* `width` is what actually differs between the shells. The app's roster earns 340px because its
* rows are two-line message previews; admin and settings hold short nav labels and earn 300px.
* `width` is what differs between the shells before anybody resizes one. The app's roster earns
* 340px because its rows are two-line message previews; admin and settings hold short nav labels and
* earn 300px. Dragging the sidebar's edge (`SidebarRail`) changes it, and each shell remembers its
* own under its `name`.
*/
export function SidebarShell({
children,
className,
width,
name,
width: defaultWidth,
}: {
children: ReactNode;
className?: string;
/** Whose width this is: each shell remembers its own, under this name. */
name: string;
/** The width before anybody drags the sidebar's edge. */
width: string;
}) {
// Read before the first paint, so a shell somebody left collapsed never flashes open.
const [open, setOpen] = useState(() =>
parseStoredSidebarOpen(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)),
);
const widthKey = `${SIDEBAR_WIDTH_STORAGE_PREFIX}${name}`;
const [width, setWidth] = useState(() =>
parseStoredSidebarWidth(
window.localStorage.getItem(widthKey),
defaultWidth,
),
);
// While the edge is dragged, the sidebar follows the pointer instead of easing after it.
const [resizing, setResizing] = useState(false);
const resize = useMemo(
() => ({
setWidth: (pixels: number) => setWidth(`${pixels}px`),
commitWidth: (pixels: number) =>
window.localStorage.setItem(widthKey, String(pixels)),
setResizing,
}),
[widthKey],
);

useEffect(() => {
applySidebarOpen(open, {
Expand All @@ -41,6 +76,7 @@ export function SidebarShell({
return (
<SidebarProvider
className={className}
data-resizing={resizing ? "" : undefined}
onOpenChange={setOpen}
open={open}
/*
Expand All @@ -56,7 +92,7 @@ export function SidebarShell({
} as CSSProperties
}
>
{children}
<SidebarResizeProvider value={resize}>{children}</SidebarResizeProvider>
</SidebarProvider>
);
}
15 changes: 14 additions & 1 deletion app/src/components/settings/settings-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
IconArrowLeft,
IconBell,
IconChecks,
IconDeviceDesktop,
IconKey,
IconLayoutGrid,
IconSettings,
Expand All @@ -18,6 +19,7 @@ import {
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import { desktopShell } from "@/lib/desktop/intelligence-dashboard";

const appLinkOptions = { to: "/" } satisfies LinkOptions;

Expand All @@ -29,6 +31,8 @@ const ITEMS: {
* everywhere else, so an entry stays lit on the pages beneath it.
*/
exact?: boolean;
/** Shown only inside OpenBot Desktop, which is what the page reads from. */
desktopOnly?: boolean;
icon: React.ComponentType<{ className?: string }>;
linkOptions: LinkOptions;
title: string;
Expand Down Expand Up @@ -58,6 +62,13 @@ const ITEMS: {
icon: IconKey,
linkOptions: { to: "/settings/passwords" },
},
{
/* What OpenBot runs here and the memory it takes, with Restart and Stop. */
title: "This computer",
icon: IconDeviceDesktop,
desktopOnly: true,
linkOptions: { to: "/settings/computer" },
},
{
/* The same mark Admin gives UI Components. It is the same subject seen from the other side. */
title: "Components gallery",
Expand Down Expand Up @@ -93,7 +104,9 @@ export function SettingsSidebar({
*/}
<SidebarGroup>
<SidebarMenu className="gap-px">
{ITEMS.map((option) => (
{ITEMS.filter(
(option) => !option.desktopOnly || desktopShell() !== null,
).map((option) => (
<SidebarMenuItem key={option.title}>
<SidebarMenuButton
render={(props) => (
Expand Down
137 changes: 128 additions & 9 deletions app/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
"use client";

import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { IconLayoutSidebar } from "@tabler/icons-react";
import { cva, type VariantProps } from "class-variance-authority";

import { useIsMobile } from "@/hooks/use-mobile";
import { keyOf } from "@/lib/hotkeys/hotkeys";
import { cn } from "@/lib/utils";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
Expand All @@ -24,7 +21,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { IconLayoutSidebar } from "@tabler/icons-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { keyOf } from "@/lib/hotkeys/hotkeys";
import { clampSidebarWidth, SIDEBAR_MIN_WIDTH } from "@/lib/sidebar";
import { cn } from "@/lib/utils";

const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
Expand Down Expand Up @@ -296,17 +296,135 @@ function SidebarTrigger({
);
}

/**
* How a shell lets its sidebar be resized: `setWidth` while the edge is dragged, `commitWidth` when it
* is let go. Provided by `SidebarShell`; without it the rail only toggles, as it always did.
*/
type SidebarResize = {
setWidth: (pixels: number) => void;
commitWidth: (pixels: number) => void;
setResizing: (resizing: boolean) => void;
};

const SidebarResizeContext = React.createContext<SidebarResize | null>(null);

const SidebarResizeProvider = SidebarResizeContext.Provider;

/**
* The narrowest the sidebar can be and still show its footer whole: its widest footer button's own
* content, plus the paddings around it. Measured from what is drawn, never from a rem constant, and
* never under `SIDEBAR_MIN_WIDTH`. A sidebar with no footer has only that floor.
*/
function footerWidth(panel: HTMLElement): number {
const footer = panel.querySelector<HTMLElement>(
'[data-slot="sidebar-footer"]',
);
if (!footer) return SIDEBAR_MIN_WIDTH;
const around =
panel.getBoundingClientRect().width - footer.getBoundingClientRect().width;
const footerStyle = getComputedStyle(footer);
const footerPadding =
Number.parseFloat(footerStyle.paddingLeft) +
Number.parseFloat(footerStyle.paddingRight);
let widest = 0;
for (const button of footer.querySelectorAll<HTMLElement>(
'[data-sidebar="menu-button"]',
)) {
const style = getComputedStyle(button);
const gap = Number.parseFloat(style.columnGap) || 0;
const children = Array.from(button.children) as HTMLElement[];
// `scrollWidth`, not the drawn width: a truncated label still reports what it needs.
const content =
children.reduce((sum, child) => sum + child.scrollWidth, 0) +
gap * Math.max(0, children.length - 1);
widest = Math.max(
widest,
content +
Number.parseFloat(style.paddingLeft) +
Number.parseFloat(style.paddingRight),
);
}
return Math.max(
SIDEBAR_MIN_WIDTH,
Math.ceil(widest + footerPadding + around),
);
}

/**
* The sidebar's edge. Dragged, it resizes the sidebar between what its footer needs and the shell's
* maximum; clicked without moving, it hides or shows the sidebar as it always did. A press counts as
* a drag once it has moved 3px, so a click with a slight wobble still toggles.
*/
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
const { toggleSidebar, state, isMobile } = useSidebar();
const resize = React.useContext(SidebarResizeContext);
const drag = React.useRef<{
startX: number;
startWidth: number;
min: number;
width: number;
moved: boolean;
} | null>(null);
const dragged = React.useRef(false);

return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
onPointerDown={(event) => {
if (!resize || isMobile || state !== "expanded" || event.button !== 0)
return;
const panel = event.currentTarget
.closest<HTMLElement>('[data-slot="sidebar"]')
?.querySelector<HTMLElement>('[data-slot="sidebar-container"]');
if (!panel) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const startWidth = panel.getBoundingClientRect().width;
drag.current = {
startX: event.clientX,
startWidth,
min: footerWidth(panel),
width: startWidth,
moved: false,
};
}}
onPointerMove={(event) => {
const current = drag.current;
if (!current || !resize) return;
const moved = event.clientX - current.startX;
if (!current.moved && Math.abs(moved) < 3) return;
if (!current.moved) resize.setResizing(true);
current.moved = true;
current.width = clampSidebarWidth(
current.startWidth + moved,
current.min,
);
resize.setWidth(current.width);
}}
onPointerUp={() => {
const current = drag.current;
drag.current = null;
if (!current?.moved || !resize) return;
dragged.current = true;
resize.setResizing(false);
resize.commitWidth(current.width);
}}
onPointerCancel={() => {
if (drag.current?.moved) resize?.setResizing(false);
drag.current = null;
}}
onClick={() => {
// The click that ends a drag is not a request to hide the sidebar.
if (dragged.current) {
dragged.current = false;
return;
}
toggleSidebar();
}}
title="Drag to resize, click to hide"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
Expand Down Expand Up @@ -736,6 +854,7 @@ export {
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarResizeProvider,
SidebarSeparator,
SidebarTrigger,
useOptionalSidebar,
Expand Down
Loading
Loading