open-swe/ui/src/components/agents/AgentsThreadsPage.tsx
Caroline di Vittorio e8bb6b497b
feat: mark threads as resolved to hide from sidebar (#1500)
* feat: mark threads resolved to hide from sidebar [closes resolve-threads]

Add a resolved flag (thread metadata) so users can clear finished
threads from the sidebar without deleting them. Resolved threads move to
a collapsible "Resolved" group (capped at 20 with a "Show all" link) and
are fully searchable/filterable + paginated on a new /agents/threads
page driven by URL query params. Sending a new message auto-unresolves.

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

* fix: refresh paginated agent thread lists

Invalidate all agent thread list queries when thread lifecycle events change sidebar data, keeping the new paginated sidebar cache in sync.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Johannes du Plessis <51395795+johannes117@users.noreply.github.com>
2026-06-12 12:01:42 -07:00

302 lines
9.2 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 total = data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
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-lg 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>
{total > 0 && (
<div className="mt-auto flex items-center justify-between pt-2 text-xs text-[var(--ui-text-muted)]">
<span>
{offset + 1}–{Math.min(offset + PAGE_SIZE, total)} of {total}
</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} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={filters.page >= totalPages}
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>
)
}