mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 05:22:10 +00:00
feat: add filter & grouping menu to agents threads sidebar (#1617)
Add a Cursor-style control to the agents sidebar that groups (None/Date/ Status/Project), filters (ownership, status, source, pull request, model, repo, include-resolved), and compacts the threads list. All client-side over already-fetched sidebar threads; preferences persist in localStorage. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
2f56d7546a
commit
baf0c248ea
5 changed files with 998 additions and 40 deletions
|
|
@ -27,6 +27,7 @@ import type { SessionUser } from "@/lib/api"
|
||||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||||
import type { SidebarLayout } from "@/components/sidebar-layout"
|
import type { SidebarLayout } from "@/components/sidebar-layout"
|
||||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||||
|
import { SidebarFilterMenu } from "@/components/agents/SidebarFilterMenu"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
SidebarCollapseButton,
|
SidebarCollapseButton,
|
||||||
|
|
@ -34,7 +35,13 @@ import {
|
||||||
SidebarLayoutProvider,
|
SidebarLayoutProvider,
|
||||||
useSidebarLayout,
|
useSidebarLayout,
|
||||||
} from "@/components/sidebar-layout"
|
} from "@/components/sidebar-layout"
|
||||||
import { groupThreads } from "@/lib/agents/api"
|
import {
|
||||||
|
availableFacets,
|
||||||
|
filterThreads,
|
||||||
|
groupThreadsByMode,
|
||||||
|
hasActiveFilters,
|
||||||
|
} from "@/lib/agents/sidebarFilter"
|
||||||
|
import { useSidebarPrefs } from "@/lib/agents/sidebarPrefs"
|
||||||
import {
|
import {
|
||||||
useDeleteAgentThread,
|
useDeleteAgentThread,
|
||||||
useResolveAgentThread,
|
useResolveAgentThread,
|
||||||
|
|
@ -101,6 +108,8 @@ export function AgentsSidebar({
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
layout,
|
layout,
|
||||||
}: AgentsSidebarProps) {
|
}: AgentsSidebarProps) {
|
||||||
|
const { prefs, setGroup, setCompact, setFilters, resetFilters } =
|
||||||
|
useSidebarPrefs()
|
||||||
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
|
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
|
||||||
const activeThreads = sidebar.data?.active.items ?? []
|
const activeThreads = sidebar.data?.active.items ?? []
|
||||||
const resolvedThreads = sidebar.data?.resolved.items ?? []
|
const resolvedThreads = sidebar.data?.resolved.items ?? []
|
||||||
|
|
@ -108,7 +117,16 @@ export function AgentsSidebar({
|
||||||
const visibleThreads = [...activeThreads, ...resolvedThreads]
|
const visibleThreads = [...activeThreads, ...resolvedThreads]
|
||||||
useSeedAgentThreadDetails(visibleThreads, activeThreadId)
|
useSeedAgentThreadDetails(visibleThreads, activeThreadId)
|
||||||
useRunCompletionNotifier(visibleThreads, activeThreadId)
|
useRunCompletionNotifier(visibleThreads, activeThreadId)
|
||||||
const groups = groupThreads(activeThreads)
|
|
||||||
|
const facets = availableFacets(visibleThreads)
|
||||||
|
const filteredActive = filterThreads(activeThreads, prefs.filters)
|
||||||
|
const filteredResolved = filterThreads(resolvedThreads, prefs.filters)
|
||||||
|
const sections = groupThreadsByMode(filteredActive, prefs.group)
|
||||||
|
const showResolved = prefs.filters.includeResolved
|
||||||
|
const isEmpty =
|
||||||
|
sections.length === 0 &&
|
||||||
|
(!showResolved || filteredResolved.length === 0) &&
|
||||||
|
hasActiveFilters(prefs.filters)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarFrame
|
<SidebarFrame
|
||||||
|
|
@ -159,42 +177,55 @@ export function AgentsSidebar({
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||||
<ThreadGroup
|
{prefs.group === "none"
|
||||||
label="Today"
|
? sections[0]?.threads.map((thread) => (
|
||||||
threads={groups.today}
|
<ThreadRow
|
||||||
activeThreadId={activeThreadId}
|
key={thread.id}
|
||||||
onNavigate={layout.closeOnMobile}
|
thread={thread}
|
||||||
/>
|
isActive={thread.id === activeThreadId}
|
||||||
<ThreadGroup
|
onNavigate={layout.closeOnMobile}
|
||||||
label="Last 7 days"
|
compact={prefs.compact}
|
||||||
threads={groups.last7}
|
/>
|
||||||
activeThreadId={activeThreadId}
|
))
|
||||||
onNavigate={layout.closeOnMobile}
|
: sections.map((section) => (
|
||||||
defaultCollapsed
|
<ThreadGroup
|
||||||
/>
|
key={`${prefs.group}:${section.key}`}
|
||||||
<ThreadGroup
|
label={section.label}
|
||||||
label="Last 30 days"
|
threads={section.threads}
|
||||||
threads={groups.last30}
|
activeThreadId={activeThreadId}
|
||||||
activeThreadId={activeThreadId}
|
onNavigate={layout.closeOnMobile}
|
||||||
onNavigate={layout.closeOnMobile}
|
defaultCollapsed={section.defaultCollapsed}
|
||||||
defaultCollapsed
|
compact={prefs.compact}
|
||||||
/>
|
/>
|
||||||
<ThreadGroup
|
))}
|
||||||
label="Older"
|
{showResolved && (
|
||||||
threads={groups.older}
|
<ResolvedThreadGroup
|
||||||
activeThreadId={activeThreadId}
|
threads={filteredResolved}
|
||||||
onNavigate={layout.closeOnMobile}
|
hasMore={resolvedHasMore}
|
||||||
/>
|
activeThreadId={activeThreadId}
|
||||||
<ResolvedThreadGroup
|
onNavigate={layout.closeOnMobile}
|
||||||
threads={resolvedThreads}
|
compact={prefs.compact}
|
||||||
hasMore={resolvedHasMore}
|
/>
|
||||||
activeThreadId={activeThreadId}
|
)}
|
||||||
onNavigate={layout.closeOnMobile}
|
{isEmpty && (
|
||||||
/>
|
<p className="px-2.5 py-6 text-center text-xs text-[var(--ui-text-dim)]">
|
||||||
|
No threads match these filters.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2">
|
<div className="flex items-center gap-1 p-2">
|
||||||
<SidebarUserMenu user={user} showSettingsLink />
|
<div className="min-w-0 flex-1">
|
||||||
|
<SidebarUserMenu user={user} showSettingsLink />
|
||||||
|
</div>
|
||||||
|
<SidebarFilterMenu
|
||||||
|
prefs={prefs}
|
||||||
|
facets={facets}
|
||||||
|
onGroupChange={setGroup}
|
||||||
|
onFiltersChange={setFilters}
|
||||||
|
onCompactChange={setCompact}
|
||||||
|
onResetFilters={resetFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarFrame>
|
</SidebarFrame>
|
||||||
)
|
)
|
||||||
|
|
@ -206,12 +237,14 @@ function ThreadGroup({
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
threads: Array<AgentThread>
|
threads: Array<AgentThread>
|
||||||
activeThreadId?: string
|
activeThreadId?: string
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
defaultCollapsed?: boolean
|
defaultCollapsed?: boolean
|
||||||
|
compact?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||||
if (threads.length === 0) return null
|
if (threads.length === 0) return null
|
||||||
|
|
@ -219,7 +252,7 @@ function ThreadGroup({
|
||||||
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
|
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className={compact ? "mb-2" : "mb-3"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCollapsed((value) => !value)}
|
onClick={() => setCollapsed((value) => !value)}
|
||||||
|
|
@ -237,6 +270,7 @@ function ThreadGroup({
|
||||||
thread={thread}
|
thread={thread}
|
||||||
isActive={thread.id === activeThreadId}
|
isActive={thread.id === activeThreadId}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -248,11 +282,13 @@ function ResolvedThreadGroup({
|
||||||
hasMore,
|
hasMore,
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
threads: Array<AgentThread>
|
threads: Array<AgentThread>
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
activeThreadId?: string
|
activeThreadId?: string
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
|
compact?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState(true)
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
if (threads.length === 0) return null
|
if (threads.length === 0) return null
|
||||||
|
|
@ -283,6 +319,7 @@ function ResolvedThreadGroup({
|
||||||
thread={thread}
|
thread={thread}
|
||||||
isActive={thread.id === activeThreadId}
|
isActive={thread.id === activeThreadId}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
|
|
@ -305,10 +342,12 @@ function ThreadRow({
|
||||||
thread,
|
thread,
|
||||||
isActive,
|
isActive,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
thread: AgentThread
|
thread: AgentThread
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
|
compact?: boolean
|
||||||
}) {
|
}) {
|
||||||
const deleteThread = useDeleteAgentThread()
|
const deleteThread = useDeleteAgentThread()
|
||||||
const resolveThread = useResolveAgentThread()
|
const resolveThread = useResolveAgentThread()
|
||||||
|
|
@ -367,7 +406,8 @@ function ThreadRow({
|
||||||
params={{ threadId: thread.id }}
|
params={{ threadId: thread.id }}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group mb-0.5 flex h-8 items-center gap-2 rounded-lg px-2.5 transition-colors",
|
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 transition-colors",
|
||||||
|
compact ? "h-7 gap-1.5" : "h-8",
|
||||||
isActive
|
isActive
|
||||||
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
||||||
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
|
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
|
||||||
|
|
@ -405,7 +445,7 @@ function ThreadRow({
|
||||||
<span className="min-w-0 flex-1 truncate text-xs">
|
<span className="min-w-0 flex-1 truncate text-xs">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
</span>
|
</span>
|
||||||
{prMeta && PrIcon && (
|
{!compact && prMeta && PrIcon && (
|
||||||
<PrIcon
|
<PrIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-3.5 shrink-0 group-hover:hidden",
|
"size-3.5 shrink-0 group-hover:hidden",
|
||||||
|
|
@ -416,7 +456,7 @@ function ThreadRow({
|
||||||
<title>{prMeta.label}</title>
|
<title>{prMeta.label}</title>
|
||||||
</PrIcon>
|
</PrIcon>
|
||||||
)}
|
)}
|
||||||
{badge && (
|
{!compact && badge && (
|
||||||
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-success)] group-hover:hidden">
|
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-success)] group-hover:hidden">
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
317
ui/src/components/agents/SidebarFilterMenu.tsx
Normal file
317
ui/src/components/agents/SidebarFilterMenu.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
import { Menu } from "@base-ui/react/menu"
|
||||||
|
import { CaretRightIcon, CheckIcon, FunnelIcon } from "@phosphor-icons/react"
|
||||||
|
|
||||||
|
import type { AgentSource, AgentStatus } from "@/lib/agents/types"
|
||||||
|
import type {
|
||||||
|
PrFilter,
|
||||||
|
SidebarFacets,
|
||||||
|
SidebarFilters,
|
||||||
|
SidebarGroupMode,
|
||||||
|
} from "@/lib/agents/sidebarFilter"
|
||||||
|
import type { SidebarPrefs } from "@/lib/agents/sidebarPrefs"
|
||||||
|
import {
|
||||||
|
GROUP_MODE_OPTIONS,
|
||||||
|
OWNERSHIP_OPTIONS,
|
||||||
|
PR_FILTER_OPTIONS,
|
||||||
|
SOURCE_FILTER_OPTIONS,
|
||||||
|
STATUS_FILTER_OPTIONS,
|
||||||
|
hasActiveFilters,
|
||||||
|
toggleArrayValue,
|
||||||
|
} from "@/lib/agents/sidebarFilter"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const POPUP_CLASS =
|
||||||
|
"z-50 min-w-[12rem] origin-(--transform-origin) overflow-hidden rounded-md border border-[var(--ui-border)] bg-popover p-1 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
|
||||||
|
|
||||||
|
const ITEM_CLASS =
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-none select-none data-highlighted:bg-[var(--ui-sidebar-hover)] data-disabled:pointer-events-none data-disabled:opacity-50"
|
||||||
|
|
||||||
|
const LABEL_CLASS =
|
||||||
|
"px-2 py-1 text-[10px] font-medium tracking-wide text-[var(--ui-text-dim)] uppercase"
|
||||||
|
|
||||||
|
const SEPARATOR_CLASS = "my-1 h-px bg-[var(--ui-border)]"
|
||||||
|
|
||||||
|
function Indicator() {
|
||||||
|
return <CheckIcon className="size-3.5 shrink-0" weight="bold" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountBadge({ count }: { count: number }) {
|
||||||
|
if (count <= 0) return null
|
||||||
|
return (
|
||||||
|
<span className="ml-auto rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-muted)]">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckboxSubmenu({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
selected: Array<string>
|
||||||
|
onToggle: (value: string) => void
|
||||||
|
}) {
|
||||||
|
const disabled = options.length === 0
|
||||||
|
return (
|
||||||
|
<Menu.SubmenuRoot>
|
||||||
|
<Menu.SubmenuTrigger className={ITEM_CLASS} disabled={disabled}>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{selected.length > 0 ? (
|
||||||
|
<CountBadge count={selected.length} />
|
||||||
|
) : (
|
||||||
|
<CaretRightIcon className="ml-auto size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
</Menu.SubmenuTrigger>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="z-50 outline-none"
|
||||||
|
>
|
||||||
|
<Menu.Popup className={cn(POPUP_CLASS, "max-h-72 overflow-y-auto")}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Menu.CheckboxItem
|
||||||
|
key={option.value}
|
||||||
|
checked={selected.includes(option.value)}
|
||||||
|
onCheckedChange={() => onToggle(option.value)}
|
||||||
|
closeOnClick={false}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
<Menu.CheckboxItemIndicator className="ml-auto flex">
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" weight="bold" />
|
||||||
|
</Menu.CheckboxItemIndicator>
|
||||||
|
</Menu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.SubmenuRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarFilterMenuProps {
|
||||||
|
prefs: SidebarPrefs
|
||||||
|
facets: SidebarFacets
|
||||||
|
onGroupChange: (mode: SidebarGroupMode) => void
|
||||||
|
onFiltersChange: (filters: SidebarFilters) => void
|
||||||
|
onCompactChange: (compact: boolean) => void
|
||||||
|
onResetFilters: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarFilterMenu({
|
||||||
|
prefs,
|
||||||
|
facets,
|
||||||
|
onGroupChange,
|
||||||
|
onFiltersChange,
|
||||||
|
onCompactChange,
|
||||||
|
onResetFilters,
|
||||||
|
}: SidebarFilterMenuProps) {
|
||||||
|
const { filters } = prefs
|
||||||
|
const active = hasActiveFilters(filters)
|
||||||
|
|
||||||
|
const patch = (next: Partial<SidebarFilters>) =>
|
||||||
|
onFiltersChange({ ...filters, ...next })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Root>
|
||||||
|
<Menu.Trigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Group and filter threads"
|
||||||
|
className="relative flex size-7 shrink-0 items-center justify-center rounded-md text-[var(--ui-text-muted)] transition-colors outline-none hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)] data-popup-open:bg-[var(--ui-sidebar-hover)] data-popup-open:text-[var(--ui-text)]"
|
||||||
|
>
|
||||||
|
<FunnelIcon className="size-4" />
|
||||||
|
{active && (
|
||||||
|
<span className="absolute top-1 right-1 size-1.5 rounded-full bg-[var(--ui-accent)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={6}
|
||||||
|
className="z-50 outline-none"
|
||||||
|
>
|
||||||
|
<Menu.Popup className={POPUP_CLASS}>
|
||||||
|
<div className={LABEL_CLASS}>Group</div>
|
||||||
|
<Menu.RadioGroup
|
||||||
|
value={prefs.group}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onGroupChange(value as SidebarGroupMode)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{GROUP_MODE_OPTIONS.map((option) => (
|
||||||
|
<Menu.RadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
closeOnClick={false}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
<Menu.RadioItemIndicator className="ml-auto flex">
|
||||||
|
<Indicator />
|
||||||
|
</Menu.RadioItemIndicator>
|
||||||
|
</Menu.RadioItem>
|
||||||
|
))}
|
||||||
|
</Menu.RadioGroup>
|
||||||
|
|
||||||
|
<Menu.Separator className={SEPARATOR_CLASS} />
|
||||||
|
|
||||||
|
<Menu.SubmenuRoot>
|
||||||
|
<Menu.SubmenuTrigger className={ITEM_CLASS}>
|
||||||
|
<span className="truncate">Filter</span>
|
||||||
|
<CaretRightIcon className="ml-auto size-3.5 shrink-0" />
|
||||||
|
</Menu.SubmenuTrigger>
|
||||||
|
<Menu.Portal>
|
||||||
|
<Menu.Positioner
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="z-50 outline-none"
|
||||||
|
>
|
||||||
|
<Menu.Popup className={POPUP_CLASS}>
|
||||||
|
<Menu.RadioGroup
|
||||||
|
value={filters.ownership}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patch({
|
||||||
|
ownership: value as SidebarFilters["ownership"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{OWNERSHIP_OPTIONS.map((option) => (
|
||||||
|
<Menu.RadioItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
closeOnClick={false}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
<Menu.RadioItemIndicator className="ml-auto flex">
|
||||||
|
<Indicator />
|
||||||
|
</Menu.RadioItemIndicator>
|
||||||
|
</Menu.RadioItem>
|
||||||
|
))}
|
||||||
|
</Menu.RadioGroup>
|
||||||
|
|
||||||
|
<Menu.Separator className={SEPARATOR_CLASS} />
|
||||||
|
|
||||||
|
<CheckboxSubmenu
|
||||||
|
label="Status"
|
||||||
|
options={STATUS_FILTER_OPTIONS}
|
||||||
|
selected={filters.statuses}
|
||||||
|
onToggle={(value) =>
|
||||||
|
patch({
|
||||||
|
statuses: toggleArrayValue(
|
||||||
|
filters.statuses,
|
||||||
|
value as AgentStatus
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CheckboxSubmenu
|
||||||
|
label="Source"
|
||||||
|
options={SOURCE_FILTER_OPTIONS}
|
||||||
|
selected={filters.sources}
|
||||||
|
onToggle={(value) =>
|
||||||
|
patch({
|
||||||
|
sources: toggleArrayValue(
|
||||||
|
filters.sources,
|
||||||
|
value as AgentSource
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CheckboxSubmenu
|
||||||
|
label="Pull request"
|
||||||
|
options={PR_FILTER_OPTIONS}
|
||||||
|
selected={filters.pr}
|
||||||
|
onToggle={(value) =>
|
||||||
|
patch({
|
||||||
|
pr: toggleArrayValue(filters.pr, value as PrFilter),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CheckboxSubmenu
|
||||||
|
label="Model"
|
||||||
|
options={facets.models.map((m) => ({
|
||||||
|
value: m,
|
||||||
|
label: m,
|
||||||
|
}))}
|
||||||
|
selected={filters.models}
|
||||||
|
onToggle={(value) =>
|
||||||
|
patch({
|
||||||
|
models: toggleArrayValue(filters.models, value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CheckboxSubmenu
|
||||||
|
label="Repo"
|
||||||
|
options={facets.repos.map((r) => ({
|
||||||
|
value: r,
|
||||||
|
label: r,
|
||||||
|
}))}
|
||||||
|
selected={filters.repos}
|
||||||
|
onToggle={(value) =>
|
||||||
|
patch({ repos: toggleArrayValue(filters.repos, value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Menu.Separator className={SEPARATOR_CLASS} />
|
||||||
|
|
||||||
|
<Menu.CheckboxItem
|
||||||
|
checked={filters.includeResolved}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patch({ includeResolved: checked })
|
||||||
|
}
|
||||||
|
closeOnClick={false}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<span className="truncate">Include resolved</span>
|
||||||
|
<Menu.CheckboxItemIndicator className="ml-auto flex">
|
||||||
|
<CheckIcon
|
||||||
|
className="size-3.5 shrink-0"
|
||||||
|
weight="bold"
|
||||||
|
/>
|
||||||
|
</Menu.CheckboxItemIndicator>
|
||||||
|
</Menu.CheckboxItem>
|
||||||
|
|
||||||
|
<Menu.Separator className={SEPARATOR_CLASS} />
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
onClick={onResetFilters}
|
||||||
|
disabled={!active}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
Reset filters
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.SubmenuRoot>
|
||||||
|
|
||||||
|
<Menu.Separator className={SEPARATOR_CLASS} />
|
||||||
|
|
||||||
|
<Menu.CheckboxItem
|
||||||
|
checked={prefs.compact}
|
||||||
|
onCheckedChange={(checked) => onCompactChange(checked)}
|
||||||
|
closeOnClick={false}
|
||||||
|
className={ITEM_CLASS}
|
||||||
|
>
|
||||||
|
<span className="truncate">Compact</span>
|
||||||
|
<Menu.CheckboxItemIndicator className="ml-auto flex">
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" weight="bold" />
|
||||||
|
</Menu.CheckboxItemIndicator>
|
||||||
|
</Menu.CheckboxItem>
|
||||||
|
</Menu.Popup>
|
||||||
|
</Menu.Positioner>
|
||||||
|
</Menu.Portal>
|
||||||
|
</Menu.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
ui/src/lib/agents/sidebarFilter.test.ts
Normal file
204
ui/src/lib/agents/sidebarFilter.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SIDEBAR_FILTERS,
|
||||||
|
availableFacets,
|
||||||
|
filterThreads,
|
||||||
|
groupThreadsByMode,
|
||||||
|
hasActiveFilters,
|
||||||
|
toggleArrayValue,
|
||||||
|
} from "./sidebarFilter"
|
||||||
|
import type { SidebarFilters } from "./sidebarFilter"
|
||||||
|
import type { AgentThread } from "./types"
|
||||||
|
|
||||||
|
const DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function makeThread(overrides: Partial<AgentThread> = {}): AgentThread {
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
title: "Thread",
|
||||||
|
repo: "repo",
|
||||||
|
repoFullName: "acme/repo",
|
||||||
|
branch: "main",
|
||||||
|
model: "gpt-5",
|
||||||
|
source: "dashboard",
|
||||||
|
status: "idle",
|
||||||
|
viewed: true,
|
||||||
|
isOwner: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filters(overrides: Partial<SidebarFilters> = {}): SidebarFilters {
|
||||||
|
return { ...DEFAULT_SIDEBAR_FILTERS, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filterThreads", () => {
|
||||||
|
it("returns all threads with default filters", () => {
|
||||||
|
const threads = [makeThread(), makeThread()]
|
||||||
|
expect(filterThreads(threads, DEFAULT_SIDEBAR_FILTERS)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by ownership", () => {
|
||||||
|
const mine = makeThread({ isOwner: true })
|
||||||
|
const shared = makeThread({ isOwner: false })
|
||||||
|
const unknown = makeThread({ isOwner: undefined })
|
||||||
|
const all = [mine, shared, unknown]
|
||||||
|
expect(filterThreads(all, filters({ ownership: "mine" }))).toEqual([
|
||||||
|
mine,
|
||||||
|
unknown,
|
||||||
|
])
|
||||||
|
expect(filterThreads(all, filters({ ownership: "shared" }))).toEqual([
|
||||||
|
shared,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by status (multi-select)", () => {
|
||||||
|
const running = makeThread({ status: "running" })
|
||||||
|
const finished = makeThread({ status: "finished" })
|
||||||
|
const idle = makeThread({ status: "idle" })
|
||||||
|
const result = filterThreads(
|
||||||
|
[running, finished, idle],
|
||||||
|
filters({ statuses: ["running", "finished"] })
|
||||||
|
)
|
||||||
|
expect(result).toEqual([running, finished])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by source, defaulting missing source to dashboard", () => {
|
||||||
|
const gh = makeThread({ source: "github" })
|
||||||
|
const noSource = makeThread({ source: undefined })
|
||||||
|
expect(
|
||||||
|
filterThreads([gh, noSource], filters({ sources: ["dashboard"] }))
|
||||||
|
).toEqual([noSource])
|
||||||
|
expect(
|
||||||
|
filterThreads([gh, noSource], filters({ sources: ["github"] }))
|
||||||
|
).toEqual([gh])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by pull-request state including 'none'", () => {
|
||||||
|
const open = makeThread({
|
||||||
|
pr: {
|
||||||
|
number: 1,
|
||||||
|
title: "x",
|
||||||
|
state: "open",
|
||||||
|
headRef: "h",
|
||||||
|
baseRef: "main",
|
||||||
|
url: "u",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const noPr = makeThread({ pr: undefined })
|
||||||
|
expect(filterThreads([open, noPr], filters({ pr: ["open"] }))).toEqual([
|
||||||
|
open,
|
||||||
|
])
|
||||||
|
expect(filterThreads([open, noPr], filters({ pr: ["none"] }))).toEqual([
|
||||||
|
noPr,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by model and repo", () => {
|
||||||
|
const a = makeThread({ model: "gpt-5", repoFullName: "acme/a" })
|
||||||
|
const b = makeThread({ model: "claude", repoFullName: "acme/b" })
|
||||||
|
expect(filterThreads([a, b], filters({ models: ["claude"] }))).toEqual([b])
|
||||||
|
expect(filterThreads([a, b], filters({ repos: ["acme/a"] }))).toEqual([a])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("availableFacets", () => {
|
||||||
|
it("returns distinct sorted models and repos, skipping empties", () => {
|
||||||
|
const threads = [
|
||||||
|
makeThread({ model: "gpt-5", repoFullName: "acme/b" }),
|
||||||
|
makeThread({ model: "claude", repoFullName: "acme/a" }),
|
||||||
|
makeThread({ model: "gpt-5", repoFullName: "" }),
|
||||||
|
]
|
||||||
|
const facets = availableFacets(threads)
|
||||||
|
expect(facets.models).toEqual(["claude", "gpt-5"])
|
||||||
|
expect(facets.repos).toEqual(["acme/a", "acme/b"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("groupThreadsByMode", () => {
|
||||||
|
it("returns an empty array for no threads", () => {
|
||||||
|
expect(groupThreadsByMode([], "date")).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("groups everything into one section for 'none'", () => {
|
||||||
|
const sections = groupThreadsByMode([makeThread(), makeThread()], "none")
|
||||||
|
expect(sections).toHaveLength(1)
|
||||||
|
expect(sections[0]?.key).toBe("all")
|
||||||
|
expect(sections[0]?.threads).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("buckets by date and drops empty buckets", () => {
|
||||||
|
const now = Date.now()
|
||||||
|
const sections = groupThreadsByMode(
|
||||||
|
[
|
||||||
|
makeThread({ updatedAt: now }),
|
||||||
|
makeThread({ updatedAt: now - 3 * DAY }),
|
||||||
|
makeThread({ updatedAt: now - 40 * DAY }),
|
||||||
|
],
|
||||||
|
"date"
|
||||||
|
)
|
||||||
|
expect(sections.map((s) => s.key)).toEqual(["today", "last7", "older"])
|
||||||
|
expect(sections.find((s) => s.key === "last7")?.defaultCollapsed).toBe(true)
|
||||||
|
expect(sections.find((s) => s.key === "today")?.defaultCollapsed).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("groups by status in a fixed order", () => {
|
||||||
|
const sections = groupThreadsByMode(
|
||||||
|
[
|
||||||
|
makeThread({ status: "idle" }),
|
||||||
|
makeThread({ status: "running" }),
|
||||||
|
makeThread({ status: "error" }),
|
||||||
|
],
|
||||||
|
"status"
|
||||||
|
)
|
||||||
|
expect(sections.map((s) => s.key)).toEqual(["running", "error", "idle"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("groups by repo alphabetically with a fallback label", () => {
|
||||||
|
const sections = groupThreadsByMode(
|
||||||
|
[
|
||||||
|
makeThread({ repoFullName: "acme/z" }),
|
||||||
|
makeThread({ repoFullName: "acme/a" }),
|
||||||
|
makeThread({ repoFullName: "" }),
|
||||||
|
],
|
||||||
|
"repo"
|
||||||
|
)
|
||||||
|
expect(sections.map((s) => s.label)).toEqual([
|
||||||
|
"acme/a",
|
||||||
|
"acme/z",
|
||||||
|
"No repository",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts threads within a section by recency", () => {
|
||||||
|
const older = makeThread({ status: "idle", updatedAt: 1 })
|
||||||
|
const newer = makeThread({ status: "idle", updatedAt: 2 })
|
||||||
|
const [section] = groupThreadsByMode([older, newer], "status")
|
||||||
|
expect(section?.threads).toEqual([newer, older])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("hasActiveFilters", () => {
|
||||||
|
it("is false for defaults", () => {
|
||||||
|
expect(hasActiveFilters(DEFAULT_SIDEBAR_FILTERS)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is true when any dimension changes", () => {
|
||||||
|
expect(hasActiveFilters(filters({ ownership: "mine" }))).toBe(true)
|
||||||
|
expect(hasActiveFilters(filters({ statuses: ["running"] }))).toBe(true)
|
||||||
|
expect(hasActiveFilters(filters({ includeResolved: false }))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("toggleArrayValue", () => {
|
||||||
|
it("adds a missing value and removes a present one", () => {
|
||||||
|
expect(toggleArrayValue(["a"], "b")).toEqual(["a", "b"])
|
||||||
|
expect(toggleArrayValue(["a", "b"], "a")).toEqual(["b"])
|
||||||
|
})
|
||||||
|
})
|
||||||
278
ui/src/lib/agents/sidebarFilter.ts
Normal file
278
ui/src/lib/agents/sidebarFilter.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
import { groupThreads } from "./api"
|
||||||
|
import type { AgentSource, AgentStatus, AgentThread } from "./types"
|
||||||
|
|
||||||
|
export type SidebarGroupMode = "none" | "date" | "status" | "repo"
|
||||||
|
|
||||||
|
export type SidebarOwnership = "all" | "mine" | "shared"
|
||||||
|
|
||||||
|
export type PrFilter = "none" | "draft" | "open" | "merged" | "closed"
|
||||||
|
|
||||||
|
export interface SidebarFilters {
|
||||||
|
ownership: SidebarOwnership
|
||||||
|
statuses: Array<AgentStatus>
|
||||||
|
sources: Array<AgentSource>
|
||||||
|
pr: Array<PrFilter>
|
||||||
|
models: Array<string>
|
||||||
|
repos: Array<string>
|
||||||
|
includeResolved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
|
||||||
|
ownership: "all",
|
||||||
|
statuses: [],
|
||||||
|
sources: [],
|
||||||
|
pr: [],
|
||||||
|
models: [],
|
||||||
|
repos: [],
|
||||||
|
includeResolved: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUP_MODE_OPTIONS: Array<{
|
||||||
|
value: SidebarGroupMode
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: "repo", label: "Project" },
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "status", label: "Status" },
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const OWNERSHIP_OPTIONS: Array<{
|
||||||
|
value: SidebarOwnership
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: "all", label: "All agents" },
|
||||||
|
{ value: "mine", label: "My agents" },
|
||||||
|
{ value: "shared", label: "Shared with me" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const STATUS_FILTER_OPTIONS: Array<{
|
||||||
|
value: AgentStatus
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: "running", label: "Running" },
|
||||||
|
{ value: "finished", label: "Finished" },
|
||||||
|
{ value: "interrupted", label: "Interrupted" },
|
||||||
|
{ value: "error", label: "Error" },
|
||||||
|
{ value: "idle", label: "Idle" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SOURCE_FILTER_OPTIONS: Array<{
|
||||||
|
value: AgentSource
|
||||||
|
label: string
|
||||||
|
}> = [
|
||||||
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
|
{ value: "github", label: "GitHub" },
|
||||||
|
{ value: "slack", label: "Slack" },
|
||||||
|
{ value: "linear", label: "Linear" },
|
||||||
|
{ value: "schedule", label: "Schedule" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PR_FILTER_OPTIONS: Array<{ value: PrFilter; label: string }> = [
|
||||||
|
{ value: "none", label: "No pull request" },
|
||||||
|
{ value: "draft", label: "Draft" },
|
||||||
|
{ value: "open", label: "Open" },
|
||||||
|
{ value: "merged", label: "Merged" },
|
||||||
|
{ value: "closed", label: "Closed" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function threadSource(thread: AgentThread): AgentSource {
|
||||||
|
return thread.source ?? "dashboard"
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadPr(thread: AgentThread): PrFilter {
|
||||||
|
return thread.pr ? thread.pr.state : "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the active filter dimensions to a list of threads. */
|
||||||
|
export function filterThreads(
|
||||||
|
threads: Array<AgentThread>,
|
||||||
|
filters: SidebarFilters
|
||||||
|
): Array<AgentThread> {
|
||||||
|
return threads.filter((thread) => {
|
||||||
|
if (filters.ownership === "mine" && thread.isOwner === false) return false
|
||||||
|
if (filters.ownership === "shared" && thread.isOwner !== false) return false
|
||||||
|
if (
|
||||||
|
filters.statuses.length > 0 &&
|
||||||
|
!filters.statuses.includes(thread.status)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filters.sources.length > 0 &&
|
||||||
|
!filters.sources.includes(threadSource(thread))
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.pr.length > 0 && !filters.pr.includes(threadPr(thread))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.models.length > 0 && !filters.models.includes(thread.model)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filters.repos.length > 0 &&
|
||||||
|
!filters.repos.includes(thread.repoFullName)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarFacets {
|
||||||
|
models: Array<string>
|
||||||
|
repos: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct model + repo values present in the given threads (for the filter submenus). */
|
||||||
|
export function availableFacets(threads: Array<AgentThread>): SidebarFacets {
|
||||||
|
const models = new Set<string>()
|
||||||
|
const repos = new Set<string>()
|
||||||
|
for (const thread of threads) {
|
||||||
|
if (thread.model) models.add(thread.model)
|
||||||
|
if (thread.repoFullName) repos.add(thread.repoFullName)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
models: [...models].sort((a, b) => a.localeCompare(b)),
|
||||||
|
repos: [...repos].sort((a, b) => a.localeCompare(b)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadGroupSection {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
threads: Array<AgentThread>
|
||||||
|
defaultCollapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_GROUP_ORDER: Array<AgentStatus> = [
|
||||||
|
"running",
|
||||||
|
"finished",
|
||||||
|
"interrupted",
|
||||||
|
"error",
|
||||||
|
"idle",
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_GROUP_LABEL: Record<AgentStatus, string> = {
|
||||||
|
running: "Running",
|
||||||
|
finished: "Finished",
|
||||||
|
interrupted: "Interrupted",
|
||||||
|
error: "Error",
|
||||||
|
idle: "Idle",
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedByRecency(threads: Array<AgentThread>): Array<AgentThread> {
|
||||||
|
return [...threads].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split threads into ordered, labelled sections according to the group mode. */
|
||||||
|
export function groupThreadsByMode(
|
||||||
|
threads: Array<AgentThread>,
|
||||||
|
mode: SidebarGroupMode
|
||||||
|
): Array<ThreadGroupSection> {
|
||||||
|
if (threads.length === 0) return []
|
||||||
|
|
||||||
|
if (mode === "none") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "all",
|
||||||
|
label: "All",
|
||||||
|
threads: sortedByRecency(threads),
|
||||||
|
defaultCollapsed: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "date") {
|
||||||
|
const groups = groupThreads(threads)
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "today",
|
||||||
|
label: "Today",
|
||||||
|
threads: groups.today,
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "last7",
|
||||||
|
label: "Last 7 days",
|
||||||
|
threads: groups.last7,
|
||||||
|
collapsed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "last30",
|
||||||
|
label: "Last 30 days",
|
||||||
|
threads: groups.last30,
|
||||||
|
collapsed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "older",
|
||||||
|
label: "Older",
|
||||||
|
threads: groups.older,
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
)
|
||||||
|
.filter((section) => section.threads.length > 0)
|
||||||
|
.map((section) => ({
|
||||||
|
key: section.key,
|
||||||
|
label: section.label,
|
||||||
|
threads: section.threads,
|
||||||
|
defaultCollapsed: section.collapsed,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "status") {
|
||||||
|
const byStatus = new Map<AgentStatus, Array<AgentThread>>()
|
||||||
|
for (const thread of threads) {
|
||||||
|
const list = byStatus.get(thread.status) ?? []
|
||||||
|
list.push(thread)
|
||||||
|
byStatus.set(thread.status, list)
|
||||||
|
}
|
||||||
|
return STATUS_GROUP_ORDER.filter((status) => byStatus.has(status)).map(
|
||||||
|
(status) => ({
|
||||||
|
key: status,
|
||||||
|
label: STATUS_GROUP_LABEL[status],
|
||||||
|
threads: sortedByRecency(byStatus.get(status) ?? []),
|
||||||
|
defaultCollapsed: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byRepo = new Map<string, Array<AgentThread>>()
|
||||||
|
for (const thread of threads) {
|
||||||
|
const key = thread.repoFullName || "No repository"
|
||||||
|
const list = byRepo.get(key) ?? []
|
||||||
|
list.push(thread)
|
||||||
|
byRepo.set(key, list)
|
||||||
|
}
|
||||||
|
return [...byRepo.keys()]
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((repo) => ({
|
||||||
|
key: repo,
|
||||||
|
label: repo,
|
||||||
|
threads: sortedByRecency(byRepo.get(repo) ?? []),
|
||||||
|
defaultCollapsed: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when any filter dimension differs from the defaults. */
|
||||||
|
export function hasActiveFilters(filters: SidebarFilters): boolean {
|
||||||
|
return (
|
||||||
|
filters.ownership !== DEFAULT_SIDEBAR_FILTERS.ownership ||
|
||||||
|
filters.statuses.length > 0 ||
|
||||||
|
filters.sources.length > 0 ||
|
||||||
|
filters.pr.length > 0 ||
|
||||||
|
filters.models.length > 0 ||
|
||||||
|
filters.repos.length > 0 ||
|
||||||
|
filters.includeResolved !== DEFAULT_SIDEBAR_FILTERS.includeResolved
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle membership of a value within a filter array (immutable). */
|
||||||
|
export function toggleArrayValue<T>(values: Array<T>, value: T): Array<T> {
|
||||||
|
return values.includes(value)
|
||||||
|
? values.filter((v) => v !== value)
|
||||||
|
: [...values, value]
|
||||||
|
}
|
||||||
119
ui/src/lib/agents/sidebarPrefs.ts
Normal file
119
ui/src/lib/agents/sidebarPrefs.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SIDEBAR_FILTERS,
|
||||||
|
type SidebarFilters,
|
||||||
|
type SidebarGroupMode,
|
||||||
|
} from "./sidebarFilter"
|
||||||
|
|
||||||
|
const STORAGE_KEY = "open-swe.agents.sidebar-prefs"
|
||||||
|
|
||||||
|
const GROUP_MODES: ReadonlyArray<SidebarGroupMode> = [
|
||||||
|
"none",
|
||||||
|
"date",
|
||||||
|
"status",
|
||||||
|
"repo",
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface SidebarPrefs {
|
||||||
|
group: SidebarGroupMode
|
||||||
|
compact: boolean
|
||||||
|
filters: SidebarFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SIDEBAR_PREFS: SidebarPrefs = {
|
||||||
|
group: "date",
|
||||||
|
compact: false,
|
||||||
|
filters: DEFAULT_SIDEBAR_FILTERS,
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(value: unknown): Array<string> {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((v): v is string => typeof v === "string")
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilters(value: unknown): SidebarFilters {
|
||||||
|
const raw =
|
||||||
|
value && typeof value === "object" ? (value as Record<string, unknown>) : {}
|
||||||
|
const ownership = raw.ownership
|
||||||
|
return {
|
||||||
|
ownership:
|
||||||
|
ownership === "mine" || ownership === "shared" || ownership === "all"
|
||||||
|
? ownership
|
||||||
|
: DEFAULT_SIDEBAR_FILTERS.ownership,
|
||||||
|
statuses: asStringArray(raw.statuses) as SidebarFilters["statuses"],
|
||||||
|
sources: asStringArray(raw.sources) as SidebarFilters["sources"],
|
||||||
|
pr: asStringArray(raw.pr) as SidebarFilters["pr"],
|
||||||
|
models: asStringArray(raw.models),
|
||||||
|
repos: asStringArray(raw.repos),
|
||||||
|
includeResolved:
|
||||||
|
typeof raw.includeResolved === "boolean"
|
||||||
|
? raw.includeResolved
|
||||||
|
: DEFAULT_SIDEBAR_FILTERS.includeResolved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePrefs(value: unknown): SidebarPrefs {
|
||||||
|
const raw =
|
||||||
|
value && typeof value === "object" ? (value as Record<string, unknown>) : {}
|
||||||
|
const group = raw.group
|
||||||
|
return {
|
||||||
|
group: GROUP_MODES.includes(group as SidebarGroupMode)
|
||||||
|
? (group as SidebarGroupMode)
|
||||||
|
: DEFAULT_SIDEBAR_PREFS.group,
|
||||||
|
compact:
|
||||||
|
typeof raw.compact === "boolean"
|
||||||
|
? raw.compact
|
||||||
|
: DEFAULT_SIDEBAR_PREFS.compact,
|
||||||
|
filters: sanitizeFilters(raw.filters),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrefs(): SidebarPrefs {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_SIDEBAR_PREFS
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return DEFAULT_SIDEBAR_PREFS
|
||||||
|
return sanitizePrefs(JSON.parse(raw))
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SIDEBAR_PREFS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarPrefs() {
|
||||||
|
const [prefs, setPrefs] = useState<SidebarPrefs>(loadPrefs)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||||
|
} catch {
|
||||||
|
/* ignore persistence failures (private mode, quota, SSR) */
|
||||||
|
}
|
||||||
|
}, [prefs])
|
||||||
|
|
||||||
|
const setGroup = useCallback(
|
||||||
|
(group: SidebarGroupMode) => setPrefs((prev) => ({ ...prev, group })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const setCompact = useCallback(
|
||||||
|
(compact: boolean) => setPrefs((prev) => ({ ...prev, compact })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const setFilters = useCallback(
|
||||||
|
(filters: SidebarFilters) => setPrefs((prev) => ({ ...prev, filters })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const resetFilters = useCallback(
|
||||||
|
() =>
|
||||||
|
setPrefs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
filters: { ...DEFAULT_SIDEBAR_FILTERS },
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { prefs, setGroup, setCompact, setFilters, resetFilters }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseSidebarPrefs = ReturnType<typeof useSidebarPrefs>
|
||||||
Loading…
Add table
Reference in a new issue