fix(site): improve agents page mobile view (#24508)

closes DES-22030

## Summary

Mobile view cleanup for the agents page — all changes are behind the
`md:` breakpoint so desktop is unchanged.

**Dropdowns:** Full-width on mobile with dynamic positioning via a
`--mobile-dropdown-bottom` CSS custom property set by a `ResizeObserver`
on the chat input box. Three position variants: `-bottom` (above chat
input), `-top` (below header), `-top-below-header` (below sidebar
header). Viewport branching uses a new `isBelowMdViewport()` helper (`<
768px`) so 640–767 px landscape phones pick the mobile branch instead of
the desktop flyout.

**Layout:** On the main agents page, mobile ordering is header → chat
list → chat input using CSS `order` and `contents` on the content
wrapper. The chat input aligns to the bottom of available space. The
sidebar list uses a top/bottom fade mask on mobile to hint at scrollable
content.

**Header:** Settings, Analytics, sound, and notification icons
consolidated into a single meatball menu dropdown on mobile.
Sound/notification toggles use `e.preventDefault()` to keep the menu
open for state feedback. Chime and notification state is lifted into
`AgentCreatePage` and passed down, so the mobile meatball menu and the
desktop `ChimeButton`/`WebPushButton` stay in sync.

**Workspace pill:** Icon-only on mobile (`size-7` round button with
`StatusIcon`), full pill on desktop. Tooltip hidden on mobile to prevent
ghost tooltip after dropdown close.

**Plus menu:** Workspace picker replaces the flyout with an inline
sub-panel on mobile (back button + search list). Desktop flyout
unchanged. `modal={false}` prevents double-tap when switching between
dropdowns.

**Model selector:** Truncated via `shrink` + `min-w-0` on mobile
(flex-based, no fixed max-width), inline provider/context subtext per
item, tooltip hidden on mobile. Added `open` / `onOpenChange` /
`onTriggerTouchStart` props for external control.

**Consistency:** All back/close buttons normalized to `ArrowLeftIcon`.
Right panel, sidebar settings, header `mobileBack`, and workspace
sub-panel all match the chat top bar pattern.

**Misc polish:** Chat tree nodes use `select-none` +
`-webkit-touch-callout:none` on coarse pointers to suppress the
long-press selection/callout on mobile.

<details>
<summary>Files changed (18)</summary>

- `site/src/index.css` — mobile dropdown CSS with 3 position variants
- `site/src/utils/mobile.ts` — new `isBelowMdViewport()` helper
(`<768px`)
- `site/src/pages/AgentsPage/AgentChatPageView.tsx` — bottom padding
`pb-3`
- `site/src/pages/AgentsPage/AgentCreatePage.tsx` — lift chime + webpush
state; pass handlers to header and buttons
- `site/src/pages/AgentsPage/AgentsPageView.tsx` — `contents` wrapper +
sidebar `border-b`
- `site/src/pages/AgentsPage/components/AgentChatInput.tsx` —
`ResizeObserver` composer ref, `plusMenuView` state, inline workspace
picker, `modal={false}`, mobile branching via `isBelowMdViewport`, bg
- `site/src/pages/AgentsPage/components/AgentCreateForm.tsx` —
`order-last` + `items-end` on mobile
- `site/src/pages/AgentsPage/components/AgentPageHeader.tsx` — meatball
menu (controlled chime/webpush props), `ArrowLeftIcon`, `order-first`,
padding, desktop/mobile branching via `matchMedia`
- `site/src/pages/AgentsPage/components/AgentPageHeader.stories.tsx` —
new Storybook coverage + `play` assertions that toggle state stays in
sync across breakpoints
- `site/src/pages/AgentsPage/components/ChimeButton.tsx` — optional
controlled `enabled` / `onToggle` props
- `site/src/pages/AgentsPage/components/WebPushButton.tsx` — optional
controlled `webPush` / `onToggle` props
-
`site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.tsx`
— full-width dropdown class
- `site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx`
— truncation, inline subtext, tooltip hidden on mobile, new props
(`open`, `onOpenChange`, `onTriggerTouchStart`)
- `site/src/pages/AgentsPage/components/ChatTopBar.tsx` — full-width
dropdown class
- `site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx` —
full-width dropdown class (mobile branch)
- `site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx` —
`ArrowLeftIcon`, filter dropdown class, top/bottom fade mask on scroll
area, `select-none` on tree nodes
- `site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx` —
`ArrowLeftIcon`, padding, back button placement
- `site/src/pages/AgentsPage/components/WorkspacePill.tsx` — compact
icon trigger, tooltip hidden on mobile, full-width dropdown class

</details>

> 🤖 Generated by Coder Agents

---------

Co-authored-by: Jaayden Halko <jaayden@coder.com>
This commit is contained in:
TJ
2026-04-23 13:41:44 +01:00
committed by GitHub
co-authored by Jaayden Halko
parent 537e35dd94
commit 95386f526a
18 changed files with 1071 additions and 406 deletions
+75
View File
@@ -156,6 +156,81 @@
}
}
@layer components {
/* Map each stripe variant to a color token so the
pseudo-element rules can stay DRY. */
.navbar-stripe-devel {
--stripe-color: var(--content-warning);
}
.navbar-stripe-rc {
--stripe-color: var(--border-sky);
}
/* Thin stripe bars at the top and bottom edges of the
navbar. Using pseudo-elements keeps the stripes out of
the content area so nav links stay readable. */
.navbar-stripe-devel::before,
.navbar-stripe-devel::after,
.navbar-stripe-rc::before,
.navbar-stripe-rc::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 4px;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
hsl(var(--stripe-color) / 0.5) 4px,
hsl(var(--stripe-color) / 0.5) 8px
);
pointer-events: none;
}
.navbar-stripe-devel::before,
.navbar-stripe-rc::before {
top: 0;
}
.navbar-stripe-devel::after,
.navbar-stripe-rc::after {
bottom: 0;
}
@media (max-width: 767px) {
/*
* Full-width mobile dropdowns. We set a --mobile-dropdown-bottom
* custom property on the chat input container so the dropdown
* position tracks the actual input box, not a hardcoded offset.
*/
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown) {
position: fixed !important;
left: 1rem !important;
width: calc(100vw - 2rem) !important;
min-width: 0 !important;
transform: none !important;
bottom: var(--mobile-dropdown-bottom, 5rem) !important;
top: auto !important;
}
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown-top) {
bottom: auto !important;
top: var(--mobile-dropdown-top, 3.5rem) !important;
}
[data-radix-popper-content-wrapper]:has(
> .mobile-full-width-dropdown-top-below-header
) {
bottom: auto !important;
top: 5rem !important;
}
.mobile-full-width-dropdown {
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
}
}
}
@layer base {
* {
@apply border-border;
@@ -467,7 +467,7 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
/>
</div>
</ChatScrollContainer>
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<ChatPageInput
organizationId={organizationId}
store={store}
@@ -612,7 +612,7 @@ export const AgentChatPageLoadingView: FC<AgentChatPageLoadingViewProps> = ({
</div>
</div>
</div>
<div className="shrink-0 overflow-y-auto px-4 pb-4 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<div className="shrink-0 overflow-y-auto px-4 pb-3 md:pb-0 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<AgentChatInput
onSend={() => {}}
initialValue=""
+34 -4
View File
@@ -1,6 +1,8 @@
import type { FC } from "react";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import {
chatModelConfigs,
chatModels,
@@ -9,6 +11,7 @@ import {
} from "#/api/queries/chats";
import { workspaces } from "#/api/queries/workspaces";
import type * as TypesGen from "#/api/typesGenerated";
import { useWebpushNotifications } from "#/contexts/useWebpushNotifications";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import {
AgentCreateForm,
@@ -17,6 +20,7 @@ import {
import { AgentPageHeader } from "./components/AgentPageHeader";
import { ChimeButton } from "./components/ChimeButton";
import { WebPushButton } from "./components/WebPushButton";
import { getChimeEnabled, setChimeEnabled } from "./utils/chime";
import { getModelOptionsFromConfigs } from "./utils/modelOptions";
import { buildAgentChatPath } from "./utils/navigation";
@@ -33,6 +37,8 @@ const AgentCreatePage: FC = () => {
const mcpServersQuery = useQuery(mcpServerConfigs());
const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 }));
const createMutation = useMutation(createChat(queryClient));
const webPush = useWebpushNotifications();
const [chimeEnabled, setChimeEnabledState] = useState(getChimeEnabled);
const catalogModelOptions = getModelOptionsFromConfigs(
chatModelConfigsQuery.data,
@@ -77,11 +83,35 @@ const AgentCreatePage: FC = () => {
navigate(buildAgentChatPath({ chatId: createdChat.id }));
};
const handleChimeToggle = () => {
const next = !chimeEnabled;
setChimeEnabledState(next);
setChimeEnabled(next);
};
const handleNotificationToggle = async () => {
try {
if (webPush.subscribed) {
await webPush.unsubscribe();
} else {
await webPush.subscribe();
}
} catch (error) {
const action = webPush.subscribed ? "disable" : "enable";
toast.error(getErrorMessage(error, `Failed to ${action} notifications.`));
}
};
return (
<>
<AgentPageHeader>
<ChimeButton />
<WebPushButton />
<AgentPageHeader
chimeEnabled={chimeEnabled}
onToggleChime={handleChimeToggle}
webPush={webPush}
onToggleNotifications={handleNotificationToggle}
>
<ChimeButton enabled={chimeEnabled} onToggle={handleChimeToggle} />
<WebPushButton webPush={webPush} onToggle={handleNotificationToggle} />
</AgentPageHeader>
<AgentCreateForm
onCreateChat={handleCreateChat}
+2 -2
View File
@@ -165,7 +165,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default"
: isSettingsDetail || isAnalytics
? "hidden md:block shrink-0"
: "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0",
: "order-2 md:order-none flex-1 min-h-0 border-b border-border-default md:flex-none md:border-t-0 md:border-b-0",
isSidebarCollapsed && "md:hidden",
)}
>
@@ -207,7 +207,7 @@ export const AgentsPageView: FC<AgentsPageViewProps> = ({
!agentId &&
!isSettingsDetail &&
sidebarView.panel === "chats" &&
"order-1 md:order-none flex-none md:flex-1",
"contents md:flex md:flex-1 md:flex-col",
)}
>
<Outlet context={outletContextValue} />
@@ -1,4 +1,5 @@
import {
ArrowLeftIcon,
ArrowUpIcon,
CheckIcon,
ChevronRightIcon,
@@ -49,7 +50,7 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { countInvisibleCharacters } from "#/utils/invisibleUnicode";
import { isMobileViewport } from "#/utils/mobile";
import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile";
import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth";
import { useOverflowCount } from "../hooks/useOverflowCount";
import { useSpeechRecognition } from "../hooks/useSpeechRecognition";
@@ -310,6 +311,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
null,
);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const [plusMenuView, setPlusMenuView] = useState<"main" | "workspace">(
"main",
);
const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false);
const [mcpConnectingId, setMcpConnectingId] = useState<string | null>(null);
const mcpPopupRef = useRef<Window | null>(null);
@@ -442,6 +446,35 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [composerElement, setComposerElement] = useState<HTMLDivElement | null>(
null,
);
useEffect(() => {
if (!composerElement) return;
const update = () => {
const rect = composerElement.getBoundingClientRect();
const bottom = Math.max(0, window.innerHeight - rect.bottom);
document.documentElement.style.setProperty(
"--mobile-dropdown-bottom",
`${bottom}px`,
);
};
update();
const ro = new ResizeObserver(update);
ro.observe(composerElement);
window.addEventListener("resize", update);
const viewport = window.visualViewport;
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
document.documentElement.style.removeProperty("--mobile-dropdown-bottom");
};
}, [composerElement]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && onAttach) {
onAttach(Array.from(e.target.files));
@@ -668,8 +701,9 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
/>
)}
<div
ref={setComposerElement}
className={cn(
"rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40",
"rounded-2xl border border-border-default/80 bg-surface-secondary md:bg-surface-secondary/45 p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40",
isDragging && "ring-2 ring-content-link/40",
isEditingHistoryMessage &&
"shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
@@ -776,7 +810,15 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-1">
{/* Plus menu */}
<Popover open={plusMenuOpen} onOpenChange={setPlusMenuOpen}>
<Popover
modal={false}
open={plusMenuOpen}
onOpenChange={(open) => {
setPlusMenuOpen(open);
if (!open) setPlusMenuView("main");
}}
>
{" "}
<PopoverTrigger asChild>
<Button
type="button"
@@ -792,152 +834,214 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<PopoverContent
side="bottom"
align="start"
className="w-auto min-w-[200px] p-1"
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-auto min-w-[200px] p-1"
>
{onAttach && (
<button
type="button"
onClick={() => {
setPlusMenuOpen(false);
fileInputRef.current?.click();
}}
className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary"
>
<ImageIcon className="size-3.5 shrink-0" />
Attach image
</button>
)}
{onPlanModeToggle && (
<button
type="button"
role="menuitemcheckbox"
aria-checked={planModeEnabled}
onClick={handlePlanModeToggle}
disabled={isDisabled}
className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<PencilIcon className="size-3.5 shrink-0" />
<span>Plan first</span>
{planModeEnabled && (
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
)}
</button>
)}
{workspaceOptions && onWorkspaceChange && (
<Popover
open={workspacePickerOpen}
onOpenChange={setWorkspacePickerOpen}
>
<PopoverTrigger asChild>
{plusMenuView === "workspace" ? (
<div className="p-0">
<button
type="button"
onClick={() => setPlusMenuView("main")}
className="flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary"
>
<ArrowLeftIcon className="size-3.5 shrink-0" />
<span>Back</span>
</button>
<Separator className="my-1" />
<Command loop>
<CommandInput
placeholder="Search workspaces..."
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs">
No workspaces found
</CommandEmpty>
<CommandGroup>
{workspaceOptions?.map((workspace) => (
<CommandItem
className="text-xs font-normal"
key={workspace.id}
value={workspace.name}
onSelect={() => {
onWorkspaceChange?.(workspace.id);
setPlusMenuOpen(false);
}}
>
{workspace.name}
{selectedWorkspaceId === workspace.id && (
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
) : (
<>
{onAttach && (
<button
type="button"
disabled={isDisabled || isWorkspaceLoading}
onClick={() => {
setPlusMenuOpen(false);
fileInputRef.current?.click();
}}
className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary"
>
<ImageIcon className="size-3.5 shrink-0" />
Attach image
</button>
)}
{onPlanModeToggle && (
<button
type="button"
role="menuitemcheckbox"
aria-checked={planModeEnabled}
onClick={handlePlanModeToggle}
disabled={isDisabled}
className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<MonitorIcon className="size-3.5 shrink-0" />
<span>Attach workspace</span>
<ChevronRightIcon
className={cn(
"ml-auto size-icon-sm transition-transform",
workspacePickerOpen && "rotate-180",
)}
/>
<PencilIcon className="size-3.5 shrink-0" />
<span>Plan first</span>
{planModeEnabled && (
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
)}
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-64 p-0"
>
<Command loop>
<CommandInput
placeholder="Search workspaces..."
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs">
No workspaces found
</CommandEmpty>
<CommandGroup>
{workspaceOptions.map((workspace) => (
<CommandItem
className="text-xs font-normal"
key={workspace.id}
value={workspace.name}
onSelect={() => {
onWorkspaceChange(workspace.id);
setWorkspacePickerOpen(false);
setPlusMenuOpen(false);
}}
>
{workspace.name}
{selectedWorkspaceId === workspace.id && (
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
{enabledMcpServers.length > 0 && (
<>
<Separator className="my-1" />
{enabledMcpServers.map((server) => {
const isForceOn = server.availability === "force_on";
const isSelected =
isForceOn ||
(selectedMCPServerIds?.includes(server.id) ?? false);
const needsAuth =
server.auth_type === "oauth2" && !server.auth_connected;
const isConnecting = mcpConnectingId === server.id;
return (
<div
key={server.id}
className="flex items-center gap-1.5 px-1 py-1.5"
)}
{workspaceOptions &&
onWorkspaceChange &&
(isBelowMdViewport() ? (
<button
type="button"
disabled={isDisabled || isWorkspaceLoading}
onClick={() => setPlusMenuView("workspace")}
className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{server.icon_url ? (
<ExternalImage
src={server.icon_url}
alt=""
className="size-3.5 shrink-0 rounded-sm"
/>
) : (
<ServerIcon className="size-3.5 shrink-0 text-content-secondary" />
)}
<span className="min-w-0 flex-1 truncate text-xs text-content-secondary">
{server.display_name}
</span>
{needsAuth ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-[10px] leading-none"
onClick={() => handleMcpConnect(server)}
disabled={isDisabled || mcpConnectingId !== null}
<MonitorIcon className="size-3.5 shrink-0" />
<span>Attach workspace</span>
<ChevronRightIcon className="ml-auto size-icon-sm" />
</button>
) : (
<Popover
open={workspacePickerOpen}
onOpenChange={setWorkspacePickerOpen}
>
<PopoverTrigger asChild>
<button
type="button"
disabled={isDisabled || isWorkspaceLoading}
className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{isConnecting ? (
<Spinner loading className="size-2.5" />
) : null}
Auth
</Button>
) : (
<Switch
size="sm"
checked={isSelected}
onCheckedChange={(checked) =>
handleMcpToggle(server.id, checked)
}
disabled={isDisabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
)}
</div>
);
})}
<MonitorIcon className="size-3.5 shrink-0" />
<span>Attach workspace</span>
<ChevronRightIcon
className={cn(
"ml-auto size-icon-sm transition-transform",
workspacePickerOpen && "rotate-180",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={8}
className="w-64 p-0"
>
<Command loop>
<CommandInput
placeholder="Search workspaces..."
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs">
No workspaces found
</CommandEmpty>
<CommandGroup>
{workspaceOptions.map((workspace) => (
<CommandItem
className="text-xs font-normal"
key={workspace.id}
value={workspace.name}
onSelect={() => {
onWorkspaceChange(workspace.id);
setWorkspacePickerOpen(false);
setPlusMenuOpen(false);
}}
>
{workspace.name}
{selectedWorkspaceId === workspace.id && (
<CheckIcon className="ml-auto size-icon-sm shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
))}
{enabledMcpServers.length > 0 && (
<>
<Separator className="my-1" />
{enabledMcpServers.map((server) => {
const isForceOn = server.availability === "force_on";
const isSelected =
isForceOn ||
(selectedMCPServerIds?.includes(server.id) ??
false);
const needsAuth =
server.auth_type === "oauth2" &&
!server.auth_connected;
const isConnecting = mcpConnectingId === server.id;
return (
<div
key={server.id}
className="flex items-center gap-1.5 px-1 py-1.5"
>
{server.icon_url ? (
<ExternalImage
src={server.icon_url}
alt=""
className="size-3.5 shrink-0 rounded-sm"
/>
) : (
<ServerIcon className="size-3.5 shrink-0 text-content-secondary" />
)}
<span className="min-w-0 flex-1 truncate text-xs text-content-secondary">
{server.display_name}
</span>
{needsAuth ? (
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 px-2 text-[10px] leading-none"
onClick={() => handleMcpConnect(server)}
disabled={
isDisabled || mcpConnectingId !== null
}
>
{isConnecting ? (
<Spinner loading className="size-2.5" />
) : null}
Auth
</Button>
) : (
<Switch
size="sm"
checked={isSelected}
onCheckedChange={(checked) =>
handleMcpToggle(server.id, checked)
}
disabled={isDisabled || isForceOn}
aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`}
/>
)}
</div>
);
})}
</>
)}
</>
)}
</PopoverContent>
@@ -954,14 +1058,15 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
formatProviderLabel={formatProviderLabel}
dropdownSide="top"
dropdownAlign="center"
enableMobileFullWidthDropdown
/>
)}
{planModeEnabled && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary">
<span className="hidden shrink-0 items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary md:inline-flex">
<PencilIcon className="size-3" />
Planning
</span>
)}
)}{" "}
{/* Badge row — all badges and the pill always
* render so the DOM structure never changes.
* Overflow badges use invisible + order-1 to
@@ -969,13 +1074,15 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
* when there's no overflow but still occupies
* layout space, preventing measurement flicker. */}
{workspace && workspaceAgent && chatId && (
<WorkspacePill
workspace={workspace}
agent={workspaceAgent}
chatId={chatId}
sshCommand={sshCommand}
folder={folder}
/>
<span className="ml-1 md:ml-0">
<WorkspacePill
workspace={workspace}
agent={workspaceAgent}
chatId={chatId}
sshCommand={sshCommand}
folder={folder}
/>
</span>
)}
<div
ref={badgeContainerRef}
@@ -1017,7 +1124,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
<PopoverContent
side="top"
align="start"
className="flex w-auto max-w-64 flex-wrap gap-1 p-2"
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom flex w-auto max-w-64 flex-wrap gap-1 p-2"
>
{overflowBadges.map((badge) => (
<ToolBadge
@@ -412,7 +412,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
return (
<>
<div className="flex min-h-0 flex-1 items-start justify-center overflow-auto p-4 pt-12 md:h-full md:items-center md:pt-4">
<div className="order-last flex min-h-0 flex-none items-end justify-center overflow-auto p-4 pb-4 md:order-none md:h-full md:flex-1 md:items-center md:pt-12">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
{isForbidden ? (
<ChatAccessDeniedAlert />
@@ -0,0 +1,231 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useMemo, useState } from "react";
import { Outlet } from "react-router";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { withDashboardProvider } from "#/testHelpers/storybook";
import { AgentPageHeader } from "./AgentPageHeader";
import { ChimeButton } from "./ChimeButton";
import { WebPushButton } from "./WebPushButton";
type MediaChangeListener = (event: MediaQueryListEvent) => void;
const createMatchMediaController = (initialDesktop: boolean) => {
let desktop = initialDesktop;
const listeners = new Set<MediaChangeListener>();
const eventListenerWrappers = new Map<
EventListenerOrEventListenerObject,
MediaChangeListener
>();
const getWrappedEventListener = (
listener: EventListenerOrEventListenerObject | null,
): MediaChangeListener | undefined => {
if (!listener) {
return undefined;
}
const existing = eventListenerWrappers.get(listener);
if (existing) {
return existing;
}
const wrapped: MediaChangeListener = (event) => {
if (typeof listener === "function") {
listener(event);
return;
}
listener.handleEvent(event);
};
eventListenerWrappers.set(listener, wrapped);
return wrapped;
};
const dispatch = (): void => {
const event = {
matches: desktop,
media: "(min-width: 768px)",
} as MediaQueryListEvent;
for (const listener of listeners) {
listener(event);
}
};
const matchMedia = ((query: string): MediaQueryList => {
const isDesktopQuery = /\(\s*min-width\s*:\s*768px\s*\)/.test(query);
return {
matches: isDesktopQuery ? desktop : false,
media: query,
onchange: null,
addEventListener: (
_type: string,
listener: EventListenerOrEventListenerObject | null,
) => {
if (isDesktopQuery) {
const wrapped = getWrappedEventListener(listener);
if (wrapped) {
listeners.add(wrapped);
}
}
},
removeEventListener: (
_type: string,
listener: EventListenerOrEventListenerObject | null,
) => {
if (isDesktopQuery) {
const wrapped = getWrappedEventListener(listener);
if (wrapped) {
listeners.delete(wrapped);
}
if (listener) {
eventListenerWrappers.delete(listener);
}
}
},
dispatchEvent: () => true,
addListener: (listener: MediaChangeListener) => {
if (isDesktopQuery) {
listeners.add(listener);
}
},
removeListener: (listener: MediaChangeListener) => {
if (isDesktopQuery) {
listeners.delete(listener);
}
},
};
}) as typeof window.matchMedia;
return {
matchMedia,
setDesktop: (value: boolean) => {
desktop = value;
dispatch();
},
};
};
const HeaderStateHarness: FC = () => {
const [chimeEnabled, setChimeEnabled] = useState(true);
const [webpushSubscribed, setWebpushSubscribed] = useState(false);
const [webpushLoading, setWebpushLoading] = useState(false);
const webPush = useMemo(
() => ({
enabled: true,
subscribed: webpushSubscribed,
loading: webpushLoading,
subscribe: async () => {
setWebpushLoading(true);
await Promise.resolve();
setWebpushSubscribed(true);
setWebpushLoading(false);
},
unsubscribe: async () => {
setWebpushLoading(true);
await Promise.resolve();
setWebpushSubscribed(false);
setWebpushLoading(false);
},
}),
[webpushLoading, webpushSubscribed],
);
const handleNotificationToggle = async () => {
if (webpushSubscribed) {
await webPush.unsubscribe();
} else {
await webPush.subscribe();
}
};
return (
<AgentPageHeader
chimeEnabled={chimeEnabled}
onToggleChime={() => setChimeEnabled((enabled) => !enabled)}
webPush={webPush}
onToggleNotifications={handleNotificationToggle}
>
<ChimeButton
enabled={chimeEnabled}
onToggle={() => setChimeEnabled((enabled) => !enabled)}
/>
<WebPushButton webPush={webPush} onToggle={handleNotificationToggle} />
</AgentPageHeader>
);
};
const meta: Meta<typeof AgentPageHeader> = {
title: "pages/AgentsPage/AgentPageHeader",
component: AgentPageHeader,
decorators: [withDashboardProvider],
beforeEach: () => {
const originalMatchMedia = window.matchMedia;
const controller = createMatchMediaController(true);
window.matchMedia = controller.matchMedia;
return () => {
window.matchMedia = originalMatchMedia;
};
},
};
export default meta;
type Story = StoryObj<typeof AgentPageHeader>;
export const ToggleStateStaysInSyncAcrossBreakpoints: Story = {
render: () => <HeaderStateHarness />,
parameters: {
reactRouter: {
location: {
path: "/agents",
},
routing: [
{
path: "/",
element: (
<Outlet
context={{
isSidebarCollapsed: false,
onExpandSidebar: () => undefined,
}}
/>
),
children: [{ path: "agents", useStoryElement: true }],
},
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const desktopSoundButton = await canvas.findByRole("button", {
name: "Mute completion chime",
});
await userEvent.click(desktopSoundButton);
await waitFor(() => {
expect(
canvas.getByRole("button", { name: "Enable completion chime" }),
).toBeVisible();
});
const desktopNotificationButton = canvas.getByRole("button", {
name: "Enable notifications",
});
await userEvent.click(desktopNotificationButton);
await waitFor(() => {
expect(
canvas.getByRole("button", { name: "Disable notifications" }),
).toBeVisible();
});
await userEvent.click(
canvas.getByRole("button", { name: "Disable notifications" }),
);
await waitFor(() => {
expect(
canvas.getByRole("button", { name: "Enable notifications" }),
).toBeVisible();
});
},
};
@@ -1,49 +1,130 @@
import {
ArrowLeftIcon,
BarChart3Icon,
ChevronLeftIcon,
BellIcon,
BellOffIcon,
EllipsisIcon,
PanelLeftIcon,
SettingsIcon,
Volume2Icon,
VolumeOffIcon,
} from "lucide-react";
import type { FC, ReactNode } from "react";
import { useEffect, useState } from "react";
import { Link, NavLink, useLocation, useOutletContext } from "react-router";
import { toast } from "sonner";
import { getErrorMessage } from "#/api/errors";
import { Button } from "#/components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "#/components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "#/components/ExternalImage/ExternalImage";
import { CoderIcon } from "#/components/Icons/CoderIcon";
import { Spinner } from "#/components/Spinner/Spinner";
import { useWebpushNotifications } from "#/contexts/useWebpushNotifications";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { cn } from "#/utils/cn";
import type { AgentsOutletContext } from "../AgentsPageView";
import { isSettingsView, sidebarViewFromPath } from "./Sidebar/AgentsSidebar";
import { getChimeEnabled, setChimeEnabled } from "../utils/chime";
interface AgentPageHeaderProps {
children?: ReactNode;
/** When set, shows a back link on mobile instead of the logo
* and hides the settings/analytics nav buttons. */
mobileBack?: { to: string; label: string };
chimeEnabled?: boolean;
onToggleChime?: () => void;
webPush?: ReturnType<typeof useWebpushNotifications>;
onToggleNotifications?: () => Promise<void> | void;
}
export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
children,
mobileBack,
chimeEnabled: controlledChimeEnabled,
onToggleChime,
webPush: controlledWebPush,
onToggleNotifications,
}) => {
const { isSidebarCollapsed, onExpandSidebar } =
useOutletContext<AgentsOutletContext>();
const { appearance } = useDashboard();
const logoUrl = appearance.logo_url;
const location = useLocation();
const sidebarView = sidebarViewFromPath(location.pathname);
const isSettingsPanel = isSettingsView(sidebarView);
const [internalChimeEnabled, setInternalChimeEnabled] =
useState(getChimeEnabled);
const internalWebPush = useWebpushNotifications();
const chimeEnabled = controlledChimeEnabled ?? internalChimeEnabled;
const webPush = controlledWebPush ?? internalWebPush;
const [isDesktop, setIsDesktop] = useState<boolean>(() => {
return window.matchMedia("(min-width: 768px)").matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia("(min-width: 768px)");
const onMediaChange = (event: MediaQueryListEvent) => {
setIsDesktop(event.matches);
};
setIsDesktop(mediaQuery.matches);
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", onMediaChange);
} else {
mediaQuery.addListener(onMediaChange);
}
return () => {
if (typeof mediaQuery.removeEventListener === "function") {
mediaQuery.removeEventListener("change", onMediaChange);
} else {
mediaQuery.removeListener(onMediaChange);
}
};
}, []);
const handleChimeToggle = () => {
if (onToggleChime) {
onToggleChime();
return;
}
const next = !chimeEnabled;
setInternalChimeEnabled(next);
setChimeEnabled(next);
};
const handleNotificationToggle = async () => {
if (onToggleNotifications) {
await onToggleNotifications();
return;
}
try {
if (webPush.subscribed) {
await webPush.unsubscribe();
} else {
await webPush.subscribe();
}
} catch (error) {
const action = webPush.subscribed ? "disable" : "enable";
toast.error(getErrorMessage(error, `Failed to ${action} notifications.`));
}
};
return (
<div className="flex shrink-0 items-center gap-2 px-4 pt-3 pb-0.5 md:py-0.5">
<div className="order-first flex shrink-0 items-center gap-2 pl-4 pr-2 pt-3 pb-0.5 md:order-none md:px-4 md:py-0.5">
{mobileBack ? (
<Link
to={mobileBack.to}
className="inline-flex shrink-0 items-center gap-1 text-sm text-content-secondary no-underline hover:text-content-primary md:hidden"
<Button
asChild
variant="subtle"
size="icon"
aria-label={mobileBack.label}
className="h-7 w-7 shrink-0 md:hidden"
>
<ChevronLeftIcon className="h-4 w-4" />
{mobileBack.label}
</Link>
<Link to={mobileBack.to}>
<ArrowLeftIcon />
</Link>
</Button>
) : (
<NavLink to="/workspaces" className="inline-flex shrink-0 md:hidden">
{logoUrl ? (
@@ -65,41 +146,74 @@ export const AgentPageHeader: FC<AgentPageHeaderProps> = ({
</Button>
)}
<div className="min-w-0 flex-1" />
{/* Mobile-only nav buttons mirroring the sidebar toolbar
* which is hidden below the md breakpoint. */}
{!mobileBack && (
<div className="flex items-center gap-0.5 md:hidden">
<Button
asChild
variant="subtle"
size="icon"
aria-label="Settings"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
isSettingsPanel && "text-content-primary",
)}
{children && isDesktop && (
<div className="hidden items-center gap-2 md:flex">{children}</div>
)}
{/* Mobile: meatball menu with all actions */}
{!mobileBack && !isDesktop && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="subtle"
size="icon"
aria-label="More options"
className="h-7 w-7 text-content-secondary hover:text-content-primary md:hidden"
>
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top [&_[role=menuitem]]:text-sm"
>
<Link to="/agents/settings" state={{ from: location.pathname }}>
<SettingsIcon />
</Link>
</Button>
<Button
asChild
variant="subtle"
size="icon"
aria-label="Analytics"
className={cn(
"h-7 w-7 min-w-0 text-content-secondary hover:text-content-primary",
sidebarView.panel === "analytics" && "text-content-primary",
<DropdownMenuItem asChild>
<Link to="/agents/settings" state={{ from: location.pathname }}>
<SettingsIcon className="size-icon-sm" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/agents/analytics">
<BarChart3Icon className="size-icon-sm" />
Analytics
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
handleChimeToggle();
}}
>
{chimeEnabled ? (
<Volume2Icon className="size-icon-sm" />
) : (
<VolumeOffIcon className="size-icon-sm" />
)}
{chimeEnabled ? "Turn sound off" : "Turn sound on"}
</DropdownMenuItem>
{webPush.enabled && (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
void handleNotificationToggle();
}}
disabled={webPush.loading}
>
{webPush.loading ? (
<Spinner size="sm" loading className="size-icon-sm" />
) : webPush.subscribed ? (
<BellIcon className="size-icon-sm" />
) : (
<BellOffIcon className="size-icon-sm" />
)}
{webPush.subscribed
? "Turn notifications off"
: "Turn notifications on"}
</DropdownMenuItem>
)}
>
<Link to="/agents/analytics">
<BarChart3Icon />
</Link>
</Button>
</div>
)}{" "}
{children && <div className="flex items-center gap-2">{children}</div>}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};
@@ -83,7 +83,7 @@ export const CompactOrgSelector: FC<CompactOrgSelectorProps> = ({
<PopoverContent
side={dropdownSide}
align={dropdownAlign}
className="w-64 p-0"
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-64 p-0"
>
<Command loop>
<CommandInput placeholder="Find organization…" className="text-xs" />
@@ -35,6 +35,10 @@ interface ModelSelectorProps {
dropdownSide?: "top" | "bottom" | "left" | "right";
dropdownAlign?: "start" | "center" | "end";
contentClassName?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onTriggerTouchStart?: () => void;
enableMobileFullWidthDropdown?: boolean;
}
const defaultFormatProviderLabel = (provider: string): string => {
@@ -78,6 +82,10 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
dropdownSide = "bottom",
dropdownAlign = "start",
contentClassName,
open,
onOpenChange,
onTriggerTouchStart,
enableMobileFullWidthDropdown = false,
}) => {
const selectedModel = options.find((option) => option.id === value);
const optionsByProvider = (() => {
@@ -97,13 +105,20 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
const isDisabled = disabled || options.length === 0;
return (
<Select value={value} onValueChange={onValueChange} disabled={isDisabled}>
<Select
value={value}
onValueChange={onValueChange}
disabled={isDisabled}
open={open}
onOpenChange={onOpenChange}
>
<SelectTrigger
aria-label={selectedModel ? getOptionLabel(selectedModel) : placeholder}
className={cn(
"h-8 w-auto gap-1.5 border-0 bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
"h-8 min-w-0 shrink md:shrink-0 md:w-auto gap-0.5 md:gap-1.5 border-0 bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>span]:truncate [&>svg]:shrink-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
className,
)}
onTouchStart={onTriggerTouchStart}
>
<SelectValue placeholder={placeholder}>
{selectedModel ? getOptionLabel(selectedModel) : placeholder}
@@ -112,7 +127,12 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
<SelectContent
side={dropdownSide}
align={dropdownAlign}
className={cn("[&_[role=option]]:text-xs", contentClassName)}
className={cn(
enableMobileFullWidthDropdown &&
"mobile-full-width-dropdown mobile-full-width-dropdown-bottom",
"border-border-default [&_[role=option]]:text-xs",
contentClassName,
)}
>
<TooltipProvider delayDuration={300}>
{optionsByProvider.map(([provider, providerOptions]) => {
@@ -124,6 +144,7 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
key={option.id}
option={option}
providerLabel={providerLabel}
isSelected={option.id === value}
/>
))}
</SelectGroup>
@@ -143,24 +164,49 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
interface ModelOptionItemProps {
option: ModelSelectorOption;
providerLabel: string;
isSelected: boolean;
}
const ModelOptionItem: FC<ModelOptionItemProps> = ({
option,
providerLabel,
isSelected,
}) => {
const label = getOptionLabel(option);
const contextInfo =
option.contextLimit != null && option.contextLimit > 0
? formatContextLimit(option.contextLimit)
: null;
const subtext = contextInfo
? `via ${providerLabel}, ${contextInfo}`
: `via ${providerLabel}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<SelectItem value={option.id}>{getOptionLabel(option)}</SelectItem>
<SelectItem
value={option.id}
className={cn(isSelected && "bg-surface-secondary")}
>
<span className="flex flex-col">
<span>{label}</span>
<span className="text-content-secondary text-[11px] leading-tight md:hidden">
{subtext}
</span>
</span>
</SelectItem>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={4} className="px-2.5 py-1.5">
<TooltipContent
side="right"
sideOffset={4}
className="hidden px-2.5 py-1.5 md:block"
>
<span className="block font-semibold text-content-primary leading-tight">
{getOptionLabel(option)} via {providerLabel}
{label} via {providerLabel}
</span>
{option.contextLimit != null && option.contextLimit > 0 && (
{contextInfo && (
<span className="block text-content-secondary leading-tight">
{formatContextLimit(option.contextLimit)}
{contextInfo}
</span>
)}
</TooltipContent>
@@ -188,7 +188,7 @@ export const ChatTopBar: FC<ChatTopBarProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top [&_[role=menuitem]]:text-[13px]"
>
{!isArchived && onRegenerateTitle && (
<>
@@ -8,12 +8,23 @@ import {
} from "#/components/Tooltip/Tooltip";
import { getChimeEnabled, setChimeEnabled } from "../utils/chime";
export const ChimeButton: FC = () => {
const [enabled, setEnabled] = useState(getChimeEnabled);
interface ChimeButtonProps {
enabled?: boolean;
onToggle?: () => void;
}
export const ChimeButton: FC<ChimeButtonProps> = ({ enabled, onToggle }) => {
const [internalEnabled, setInternalEnabled] = useState(getChimeEnabled);
const isControlled = enabled !== undefined && onToggle !== undefined;
const isEnabled = isControlled ? enabled : internalEnabled;
const handleClick = () => {
const next = !enabled;
setEnabled(next);
if (isControlled) {
onToggle();
return;
}
const next = !internalEnabled;
setInternalEnabled(next);
setChimeEnabled(next);
};
@@ -25,11 +36,11 @@ export const ChimeButton: FC = () => {
size="icon"
onClick={handleClick}
aria-label={
enabled ? "Mute completion chime" : "Enable completion chime"
isEnabled ? "Mute completion chime" : "Enable completion chime"
}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{enabled ? (
{isEnabled ? (
<Volume2Icon className="text-content-success" />
) : (
<VolumeOffIcon className="text-content-secondary" />
@@ -37,7 +48,7 @@ export const ChimeButton: FC = () => {
</Button>
</TooltipTrigger>
<TooltipContent>
{enabled ? "Disable completion sound" : "Enable completion sound"}
{isEnabled ? "Disable completion sound" : "Enable completion sound"}
</TooltipContent>
</Tooltip>
);
@@ -262,7 +262,10 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({
return (
<Popover>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
<PopoverContent side="top" className="w-auto max-w-72 px-3 py-2">
<PopoverContent
side="top"
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-auto max-w-72 px-3 py-2"
>
{panelContent}
</PopoverContent>
</Popover>
@@ -20,11 +20,11 @@ import {
AlertTriangleIcon,
ArchiveIcon,
ArchiveRestoreIcon,
ArrowLeftIcon,
BotIcon,
BoxesIcon,
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
CoinsIcon,
EllipsisIcon,
@@ -598,7 +598,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
"group relative flex min-w-0 items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
"group relative flex min-w-0 select-none [@media(pointer:coarse)]:[-webkit-touch-callout:none] items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary has-[[data-state=open]]:bg-surface-tertiary",
"has-[[aria-current=page]]:bg-surface-quaternary/25 has-[[aria-current=page]]:text-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
isChildNode &&
@@ -991,7 +991,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="[&_[role=menuitem]]:text-[13px]"
className="mobile-full-width-dropdown mobile-full-width-dropdown-top-below-header [&_[role=menuitem]]:text-[13px]"
>
<DropdownMenuItem onSelect={() => onArchivedFilterChange?.("active")}>
Active
@@ -1127,147 +1127,157 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
disabled={isCreating}
/>
</div>
<ScrollArea
className="flex-1 [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
>
<div className="flex flex-col gap-2 px-2 py-3 md:px-2">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
{onRetryLoad && (
<Button size="sm" variant="outline" onClick={onRetryLoad}>
Retry
</Button>
)}
</div>
) : isLoading ? (
<>
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
<div className="relative min-h-0 flex-1">
<ScrollArea
className="h-full [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
viewportClassName={cn(
"[mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",
"[-webkit-mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",
"md:[mask-image:none] md:[-webkit-mask-image:none]",
)}
>
<div className="flex flex-col gap-2 px-2 py-3 md:px-2">
{loadError ? (
<div className="space-y-3 px-1">
<ErrorAlert error={loadError} />
{onRetryLoad && (
<Button size="sm" variant="outline" onClick={onRetryLoad}>
Retry
</Button>
)}
</div>
</>
) : (
<ChatTreeContext value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
<p className="m-0">
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</p>
<button
type="button"
className="mt-2 cursor-pointer border-none bg-transparent p-0 text-xs text-content-secondary hover:text-content-primary hover:underline"
onClick={() =>
onArchivedFilterChange?.(
archivedFilter === "archived" ? "active" : "archived",
)
}
>
{archivedFilter === "archived"
? "← Back to active"
: "View archived →"}
</button>
) : isLoading ? (
<>
<Skeleton className="ml-2.5 h-3.5 w-16" />
<div className="flex flex-col gap-0.5">
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="flex items-start gap-2 rounded-md px-2 py-1"
>
<Skeleton className="mt-0.5 h-5 w-5 shrink-0 rounded-md" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton
className="h-3.5"
style={{ width: `${55 + ((i * 17) % 35)}%` }}
/>
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
) : (
<div>
{visibleRootIDs.length > 0 && (
<div className="pb-2">
{/* ── Pinned section ── */}
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<div className="mb-1 ml-2.5 -mr-0.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>Pinned</span>
{showFilterOnPinned && filterDropdown}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
</>
) : (
<ChatTreeContext value={chatTreeCtx}>
{visibleRootIDs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-default bg-surface-primary p-4 text-center text-xs text-content-secondary">
<p className="m-0">
{normalizedSearch
? "No matching agents"
: archivedFilter === "archived"
? "No archived agents"
: "No agents yet"}
</p>
<button
type="button"
className="mt-2 cursor-pointer border-none bg-transparent p-0 text-xs text-content-secondary hover:text-content-primary hover:underline"
onClick={() =>
onArchivedFilterChange?.(
archivedFilter === "archived"
? "active"
: "archived",
)
}
>
{archivedFilter === "archived"
? "← Back to active"
: "View archived →"}
</button>
</div>
) : (
<div>
{visibleRootIDs.length > 0 && (
<div className="pb-2">
{/* ── Pinned section ── */}
{pinnedChats.length > 0 && (
<div className="[&:not(:first-child)]:mt-3">
<div className="mb-1 ml-2.5 -mr-0.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>Pinned</span>
{showFilterOnPinned && filterDropdown}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
<SortableContext
items={pinnedChatIds}
strategy={verticalListSortingStrategy}
>
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
<div
ref={pinnedContainerRef}
className="flex flex-col gap-0.5"
>
{sortedPinnedChats.map((chat) => (
<SortableChatTreeNode
key={chat.id}
chat={chat}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* ── Time-grouped sections ── */}
{TIME_GROUPS.map((group) => {
const groupChats = visibleRootIDs
.map((id) => chatById.get(id))
.filter(
(chat): chat is Chat =>
chat !== undefined &&
getTimeGroup(chat.updated_at) === group &&
chat.pin_order === 0,
);
if (groupChats.length === 0) return null;
return (
<div
key={group}
className="[&:not(:first-child)]:mt-3"
>
<div className="mb-1 ml-2.5 -mr-0.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
{group === firstNonEmptyGroup &&
filterDropdown}
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* ── Time-grouped sections ── */}
{TIME_GROUPS.map((group) => {
const groupChats = visibleRootIDs
.map((id) => chatById.get(id))
.filter(
(chat): chat is Chat =>
chat !== undefined &&
getTimeGroup(chat.updated_at) === group &&
chat.pin_order === 0,
</div>
);
if (groupChats.length === 0) return null;
return (
<div
key={group}
className="[&:not(:first-child)]:mt-3"
>
<div className="mb-1 ml-2.5 -mr-0.5 flex items-center justify-between text-xs font-medium text-content-secondary">
<span>{group}</span>
{group === firstNonEmptyGroup && filterDropdown}
</div>
<div className="flex flex-col gap-0.5">
{groupChats.map((chat) => (
<ChatTreeNode
key={chat.id}
chat={chat}
isChildNode={false}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</ChatTreeContext>
)}
</div>
</ScrollArea>
})}
</div>
)}
</div>
)}
{(hasNextPage || isFetchingNextPage) && (
<LoadMoreSentinel
onLoadMore={onLoadMore}
isFetchingNextPage={isFetchingNextPage}
/>
)}
</ChatTreeContext>
)}
</div>
</ScrollArea>
</div>
<div className="hidden border-0 border-t border-solid md:block">
<div className="flex items-stretch">
<DropdownMenu>
@@ -1338,13 +1348,13 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
state={location.state}
aria-label="Back to Settings"
>
<ChevronLeftIcon />
<ArrowLeftIcon />
</Link>
) : (
<Link
to={(location.state as { from?: string })?.from || "/agents"}
>
<ChevronLeftIcon />
<ArrowLeftIcon />
</Link>
)}
</Button>
@@ -1,10 +1,10 @@
import {
ArrowLeftIcon,
ChevronLeftIcon,
ChevronRightIcon,
MaximizeIcon,
MinimizeIcon,
PanelLeftIcon,
XIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { type FC, useEffect, useId, useRef, useState } from "react";
@@ -154,8 +154,19 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
{/* Tab bar – always visible for the expand button. */}
<div
role="tablist"
className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1"
className="flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-4 py-1.5 lg:px-3 lg:py-1"
>
{onClose && (
<Button
variant="subtle"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 lg:hidden"
>
<ArrowLeftIcon />
</Button>
)}
<div className="min-w-0 shrink-0 text-center">
{isExpanded && chatTitle && (
<span className="truncate text-sm text-content-primary">
@@ -163,17 +174,6 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</span>
)}
</div>
{onClose && (
<Button
variant="subtle"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 text-content-secondary hover:text-content-primary lg:hidden"
>
<XIcon />
</Button>
)}
<Button
variant="subtle"
size="icon"
@@ -196,8 +196,20 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
{/* Tab bar */}
<div
role="tablist"
className="relative flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-3 py-1"
className="relative flex shrink-0 items-center gap-2 border-0 border-b border-solid border-border-default px-4 py-1.5 lg:px-3 lg:py-1"
>
{/* Back button (mobile), placed before the tab strip */}
{onClose && (
<Button
variant="subtle"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 lg:hidden"
>
<ArrowLeftIcon />
</Button>
)}
{/* Sidebar toggle – only when expanded and sidebar is collapsed */}
{isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && (
<Button
@@ -296,18 +308,7 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</span>
</div>
)}
{/* Right side: close (mobile) / expand (desktop) */}
{onClose && (
<Button
variant="subtle"
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-7 w-7 shrink-0 text-content-secondary hover:text-content-primary lg:hidden"
>
<XIcon />
</Button>
)}
{/* Expand/collapse (desktop only) */}
<Button
variant="subtle"
size="icon"
@@ -11,22 +11,36 @@ import {
} from "#/components/Tooltip/Tooltip";
import { useWebpushNotifications } from "#/contexts/useWebpushNotifications";
export const WebPushButton: FC = () => {
const webPush = useWebpushNotifications();
interface WebPushButtonProps {
webPush?: ReturnType<typeof useWebpushNotifications>;
onToggle?: () => Promise<void> | void;
}
if (!webPush.enabled) {
export const WebPushButton: FC<WebPushButtonProps> = ({
webPush,
onToggle,
}) => {
const internalWebPush = useWebpushNotifications();
const webPushState = webPush ?? internalWebPush;
if (!webPushState.enabled) {
return null;
}
const handleClick = async () => {
if (onToggle) {
await onToggle();
return;
}
try {
if (webPush.subscribed) {
await webPush.unsubscribe();
if (webPushState.subscribed) {
await webPushState.unsubscribe();
} else {
await webPush.subscribe();
await webPushState.subscribe();
}
} catch (error) {
const action = webPush.subscribed ? "disable" : "enable";
const action = webPushState.subscribed ? "disable" : "enable";
toast.error(getErrorMessage(error, `Failed to ${action} notifications.`));
}
};
@@ -37,18 +51,18 @@ export const WebPushButton: FC = () => {
<Button
variant="subtle"
size="icon"
disabled={webPush.loading}
disabled={webPushState.loading}
onClick={handleClick}
aria-label={
webPush.subscribed
webPushState.subscribed
? "Disable notifications"
: "Enable notifications"
}
className="h-7 w-7 text-content-secondary hover:text-content-primary"
>
{webPush.loading ? (
{webPushState.loading ? (
<Spinner size="sm" loading />
) : webPush.subscribed ? (
) : webPushState.subscribed ? (
<BellIcon className="text-content-success" />
) : (
<BellOffIcon className="text-content-secondary" />
@@ -56,7 +70,9 @@ export const WebPushButton: FC = () => {
</Button>
</TooltipTrigger>
<TooltipContent>
{webPush.subscribed ? "Disable notifications" : "Enable notifications"}
{webPushState.subscribed
? "Disable notifications"
: "Enable notifications"}
</TooltipContent>
</Tooltip>
);
@@ -92,30 +92,36 @@ export const WorkspacePill: FC<WorkspacePillProps> = ({
type="button"
aria-label={`${workspace.name} workspace menu`}
className={cn(
"inline-flex min-w-[2.75rem] max-w-[200px] items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary overflow-hidden",
"inline-flex min-w-0 items-center gap-1 rounded-full bg-surface-secondary text-xs font-medium text-content-secondary overflow-hidden md:min-w-[2.75rem]",
"cursor-pointer border-0 transition-colors hover:bg-surface-tertiary hover:text-content-primary",
"size-7 justify-center p-0 md:size-auto md:max-w-[200px] md:justify-start md:px-2 md:py-0.5",
)}
>
<StatusIcon type={effectiveType} className="size-3 shrink-0" />
<span className="flex min-w-0 truncate">{workspace.name}</span>
{/* The menu opens upward (side="top"), so the chevron
points away from the menu when closed (default) and
toward it when open (rotate-180). */}
<StatusIcon
type={effectiveType}
className="size-icon-sm shrink-0 md:size-3"
/>
<span className="hidden min-w-0 truncate md:inline">
{workspace.name}
</span>
<ChevronDownIcon
className={cn(
"size-3 shrink-0 opacity-60 transition-transform",
"hidden size-3 shrink-0 opacity-60 transition-transform md:block",
open && "rotate-180",
)}
/>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{statusLabel}</TooltipContent>
<TooltipContent className="hidden md:block">
{statusLabel}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
side="top"
align="start"
className="w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
className="mobile-full-width-dropdown mobile-full-width-dropdown-bottom w-48 p-1 [&_[role=menuitem]]:text-xs [&_[role=menuitem]]:py-1 [&_svg]:!size-3.5 [&_img]:!size-3.5"
>
{hasVSCode && (
<VSCodeMenuItem
+15
View File
@@ -10,3 +10,18 @@ export const isMobileViewport = (): boolean => {
}
return window.matchMedia("(max-width: 639px)").matches;
};
/**
* Returns `true` when the viewport width is below the `md` Tailwind
* breakpoint (< 768 px). Use this for layout branching that needs to
* align with `md:` Tailwind utilities (e.g. the mobile full-width
* dropdown / inline menu layout), so that viewports between 640 and
* 767 px (common on landscape phones and small tablets) pick the
* mobile branch instead of the desktop flyout branch.
*/
export const isBelowMdViewport = (): boolean => {
if (typeof window === "undefined" || !window.matchMedia) {
return false;
}
return window.matchMedia("(max-width: 767px)").matches;
};