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
8 changes: 6 additions & 2 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1181,8 +1181,12 @@ select:disabled,
justify-content: space-between;
padding-right: 0.35rem;
}
.shell-sidebar-brand-row .shell-sidebar-brand-mark {
width: auto;
.shell-sidebar-wordmark {
flex: 1;
padding-left: 0.5rem;
font-weight: 800;
font-size: 15px;
letter-spacing: -0.01em;
}

.shell-sidebar-account-btn {
Expand Down
13 changes: 0 additions & 13 deletions apps/web/src/shell/brand-mark.tsx

This file was deleted.

86 changes: 24 additions & 62 deletions apps/web/src/shell/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
// No bench switcher: a multi-bench install still resolves via the command
// palette's hidden "Switch workbench" action.

import {
Button,
Menu,
MenuContent,
MenuItem,
MenuTrigger,
SidebarPanel,
SidebarPanelBody,
} from "@corbits/react-ui";
import { Plus } from "@/lib/icons";
import { useState } from "react";
import { Button, SidebarPanel, SidebarPanelBody } from "@corbits/react-ui";
import { MagnifyingGlass, Plus } from "@/lib/icons";

import { useBench } from "../bench-context";
import { CreateAgentPanel } from "../pages/create-agent-panel";
import { AGENTS_PATH_PREFIX } from "../path-ids";
import { openCommandPalette } from "../command-palette-open-store";
import { NEW_WORKBENCH_PATH } from "../routes";
import { SidebarBrandMark } from "./brand-mark";
import { SidebarFooter } from "./sidebar-footer";
import { WorkbenchList } from "./workbench-list";

Expand All @@ -28,61 +16,35 @@ export function Sidebar({
readonly path: string;
readonly onNavigate: (to: string) => void;
}) {
const { selectedTenantId } = useBench();
const [createAgentOpen, setCreateAgentOpen] = useState(false);

return (
<SidebarPanel
className="shell-sidebar"
data-testid="shell-sidebar"
aria-label="Agents and Channels"
>
{/* Owner's shape: logo with "+" on the first row, the search box
(inside the list) below, then Agents and Channels. No
header icon cluster — search is the box. The "+" now opens a
dropdown (New Agent / New Workbench) instead of
jumping straight to New chat. */}
<SidebarPanel className="shell-sidebar" data-testid="shell-sidebar" aria-label="Workbenches">
<div className="shell-sidebar-brand-row">
<SidebarBrandMark />
<Menu>
<MenuTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Create new"
title="Create new"
data-tour="new-workbench-button"
>
<Plus />
</Button>
</MenuTrigger>
<MenuContent align="end">
<MenuItem
disabled={selectedTenantId === null}
onSelect={() => setCreateAgentOpen(true)}
>
New Agent
</MenuItem>
<MenuItem onSelect={() => onNavigate(NEW_WORKBENCH_PATH)}>New Workbench</MenuItem>
</MenuContent>
</Menu>
<span className="shell-sidebar-wordmark">Workbench</span>
<Button
variant="ghost"
size="sm"
aria-label="Search"
title="Search"
onClick={openCommandPalette}
>
<MagnifyingGlass />
</Button>
<Button
variant="ghost"
size="sm"
aria-label="New workbench"
title="New workbench"
data-tour="new-workbench-button"
onClick={() => onNavigate(NEW_WORKBENCH_PATH)}
>
<Plus />
</Button>
</div>
{/* Agents and Channels labels render inside the list, below its
search box (owner's order: logo · search · sections · rows). */}

<SidebarPanelBody data-tour="sidebar-list">
<WorkbenchList path={path} onNavigate={onNavigate} />
</SidebarPanelBody>

{selectedTenantId === null ? null : (
<CreateAgentPanel
open={createAgentOpen}
onOpenChange={setCreateAgentOpen}
tenantId={selectedTenantId}
onCreated={() => onNavigate(AGENTS_PATH_PREFIX)}
/>
)}

<SidebarFooter path={path} onNavigate={onNavigate} />
</SidebarPanel>
);
Expand Down
106 changes: 67 additions & 39 deletions apps/web/src/shell/workbench-list.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
// One section and nothing else: Workbenches. Agent membership is a
// workbench concept.

import { EmptyState, Input, Skeleton } from "@corbits/react-ui";
import { Hash, MagnifyingGlass } from "@/lib/icons";
import { useState } from "react";
import { EmptyState, Skeleton } from "@corbits/react-ui";
import { useQuery } from "@tanstack/react-query";
import { WorkbenchAvatar } from "@/chat/avatar";
import { listChatAgents } from "@/chat/threads-api";
import { Hash } from "@/lib/icons";

import { useBench } from "../bench-context";
import { tenantKeys } from "../query-client";
import { workbenchIdFromPath, workbenchPath } from "../workbench-path";
import type { HubTenant } from "../needs-converge";
import { useSidebarSections } from "./sidebar-sections";

export const SIDEBAR_EMPTY_COPY = "No workbenches yet";

function matches(text: string, needle: string): boolean {
return text.toLowerCase().includes(needle);
}

function SectionLabel({ children }: { readonly children: string }) {
return <div className="shell-panel-section-label">{children}</div>;
}
Expand Down Expand Up @@ -60,7 +59,6 @@ export function WorkbenchList({
}) {
const { selectedTenantId } = useBench();
const sections = useSidebarSections(selectedTenantId);
const [query, setQuery] = useState("");

if (sections.kind === "loading") {
return (
Expand All @@ -81,41 +79,71 @@ export function WorkbenchList({
);
}

const needle = query.trim().toLowerCase();
const workbenches = sections.workbenches.filter(
(tenant) => needle === "" || matches(tenant.name, needle),
);
const activeWorkbenchId = workbenchIdFromPath(path);
const { workbenches } = sections;

return (
<div className="panel-stack" aria-label="Workbenches">
<label className="shell-panel-search">
<MagnifyingGlass aria-hidden="true" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search…"
aria-label="Search workbenches"
/>
</label>

{workbenches.length === 0 ? (
<p className="shell-panel-list-empty">{SIDEBAR_EMPTY_COPY}</p>
) : null}
<div className="panel-stack" aria-label="Workbenches and workers">
<div className="panel-stack-group">
<SectionLabel>Workbenches</SectionLabel>
{workbenches.length === 0 ? (
<p className="shell-panel-list-empty">{SIDEBAR_EMPTY_COPY}</p>
) : null}
{workbenches.map((tenant) => (
<WorkbenchRow
key={tenant.id}
tenant={tenant}
active={tenant.id === activeWorkbenchId}
onSelect={() => onNavigate(workbenchPath(tenant.id))}
/>
))}
</div>
<WorkerGroup path={path} onNavigate={onNavigate} />
</div>
);
}

{workbenches.length === 0 ? null : (
<div className="panel-stack-group">
<SectionLabel>Workbenches</SectionLabel>
{workbenches.map((tenant) => (
<WorkbenchRow
key={tenant.id}
tenant={tenant}
active={tenant.id === activeWorkbenchId}
onSelect={() => onNavigate(workbenchPath(tenant.id))}
/>
))}
</div>
)}
// No client-side in-flight-turn state is queryable here, so every worker
// renders idle.
function WorkerGroup({
path,
onNavigate,
}: {
readonly path: string;
readonly onNavigate: (to: string) => void;
}) {
const { selectedTenantId } = useBench();
const agents = useQuery({
queryKey: tenantKeys.agents(selectedTenantId ?? "none"),
enabled: selectedTenantId !== null,
queryFn: () => listChatAgents(selectedTenantId as string),
});
const workers = agents.data ?? [];
if (workers.length === 0) return null;
return (
<div className="panel-stack-group">
<SectionLabel>Workers</SectionLabel>
{workers.map((agent) => {
const to = `/workers/${agent.id}`;
const active = path === to;
return (
<button
key={agent.id}
type="button"
className="shell-ch-row"
aria-current={active ? "true" : undefined}
data-active={active ? "true" : undefined}
onClick={() => onNavigate(to)}
>
<WorkbenchAvatar kind="worker" name={agent.name} size="sm" status="idle" />
<span className="shell-ch-meta">
<span className="shell-ch-name-row">
<span className="shell-ch-name">{agent.name}</span>
</span>
</span>
</button>
);
})}
</div>
);
}
Loading