fix: update sidebar thread activity indicators (#1441)

* fix: update sidebar thread activity indicators

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

* fix: improve sidebar thread organization

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

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-08 10:38:31 -07:00 • committed by GitHub
parent bf8407d39c
commit ec41f138fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 468 additions and 132 deletions

View file

@ -232,8 +232,28 @@ def _run_status_to_agent_status(thread_status: str | None, run_status: str | Non
return "idle" return "idle"
def _thread_run_id(metadata: dict[str, Any], latest_run_id: str | None) -> str | None:
if latest_run_id:
return latest_run_id
run_id = metadata.get("latest_run_id")
return run_id if isinstance(run_id, str) and run_id else None
def _is_thread_viewed(metadata: dict[str, Any], latest_run_id: str | None) -> bool:
viewed_at = metadata.get("last_viewed_at_ms")
viewed_run_id = metadata.get("last_viewed_run_id")
run_id = _thread_run_id(metadata, latest_run_id)
if run_id:
return viewed_run_id == run_id
return isinstance(viewed_at, (int, float))
def _thread_summary( def _thread_summary(
thread: dict[str, Any], *, messages: list[dict[str, Any]] | None = None thread: dict[str, Any],
*,
messages: list[dict[str, Any]] | None = None,
latest_run_status: str | None = None,
latest_run_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {} metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
owner, name, full_name = _metadata_repo(metadata) owner, name, full_name = _metadata_repo(metadata)
@ -243,11 +263,11 @@ def _thread_summary(
model = metadata.get("model") if isinstance(metadata.get("model"), str) else "Default" model = metadata.get("model") if isinstance(metadata.get("model"), str) else "Default"
effort = metadata.get("effort") if isinstance(metadata.get("effort"), str) else None effort = metadata.get("effort") if isinstance(metadata.get("effort"), str) else None
thread_status = thread.get("status") if isinstance(thread.get("status"), str) else "idle" thread_status = thread.get("status") if isinstance(thread.get("status"), str) else "idle"
latest_run_status = metadata.get("latest_run_status") metadata_run_status = metadata.get("latest_run_status")
status = _run_status_to_agent_status( run_status = latest_run_status or (
thread_status, metadata_run_status if isinstance(metadata_run_status, str) else None
latest_run_status if isinstance(latest_run_status, str) else None,
) )
status = _run_status_to_agent_status(thread_status, run_status)
pr_number = metadata.get("pr_number") pr_number = metadata.get("pr_number")
pr_url = metadata.get("pr_url") pr_url = metadata.get("pr_url")
@ -264,6 +284,12 @@ def _thread_summary(
"effort": effort, "effort": effort,
"source": _thread_source(metadata), "source": _thread_source(metadata),
"status": status, "status": status,
"viewed": _is_thread_viewed(metadata, latest_run_id),
"viewedAt": (
int(metadata["last_viewed_at_ms"])
if isinstance(metadata.get("last_viewed_at_ms"), (int, float))
else None
),
"createdAt": int(created_at) if isinstance(created_at, (int, float)) else _now_ms(), "createdAt": int(created_at) if isinstance(created_at, (int, float)) else _now_ms(),
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(), "updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
} }
@ -283,13 +309,52 @@ def _thread_summary(
return summary return summary
async def _latest_run_status(thread_id: str) -> str | None: async def _latest_run_info(client: Any, thread_id: str) -> tuple[str | None, str | None]:
runs = await langgraph_client().runs.list(thread_id, limit=1) try:
runs = await client.runs.list(thread_id, limit=1)
except Exception: # noqa: BLE001
logger.debug("Could not fetch latest run for thread %s", thread_id, exc_info=True)
return None, None
if not runs: if not runs:
return None return None, None
run = runs[0] run = runs[0]
raw = run.get("status") if isinstance(run, dict) else getattr(run, "status", None) raw_status = run.get("status") if isinstance(run, dict) else getattr(run, "status", None)
return raw.lower() if isinstance(raw, str) else None raw_id = (
(run.get("run_id") or run.get("id"))
if isinstance(run, dict)
else (getattr(run, "run_id", None) or getattr(run, "id", None))
)
status = raw_status.lower() if isinstance(raw_status, str) else None
run_id = raw_id if isinstance(raw_id, str) and raw_id else None
return status, run_id
async def _latest_run_status(thread_id: str) -> str | None:
status, _ = await _latest_run_info(langgraph_client(), thread_id)
return status
async def _refresh_latest_run_metadata(
client: Any, thread: dict[str, Any]
) -> tuple[dict[str, Any], str | None, str | None]:
thread_id = thread.get("thread_id") or thread.get("id")
if not isinstance(thread_id, str) or not thread_id:
return thread, None, None
latest_run_status, latest_run_id = await _latest_run_info(client, thread_id)
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
metadata_update: dict[str, Any] = {}
if latest_run_status and latest_run_status != metadata.get("latest_run_status"):
metadata_update["latest_run_status"] = latest_run_status
if latest_run_id and latest_run_id != metadata.get("latest_run_id"):
metadata_update["latest_run_id"] = latest_run_id
if metadata_update:
try:
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
except Exception: # noqa: BLE001
logger.debug("Could not persist latest run metadata for %s", thread_id, exc_info=True)
else:
thread = {**thread, "metadata": {**metadata, **metadata_update}}
return thread, latest_run_status, latest_run_id
async def list_dashboard_threads( async def list_dashboard_threads(
@ -318,11 +383,42 @@ async def list_dashboard_threads(
if isinstance(thread_id, str) and thread_id not in seen: if isinstance(thread_id, str) and thread_id not in seen:
seen[thread_id] = thread seen[thread_id] = thread
summaries = [_thread_summary(thread) for thread in seen.values()] summaries: list[dict[str, Any]] = []
for thread in seen.values():
refreshed, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(
client, thread
)
summaries.append(
_thread_summary(
refreshed,
latest_run_status=latest_run_status,
latest_run_id=latest_run_id,
)
)
summaries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True) summaries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True)
return summaries[:limit] return summaries[:limit]
async def _mark_thread_viewed(
client: Any,
thread_id: str,
metadata: dict[str, Any],
*,
latest_run_id: str | None,
) -> dict[str, Any]:
now_ms = _now_ms()
metadata_update: dict[str, Any] = {"last_viewed_at_ms": now_ms}
run_id = _thread_run_id(metadata, latest_run_id)
if run_id:
metadata_update["last_viewed_run_id"] = run_id
try:
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
except Exception: # noqa: BLE001
logger.debug("Could not mark thread %s viewed", thread_id, exc_info=True)
return metadata
return {**metadata, **metadata_update}
async def get_dashboard_thread( async def get_dashboard_thread(
thread_id: str, login: str, *, email: str | None = None thread_id: str, login: str, *, email: str | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -334,6 +430,7 @@ async def get_dashboard_thread(
raise HTTPException(404, "thread not found") from exc raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {} metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login, email)
messages: list[dict[str, Any]] = [] messages: list[dict[str, Any]] = []
try: try:
@ -348,12 +445,32 @@ async def get_dashboard_thread(
raw_messages = values.get("messages") if isinstance(values, dict) else [] raw_messages = values.get("messages") if isinstance(values, dict) else []
messages = state_messages_to_ui(raw_messages if isinstance(raw_messages, list) else []) messages = state_messages_to_ui(raw_messages if isinstance(raw_messages, list) else [])
latest_run_status = await _latest_run_status(thread_id) thread, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread)
if latest_run_status and latest_run_status != metadata.get("latest_run_status"): metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
metadata = {**metadata, "latest_run_status": latest_run_status} status = _run_status_to_agent_status(
thread.get("status") if isinstance(thread.get("status"), str) else "idle",
latest_run_status
or (
metadata.get("latest_run_status")
if isinstance(metadata.get("latest_run_status"), str)
else None
),
)
if status != "running":
metadata = await _mark_thread_viewed(
client,
thread_id,
metadata,
latest_run_id=latest_run_id,
)
thread = {**thread, "metadata": metadata} thread = {**thread, "metadata": metadata}
return _thread_summary(thread, messages=messages) return _thread_summary(
thread,
messages=messages,
latest_run_status=latest_run_status,
latest_run_id=latest_run_id,
)
def _resolve_repo_config(repo: str | None) -> dict[str, str]: def _resolve_repo_config(repo: str | None) -> dict[str, str]:

View file

@ -0,0 +1,103 @@
from typing import Any
from agent.dashboard import thread_api
class FakeThreads:
def __init__(self, metadata: dict[str, Any], *, status: str = "idle") -> None:
self.thread = {"thread_id": "tid", "status": status, "metadata": metadata.copy()}
self.updates: list[dict[str, Any]] = []
async def get(self, thread_id: str) -> dict[str, Any]:
assert thread_id == "tid"
return self.thread
async def get_state(self, thread_id: str) -> dict[str, Any]:
assert thread_id == "tid"
return {"values": {"messages": []}}
async def update(self, *, thread_id: str, metadata: dict[str, Any]) -> None:
assert thread_id == "tid"
self.updates.append(metadata)
self.thread["metadata"] = {**self.thread["metadata"], **metadata}
async def search(self, **kwargs: Any) -> list[dict[str, Any]]:
return [self.thread]
class FakeRuns:
def __init__(self, status: str, run_id: str = "run-1") -> None:
self.status = status
self.run_id = run_id
async def list(self, thread_id: str, limit: int = 1) -> list[dict[str, str]]:
assert thread_id == "tid"
assert limit == 1
return [{"run_id": self.run_id, "status": self.status}]
class FakeClient:
def __init__(
self, metadata: dict[str, Any], run_status: str, *, thread_status: str = "idle"
) -> None:
self.threads = FakeThreads(metadata, status=thread_status)
self.runs = FakeRuns(run_status)
async def test_list_dashboard_threads_refreshes_finished_run_status(monkeypatch) -> None:
client = FakeClient(
{
"source": "dashboard",
"github_login": "octocat",
"latest_run_id": "run-1",
"latest_run_status": "pending",
},
"success",
)
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
results = await thread_api.list_dashboard_threads("octocat")
assert results[0]["status"] == "finished"
assert results[0]["viewed"] is False
assert client.threads.thread["metadata"]["latest_run_status"] == "success"
async def test_get_dashboard_thread_marks_finished_thread_viewed(monkeypatch) -> None:
client = FakeClient(
{
"source": "dashboard",
"github_login": "octocat",
"latest_run_id": "run-1",
"latest_run_status": "success",
},
"success",
)
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
result = await thread_api.get_dashboard_thread("tid", "octocat")
assert result["status"] == "finished"
assert result["viewed"] is True
assert isinstance(result["viewedAt"], int)
assert client.threads.thread["metadata"]["last_viewed_run_id"] == "run-1"
async def test_get_dashboard_thread_does_not_mark_running_thread_viewed(monkeypatch) -> None:
client = FakeClient(
{
"source": "dashboard",
"github_login": "octocat",
"latest_run_id": "run-1",
"latest_run_status": "running",
},
"running",
thread_status="busy",
)
monkeypatch.setattr(thread_api, "langgraph_client", lambda: client)
result = await thread_api.get_dashboard_thread("tid", "octocat")
assert result["status"] == "running"
assert result["viewed"] is False
assert "last_viewed_run_id" not in client.threads.thread["metadata"]

View file

@ -1,101 +1,128 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import type { SessionUser } from "@/lib/api"; import type { SessionUser } from "@/lib/api"
import type { PendingPrompt } from "@/lib/agents/pendingPrompts"; import type { PendingPrompt } from "@/lib/agents/pendingPrompts"
import type { AgentThread, Message } from "@/lib/agents/types"; import type { AgentThread, Message } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/useModelOptions"; import type { ModelSelection } from "@/lib/agents/useModelOptions"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"; import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
import { AgentsShell } from "@/components/agents/AgentsSidebar"; import { AgentsShell } from "@/components/agents/AgentsSidebar"
import { MessageView } from "@/components/agents/ported"; import { MessageView } from "@/components/agents/ported"
import { useCancelAgentThread, useSendAgentMessage } from "@/lib/agents/queries"; import {
import { dropPendingPrompts, getPendingPrompts } from "@/lib/agents/pendingPrompts"; agentThreadKeys,
import { useAgentThreadStream } from "@/lib/agents/useThreadStream"; useCancelAgentThread,
import { useModelOptions } from "@/lib/agents/useModelOptions"; useSendAgentMessage,
} from "@/lib/agents/queries"
import {
dropPendingPrompts,
getPendingPrompts,
} from "@/lib/agents/pendingPrompts"
import { useAgentThreadStream } from "@/lib/agents/useThreadStream"
import { useModelOptions } from "@/lib/agents/useModelOptions"
interface AgentThreadViewProps { interface AgentThreadViewProps {
user: SessionUser; user: SessionUser
thread: AgentThread; thread: AgentThread
} }
function messageText(message: Message): string { function messageText(message: Message): string {
return message.chunks return message.chunks
.filter((chunk) => chunk.kind === "text") .filter((chunk) => chunk.kind === "text")
.map((chunk) => chunk.text) .map((chunk) => chunk.text)
.join(""); .join("")
} }
function messageImageKey(message: Message): string { function messageImageKey(message: Message): string {
return message.chunks return message.chunks
.filter((chunk) => chunk.kind === "image") .filter((chunk) => chunk.kind === "image")
.map((chunk) => `${chunk.mimeType}:${chunk.base64}`) .map((chunk) => `${chunk.mimeType}:${chunk.base64}`)
.join("\u0000"); .join("\u0000")
} }
function pendingImageKey(entry: PendingPrompt): string { function pendingImageKey(entry: PendingPrompt): string {
return (entry.images ?? []) return (entry.images ?? [])
.map((image) => `${image.mimeType}:${image.base64}`) .map((image) => `${image.mimeType}:${image.base64}`)
.join("\u0000"); .join("\u0000")
} }
function isPendingPromptConfirmed(entry: PendingPrompt, messages: Array<Message>): boolean { function isPendingPromptConfirmed(
entry: PendingPrompt,
messages: Array<Message>
): boolean {
return messages.slice(entry.insertAt).some((message) => { return messages.slice(entry.insertAt).some((message) => {
if (message.author !== "user") return false; if (message.author !== "user") return false
return messageText(message) === entry.prompt && messageImageKey(message) === pendingImageKey(entry); return (
}); messageText(message) === entry.prompt &&
messageImageKey(message) === pendingImageKey(entry)
)
})
} }
export function AgentThreadView({ user, thread }: AgentThreadViewProps) { export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
const sendMessage = useSendAgentMessage(thread.id); const queryClient = useQueryClient()
const cancelThread = useCancelAgentThread(thread.id); const sendMessage = useSendAgentMessage(thread.id)
useAgentThreadStream(thread.id, thread.status === "running"); const cancelThread = useCancelAgentThread(thread.id)
const [pendingPrompts, setPendingPrompts] = useState<Array<PendingPrompt>>(() => useAgentThreadStream(thread.id, thread.status === "running")
getPendingPrompts(thread.id), const [pendingPrompts, setPendingPrompts] = useState<Array<PendingPrompt>>(
); () => getPendingPrompts(thread.id)
)
const { models, defaultSelection } = useModelOptions(); const { models, defaultSelection } = useModelOptions()
const threadSelection = useMemo<ModelSelection | null>(() => { const threadSelection = useMemo<ModelSelection | null>(() => {
if (!thread.model || !thread.effort) return null; if (!thread.model || !thread.effort) return null
const supported = models.some( const supported = models.some(
(m) => m.id === thread.model && m.efforts.includes(thread.effort ?? ""), (m) => m.id === thread.model && m.efforts.includes(thread.effort ?? "")
); )
if (!supported) return null; if (!supported) return null
return { modelId: thread.model, effort: thread.effort }; return { modelId: thread.model, effort: thread.effort }
}, [models, thread.model, thread.effort]); }, [models, thread.model, thread.effort])
const [selection, setSelection] = useState<ModelSelection | null>(null); const [selection, setSelection] = useState<ModelSelection | null>(null)
const activeSelection = selection ?? threadSelection ?? defaultSelection; const activeSelection = selection ?? threadSelection ?? defaultSelection
useEffect(() => { useEffect(() => {
setPendingPrompts((prev) => { setPendingPrompts((prev) => {
if (prev.length === 0) return prev; if (prev.length === 0) return prev
const next = dropPendingPrompts(thread.id, (entry) => const next = dropPendingPrompts(thread.id, (entry) =>
isPendingPromptConfirmed(entry, thread.messages), isPendingPromptConfirmed(entry, thread.messages)
); )
return next.length === prev.length ? prev : next; return next.length === prev.length ? prev : next
}); })
}, [thread.id, thread.messages]); }, [thread.id, thread.messages])
useEffect(() => {
queryClient.setQueryData<Array<AgentThread> | undefined>(
agentThreadKeys.all,
(threads) =>
threads?.map((item) =>
item.id === thread.id
? { ...item, ...thread, messages: item.messages }
: item
)
)
}, [queryClient, thread])
const displayMessages = useMemo<Array<Message>>(() => { const displayMessages = useMemo<Array<Message>>(() => {
if (pendingPrompts.length === 0) return thread.messages; if (pendingPrompts.length === 0) return thread.messages
const baseTimestamp = new Date().toISOString(); const baseTimestamp = new Date().toISOString()
const result = thread.messages.slice(); const result = thread.messages.slice()
pendingPrompts.forEach((entry, i) => { pendingPrompts.forEach((entry, i) => {
const chunks: Message["chunks"] = [...(entry.images ?? [])]; const chunks: Message["chunks"] = [...(entry.images ?? [])]
if (entry.prompt) chunks.push({ kind: "text", text: entry.prompt }); if (entry.prompt) chunks.push({ kind: "text", text: entry.prompt })
const synth: Message = { const synth: Message = {
id: `pending-user-${i}`, id: `pending-user-${i}`,
author: "user", author: "user",
timestamp: baseTimestamp, timestamp: baseTimestamp,
chunks, chunks,
}; }
const at = Math.min(Math.max(entry.insertAt, 0), result.length); const at = Math.min(Math.max(entry.insertAt, 0), result.length)
result.splice(at, 0, synth); result.splice(at, 0, synth)
}); })
return result; return result
}, [thread.messages, pendingPrompts]); }, [thread.messages, pendingPrompts])
const hasMessages = displayMessages.length > 0; const hasMessages = displayMessages.length > 0
const hasActiveRun = thread.status === "running"; const hasActiveRun = thread.status === "running"
const isStreaming = hasActiveRun || pendingPrompts.length > 0; const isStreaming = hasActiveRun || pendingPrompts.length > 0
return ( return (
<AgentsShell user={user} activeThreadId={thread.id}> <AgentsShell user={user} activeThreadId={thread.id}>
@ -109,7 +136,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
contentWidthClass="max-w-3xl" contentWidthClass="max-w-3xl"
/> />
<div className="shrink-0 px-4 pb-4"> <div className="shrink-0 px-4 pb-4">
<div className="mx-auto w-full min-w-0 max-w-3xl"> <div className="mx-auto w-full max-w-3xl min-w-0">
<AgentPromptBar <AgentPromptBar
placeholder="Add a follow up" placeholder="Add a follow up"
compact compact
@ -134,7 +161,9 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6"> <div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-sm text-[var(--ui-text-dim)]">This thread has no messages yet.</p> <p className="text-sm text-[var(--ui-text-dim)]">
This thread has no messages yet.
</p>
<div className="w-full max-w-3xl"> <div className="w-full max-w-3xl">
<AgentPromptBar <AgentPromptBar
placeholder="Send the first message" placeholder="Send the first message"
@ -161,5 +190,5 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) {
</div> </div>
</div> </div>
</AgentsShell> </AgentsShell>
); )
} }

View file

@ -1,19 +1,25 @@
import { Dialog } from "@base-ui/react/dialog"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { import {
CalendarBlankIcon, CalendarBlankIcon,
CaretDownIcon,
CaretRightIcon,
ChartLineUpIcon, ChartLineUpIcon,
ChatCircleIcon, ChatCircleIcon,
CircleNotchIcon,
LightningIcon, LightningIcon,
PlusIcon, PlusIcon,
XIcon, XIcon,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5" import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
import { SiLinear } from "react-icons/si" import { SiLinear } from "react-icons/si"
import { useState } from "react"
import type { ComponentType, SVGProps } from "react" import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api" import type { SessionUser } from "@/lib/api"
import type { AgentSource, AgentThread } from "@/lib/agents/types" import type { AgentSource, AgentThread } from "@/lib/agents/types"
import { SidebarUserMenu } from "@/components/SidebarUserMenu" import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import { Button } from "@/components/ui/button"
import { import {
SidebarCollapseButton, SidebarCollapseButton,
SidebarFrame, SidebarFrame,
@ -104,11 +110,19 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
activeThreadId={activeThreadId} activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile} onNavigate={layout.closeOnMobile}
/> />
<ThreadGroup
label="Last 7 days"
threads={groups.last7}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup <ThreadGroup
label="Last 30 days" label="Last 30 days"
threads={groups.last30} threads={groups.last30}
activeThreadId={activeThreadId} activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile} onNavigate={layout.closeOnMobile}
defaultCollapsed
/> />
<ThreadGroup <ThreadGroup
label="Older" label="Older"
@ -130,27 +144,40 @@ function ThreadGroup({
threads, threads,
activeThreadId, activeThreadId,
onNavigate, onNavigate,
defaultCollapsed = false,
}: { }: {
label: string label: string
threads: Array<AgentThread> threads: Array<AgentThread>
activeThreadId?: string activeThreadId?: string
onNavigate?: () => void onNavigate?: () => void
defaultCollapsed?: boolean
}) { }) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
if (threads.length === 0) return null if (threads.length === 0) return null
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
return ( return (
<div className="mb-3"> <div className="mb-3">
<div className="px-2 py-1 text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase"> <button
{label} type="button"
</div> onClick={() => setCollapsed((value) => !value)}
{threads.map((thread) => ( className="flex w-full items-center gap-1 px-2 py-1 text-left text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase transition-colors hover:text-[var(--ui-text-muted)]"
<ThreadRow aria-expanded={!collapsed}
key={thread.id} >
thread={thread} <ToggleIcon className="size-3" />
isActive={thread.id === activeThreadId} <span className="min-w-0 flex-1 truncate">{label}</span>
onNavigate={onNavigate} <span>{threads.length}</span>
/> </button>
))} {!collapsed &&
threads.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
isActive={thread.id === activeThreadId}
onNavigate={onNavigate}
/>
))}
</div> </div>
) )
} }
@ -165,6 +192,7 @@ function ThreadRow({
onNavigate?: () => void onNavigate?: () => void
}) { }) {
const deleteThread = useDeleteAgentThread() const deleteThread = useDeleteAgentThread()
const [deleteOpen, setDeleteOpen] = useState(false)
const badge = const badge =
thread.diffStats && thread.diffStats.additions > 0 thread.diffStats && thread.diffStats.additions > 0
? `+${thread.diffStats.additions}` ? `+${thread.diffStats.additions}`
@ -176,9 +204,14 @@ function ThreadRow({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (isDeleting) return if (isDeleting) return
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) setDeleteOpen(true)
return }
deleteThread.mutate(thread.id)
const onConfirmDelete = () => {
if (isDeleting) return
deleteThread.mutate(thread.id, {
onSuccess: () => setDeleteOpen(false),
})
} }
const source = const source =
@ -186,54 +219,98 @@ function ThreadRow({
? SOURCE_META[thread.source] ? SOURCE_META[thread.source]
: null : null
const SourceIcon = source?.icon const SourceIcon = source?.icon
const showFinishedIndicator = thread.status === "finished" && !thread.viewed
return ( return (
<Link <>
to="/agents/$threadId" <Link
params={{ threadId: thread.id }} to="/agents/$threadId"
onClick={onNavigate} params={{ threadId: thread.id }}
className={cn( onClick={onNavigate}
"group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors",
isActive
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
isDeleting && "opacity-50"
)}
>
<span
className={cn( className={cn(
"size-2 shrink-0 rounded-full", "group mb-0.5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors",
thread.status === "running" isActive
? "animate-pulse bg-[var(--ui-accent)]" ? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
: thread.status === "finished" : "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
? "bg-[var(--ui-accent)]" isDeleting && "opacity-50"
: "bg-[var(--ui-border)]"
)} )}
/>
{source && SourceIcon && (
<SourceIcon
className="size-3.5 shrink-0 text-[var(--ui-text-dim)]"
aria-label={source.label}
>
<title>{source.label}</title>
</SourceIcon>
)}
<span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
{badge && (
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-dim)] group-hover:hidden">
{badge}
</span>
)}
<button
type="button"
aria-label="Delete thread"
onClick={onDelete}
disabled={isDeleting}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
> >
<XIcon className="size-3" weight="bold" /> {thread.status === "running" ? (
</button> <CircleNotchIcon
</Link> className="size-3 shrink-0 animate-spin text-[var(--ui-accent)]"
aria-label="Thread running"
/>
) : (
<span
className={cn(
"size-2 shrink-0 rounded-full",
showFinishedIndicator
? "bg-[var(--ui-accent)]"
: "bg-[var(--ui-border)]"
)}
aria-label={
showFinishedIndicator ? "Thread finished" : "Thread viewed"
}
/>
)}
{source && SourceIcon && (
<SourceIcon
className="size-3.5 shrink-0 text-[var(--ui-text-dim)]"
aria-label={source.label}
>
<title>{source.label}</title>
</SourceIcon>
)}
<span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span>
{badge && (
<span className="shrink-0 rounded bg-[var(--ui-panel-2)] px-1.5 py-0.5 text-[10px] text-[var(--ui-text-dim)] group-hover:hidden">
{badge}
</span>
)}
<button
type="button"
aria-label="Delete thread"
onClick={onDelete}
disabled={isDeleting}
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<XIcon className="size-3" weight="bold" />
</button>
</Link>
<Dialog.Root open={deleteOpen} onOpenChange={setDeleteOpen}>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 z-50 bg-black/50 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0" />
<Dialog.Popup className="fixed top-1/2 left-1/2 z-50 w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-popover p-6 text-popover-foreground shadow-md ring-1 ring-foreground/10 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">
<div className="flex flex-col gap-4">
<Dialog.Title className="text-base font-semibold">
Delete thread
</Dialog.Title>
<Dialog.Description className="text-sm text-muted-foreground">
Delete "{thread.title}"? This cannot be undone.
</Dialog.Description>
<div className="mt-2 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</>
) )
} }

View file

@ -135,17 +135,19 @@ export function formatRelativeTime(ts: number): string {
return `${weeks}w` return `${weeks}w`
} }
export type ThreadGroup = "today" | "last30" | "older" export type ThreadGroup = "today" | "last7" | "last30" | "older"
export function groupThreads( export function groupThreads(
threads: Array<AgentThread> threads: Array<AgentThread>
): Record<ThreadGroup, Array<AgentThread>> { ): Record<ThreadGroup, Array<AgentThread>> {
const todayStart = new Date() const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0) todayStart.setHours(0, 0, 0, 0)
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000 const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
const groups: Record<ThreadGroup, Array<AgentThread>> = { const groups: Record<ThreadGroup, Array<AgentThread>> = {
today: [], today: [],
last7: [],
last30: [], last30: [],
older: [], older: [],
} }
@ -153,6 +155,8 @@ export function groupThreads(
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) { for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
if (thread.updatedAt >= todayStart.getTime()) { if (thread.updatedAt >= todayStart.getTime()) {
groups.today.push(thread) groups.today.push(thread)
} else if (thread.updatedAt >= sevenDaysAgo) {
groups.last7.push(thread)
} else if (thread.updatedAt >= thirtyDaysAgo) { } else if (thread.updatedAt >= thirtyDaysAgo) {
groups.last30.push(thread) groups.last30.push(thread)
} else { } else {

View file

@ -19,6 +19,10 @@ export function useAgentThreads() {
return useQuery({ return useQuery({
queryKey: agentThreadKeys.all, queryKey: agentThreadKeys.all,
queryFn: () => agentsApi.listThreads(), queryFn: () => agentsApi.listThreads(),
refetchInterval: (query) =>
query.state.data?.some((thread) => thread.status === "running")
? 2000
: false,
}) })
} }

View file

@ -163,6 +163,8 @@ export interface AgentThread {
effort?: string | null effort?: string | null
source?: AgentSource source?: AgentSource
status: AgentStatus status: AgentStatus
viewed: boolean
viewedAt?: number | null
createdAt: number createdAt: number
updatedAt: number updatedAt: number
messages: Array<Message> messages: Array<Message>