mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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=""
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user