open-swe/ui/src/components/agents/AgentsThreadsPage.tsx
Johannes du Plessis 39a26e16b5
fix: optimize agent thread lists (#1570)
* fix: optimize agent thread lists

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

* fix: refresh missing thread run status

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-18 10:09:22 -07:00

302 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Link } from "@tanstack/react-router"
import {
ArrowCounterClockwiseIcon,
CaretLeftIcon,
CaretRightIcon,
CheckCircleIcon,
} from "@phosphor-icons/react"
import { useState } from "react"
import type { AgentSource, AgentStatus, AgentThread } from "@/lib/agents/types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useResolveAgentThread, useThreadsPage } from "@/lib/agents/queries"
import { cn } from "@/lib/utils"
const PAGE_SIZE = 25
export interface ThreadsPageFilters {
resolved?: boolean
viewed?: boolean
source?: AgentSource
status?: AgentStatus
q?: string
page: number
}
type TriState = "any" | "true" | "false"
const TRI_OPTIONS: Array<{ value: TriState; label: string }> = [
{ value: "any", label: "Any" },
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
]
const SOURCE_OPTIONS: Array<{ value: AgentSource | "any"; label: string }> = [
{ value: "any", label: "Any source" },
{ value: "dashboard", label: "Dashboard" },
{ value: "github", label: "GitHub" },
{ value: "slack", label: "Slack" },
{ value: "linear", label: "Linear" },
{ value: "schedule", label: "Schedule" },
]
const STATUS_OPTIONS: Array<{ value: AgentStatus | "any"; label: string }> = [
{ value: "any", label: "Any status" },
{ value: "running", label: "Running" },
{ value: "finished", label: "Finished" },
{ value: "error", label: "Error" },
{ value: "idle", label: "Idle" },
]
function boolToTri(value?: boolean): TriState {
if (value === true) return "true"
if (value === false) return "false"
return "any"
}
function triToBool(value: TriState): boolean | undefined {
if (value === "true") return true
if (value === "false") return false
return undefined
}
export function AgentsThreadsPage({
filters,
onFiltersChange,
}: {
filters: ThreadsPageFilters
onFiltersChange: (next: ThreadsPageFilters) => void
}) {
const [search, setSearch] = useState(filters.q ?? "")
const offset = (filters.page - 1) * PAGE_SIZE
const query = useThreadsPage({
limit: PAGE_SIZE,
offset,
resolved: filters.resolved,
viewed: filters.viewed,
source: filters.source,
status: filters.status,
q: filters.q,
})
const data = query.data
const items = data?.items ?? []
const hasMore = data?.hasMore ?? false
const exactTotal = data?.total
const end = offset + items.length
const update = (patch: Partial<ThreadsPageFilters>) => {
onFiltersChange({ ...filters, ...patch, page: patch.page ?? 1 })
}
const onSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
update({ q: search.trim() || undefined })
}
return (
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6 py-8">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col gap-6">
<div>
<h1 className="font-heading text-base font-medium text-[var(--ui-text)]">
Threads
</h1>
<p className="text-xs text-[var(--ui-text-muted)]">
Search and filter every thread, including resolved ones.
</p>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)] p-3">
<form onSubmit={onSearchSubmit} className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by title..."
className="h-8"
/>
<Button type="submit" size="sm" variant="outline">
Search
</Button>
</form>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<TriFilter
label="Resolved"
value={boolToTri(filters.resolved)}
onChange={(value) => update({ resolved: triToBool(value) })}
/>
<TriFilter
label="Viewed"
value={boolToTri(filters.viewed)}
onChange={(value) => update({ viewed: triToBool(value) })}
/>
<SelectFilter
value={filters.source ?? "any"}
options={SOURCE_OPTIONS}
onChange={(value) =>
update({
source: value === "any" ? undefined : (value as AgentSource),
})
}
/>
<SelectFilter
value={filters.status ?? "any"}
options={STATUS_OPTIONS}
onChange={(value) =>
update({
status: value === "any" ? undefined : (value as AgentStatus),
})
}
/>
</div>
</div>
<div className="flex flex-col gap-1">
{query.isLoading ? (
<p className="px-2 py-8 text-center text-xs text-[var(--ui-text-muted)]">
Loading...
</p>
) : items.length === 0 ? (
<p className="px-2 py-8 text-center text-xs text-[var(--ui-text-muted)]">
No threads match these filters.
</p>
) : (
items.map((thread) => (
<ThreadListItem key={thread.id} thread={thread} />
))
)}
</div>
{(items.length > 0 || filters.page > 1) && (
<div className="mt-auto flex items-center justify-between pt-2 text-xs text-[var(--ui-text-muted)]">
<span>
{items.length > 0 ? `${offset + 1}–${end}` : "No results"}
{exactTotal != null ? ` of ${exactTotal}` : hasMore ? "+" : ""}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={filters.page <= 1}
onClick={() => update({ page: filters.page - 1 })}
>
<CaretLeftIcon className="size-3" />
Prev
</Button>
<span>Page {filters.page}</span>
<Button
size="sm"
variant="outline"
disabled={!hasMore}
onClick={() => update({ page: filters.page + 1 })}
>
Next
<CaretRightIcon className="size-3" />
</Button>
</div>
</div>
)}
</div>
</main>
)
}
function TriFilter({
label,
value,
onChange,
}: {
label: string
value: TriState
onChange: (value: TriState) => void
}) {
return (
<div className="flex items-center gap-1.5">
<span className="text-[var(--ui-text-dim)]">{label}</span>
<div className="flex overflow-hidden rounded-md border border-[var(--ui-border)]">
{TRI_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-2 py-0.5 transition-colors",
value === option.value
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]"
)}
>
{option.label}
</button>
))}
</div>
</div>
)
}
function SelectFilter({
value,
options,
onChange,
}: {
value: string
options: Array<{ value: string; label: string }>
onChange: (value: string) => void
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-7 rounded-md border border-[var(--ui-border)] bg-[var(--ui-panel)] px-2 text-xs text-[var(--ui-text)] outline-none"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
}
function ThreadListItem({ thread }: { thread: AgentThread }) {
const resolveThread = useResolveAgentThread()
const isResolved = thread.resolved === true
const onToggleResolved = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (resolveThread.isPending) return
resolveThread.mutate({ threadId: thread.id, resolved: !isResolved })
}
return (
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
className="group flex items-center gap-3 rounded-lg border border-transparent px-3 py-2 transition-colors hover:border-[var(--ui-border)] hover:bg-[var(--ui-sidebar-hover)]"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-[var(--ui-text)]">{thread.title}</p>
<p className="truncate text-[11px] text-[var(--ui-text-dim)]">
{thread.repoFullName || "no repo"} · {thread.status}
{isResolved ? " · resolved" : ""}
</p>
</div>
<button
type="button"
aria-label={isResolved ? "Unresolve thread" : "Resolve thread"}
title={isResolved ? "Unresolve thread" : "Resolve thread"}
onClick={onToggleResolved}
disabled={resolveThread.isPending}
className="flex size-6 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
{isResolved ? (
<ArrowCounterClockwiseIcon className="size-4" />
) : (
<CheckCircleIcon className="size-4" />
)}
</button>
</Link>
)
}