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:
Johannes du Plessis 2026-06-26 11:43:50 -07:00 • committed by GitHub
parent 2f56d7546a
commit baf0c248ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 998 additions and 40 deletions

View file

@ -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>

View 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>
)
}

View 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"])
})
})

View 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]
}

View 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>