mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 18:33:15 +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 { SidebarLayout } from "@/components/sidebar-layout"
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||
import { SidebarFilterMenu } from "@/components/agents/SidebarFilterMenu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
SidebarCollapseButton,
|
||||
|
|
@ -34,7 +35,13 @@ import {
|
|||
SidebarLayoutProvider,
|
||||
useSidebarLayout,
|
||||
} 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 {
|
||||
useDeleteAgentThread,
|
||||
useResolveAgentThread,
|
||||
|
|
@ -101,6 +108,8 @@ export function AgentsSidebar({
|
|||
activeThreadId,
|
||||
layout,
|
||||
}: AgentsSidebarProps) {
|
||||
const { prefs, setGroup, setCompact, setFilters, resetFilters } =
|
||||
useSidebarPrefs()
|
||||
const sidebar = useSidebarThreads(RESOLVED_SIDEBAR_LIMIT)
|
||||
const activeThreads = sidebar.data?.active.items ?? []
|
||||
const resolvedThreads = sidebar.data?.resolved.items ?? []
|
||||
|
|
@ -108,7 +117,16 @@ export function AgentsSidebar({
|
|||
const visibleThreads = [...activeThreads, ...resolvedThreads]
|
||||
useSeedAgentThreadDetails(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 (
|
||||
<SidebarFrame
|
||||
|
|
@ -159,42 +177,55 @@ export function AgentsSidebar({
|
|||
</nav>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
<ThreadGroup
|
||||
label="Today"
|
||||
threads={groups.today}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Last 7 days"
|
||||
threads={groups.last7}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Last 30 days"
|
||||
threads={groups.last30}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed
|
||||
/>
|
||||
<ThreadGroup
|
||||
label="Older"
|
||||
threads={groups.older}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
<ResolvedThreadGroup
|
||||
threads={resolvedThreads}
|
||||
hasMore={resolvedHasMore}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
/>
|
||||
{prefs.group === "none"
|
||||
? sections[0]?.threads.map((thread) => (
|
||||
<ThreadRow
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
isActive={thread.id === activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
compact={prefs.compact}
|
||||
/>
|
||||
))
|
||||
: sections.map((section) => (
|
||||
<ThreadGroup
|
||||
key={`${prefs.group}:${section.key}`}
|
||||
label={section.label}
|
||||
threads={section.threads}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
defaultCollapsed={section.defaultCollapsed}
|
||||
compact={prefs.compact}
|
||||
/>
|
||||
))}
|
||||
{showResolved && (
|
||||
<ResolvedThreadGroup
|
||||
threads={filteredResolved}
|
||||
hasMore={resolvedHasMore}
|
||||
activeThreadId={activeThreadId}
|
||||
onNavigate={layout.closeOnMobile}
|
||||
compact={prefs.compact}
|
||||
/>
|
||||
)}
|
||||
{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 className="p-2">
|
||||
<SidebarUserMenu user={user} showSettingsLink />
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<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>
|
||||
</SidebarFrame>
|
||||
)
|
||||
|
|
@ -206,12 +237,14 @@ function ThreadGroup({
|
|||
activeThreadId,
|
||||
onNavigate,
|
||||
defaultCollapsed = false,
|
||||
compact = false,
|
||||
}: {
|
||||
label: string
|
||||
threads: Array<AgentThread>
|
||||
activeThreadId?: string
|
||||
onNavigate?: () => void
|
||||
defaultCollapsed?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
if (threads.length === 0) return null
|
||||
|
|
@ -219,7 +252,7 @@ function ThreadGroup({
|
|||
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className={compact ? "mb-2" : "mb-3"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
|
|
@ -237,6 +270,7 @@ function ThreadGroup({
|
|||
thread={thread}
|
||||
isActive={thread.id === activeThreadId}
|
||||
onNavigate={onNavigate}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -248,11 +282,13 @@ function ResolvedThreadGroup({
|
|||
hasMore,
|
||||
activeThreadId,
|
||||
onNavigate,
|
||||
compact = false,
|
||||
}: {
|
||||
threads: Array<AgentThread>
|
||||
hasMore: boolean
|
||||
activeThreadId?: string
|
||||
onNavigate?: () => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
if (threads.length === 0) return null
|
||||
|
|
@ -283,6 +319,7 @@ function ResolvedThreadGroup({
|
|||
thread={thread}
|
||||
isActive={thread.id === activeThreadId}
|
||||
onNavigate={onNavigate}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
{hasMore && (
|
||||
|
|
@ -305,10 +342,12 @@ function ThreadRow({
|
|||
thread,
|
||||
isActive,
|
||||
onNavigate,
|
||||
compact = false,
|
||||
}: {
|
||||
thread: AgentThread
|
||||
isActive: boolean
|
||||
onNavigate?: () => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
const deleteThread = useDeleteAgentThread()
|
||||
const resolveThread = useResolveAgentThread()
|
||||
|
|
@ -367,7 +406,8 @@ function ThreadRow({
|
|||
params={{ threadId: thread.id }}
|
||||
onClick={onNavigate}
|
||||
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
|
||||
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
||||
: "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">
|
||||
{thread.title}
|
||||
</span>
|
||||
{prMeta && PrIcon && (
|
||||
{!compact && prMeta && PrIcon && (
|
||||
<PrIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 group-hover:hidden",
|
||||
|
|
@ -416,7 +456,7 @@ function ThreadRow({
|
|||
<title>{prMeta.label}</title>
|
||||
</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">
|
||||
{badge}
|
||||
</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