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"
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(
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]:
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
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"
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"
latest_run_status = metadata.get("latest_run_status")
status = _run_status_to_agent_status(
thread_status,
latest_run_status if isinstance(latest_run_status, str) else None,
metadata_run_status = metadata.get("latest_run_status")
run_status = latest_run_status or (
metadata_run_status if isinstance(metadata_run_status, str) else None
)
status = _run_status_to_agent_status(thread_status, run_status)
pr_number = metadata.get("pr_number")
pr_url = metadata.get("pr_url")
@ -264,6 +284,12 @@ def _thread_summary(
"effort": effort,
"source": _thread_source(metadata),
"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(),
"updatedAt": int(updated_at) if isinstance(updated_at, (int, float)) else _now_ms(),
}
@ -283,13 +309,52 @@ def _thread_summary(
return summary
async def _latest_run_status(thread_id: str) -> str | None:
runs = await langgraph_client().runs.list(thread_id, limit=1)
async def _latest_run_info(client: Any, thread_id: str) -> tuple[str | None, str | None]:
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:
return None
return None, None
run = runs[0]
raw = run.get("status") if isinstance(run, dict) else getattr(run, "status", None)
return raw.lower() if isinstance(raw, str) else None
raw_status = run.get("status") if isinstance(run, dict) else getattr(run, "status", 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(
@ -318,11 +383,42 @@ async def list_dashboard_threads(
if isinstance(thread_id, str) and thread_id not in seen:
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)
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(
thread_id: str, login: str, *, email: str | None = None
) -> dict[str, Any]:
@ -334,6 +430,7 @@ async def get_dashboard_thread(
raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
_assert_thread_owner(metadata, login, email)
messages: list[dict[str, Any]] = []
try:
@ -348,12 +445,32 @@ async def get_dashboard_thread(
raw_messages = values.get("messages") if isinstance(values, dict) else []
messages = state_messages_to_ui(raw_messages if isinstance(raw_messages, list) else [])
latest_run_status = await _latest_run_status(thread_id)
if latest_run_status and latest_run_status != metadata.get("latest_run_status"):
metadata = {**metadata, "latest_run_status": latest_run_status}
thread, latest_run_status, latest_run_id = await _refresh_latest_run_metadata(client, thread)
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else metadata
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}
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]:

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

View file

@ -1,19 +1,25 @@
import { Dialog } from "@base-ui/react/dialog"
import { Link } from "@tanstack/react-router"
import {
CalendarBlankIcon,
CaretDownIcon,
CaretRightIcon,
ChartLineUpIcon,
ChatCircleIcon,
CircleNotchIcon,
LightningIcon,
PlusIcon,
XIcon,
} from "@phosphor-icons/react"
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
import { SiLinear } from "react-icons/si"
import { useState } from "react"
import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api"
import type { AgentSource, AgentThread } from "@/lib/agents/types"
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import { Button } from "@/components/ui/button"
import {
SidebarCollapseButton,
SidebarFrame,
@ -104,11 +110,19 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
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"
@ -130,27 +144,40 @@ function ThreadGroup({
threads,
activeThreadId,
onNavigate,
defaultCollapsed = false,
}: {
label: string
threads: Array<AgentThread>
activeThreadId?: string
onNavigate?: () => void
defaultCollapsed?: boolean
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
if (threads.length === 0) return null
const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon
return (
<div className="mb-3">
<div className="px-2 py-1 text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
{label}
</div>
{threads.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
isActive={thread.id === activeThreadId}
onNavigate={onNavigate}
/>
))}
<button
type="button"
onClick={() => setCollapsed((value) => !value)}
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)]"
aria-expanded={!collapsed}
>
<ToggleIcon className="size-3" />
<span className="min-w-0 flex-1 truncate">{label}</span>
<span>{threads.length}</span>
</button>
{!collapsed &&
threads.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
isActive={thread.id === activeThreadId}
onNavigate={onNavigate}
/>
))}
</div>
)
}
@ -165,6 +192,7 @@ function ThreadRow({
onNavigate?: () => void
}) {
const deleteThread = useDeleteAgentThread()
const [deleteOpen, setDeleteOpen] = useState(false)
const badge =
thread.diffStats && thread.diffStats.additions > 0
? `+${thread.diffStats.additions}`
@ -176,9 +204,14 @@ function ThreadRow({
e.preventDefault()
e.stopPropagation()
if (isDeleting) return
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`))
return
deleteThread.mutate(thread.id)
setDeleteOpen(true)
}
const onConfirmDelete = () => {
if (isDeleting) return
deleteThread.mutate(thread.id, {
onSuccess: () => setDeleteOpen(false),
})
}
const source =
@ -186,54 +219,98 @@ function ThreadRow({
? SOURCE_META[thread.source]
: null
const SourceIcon = source?.icon
const showFinishedIndicator = thread.status === "finished" && !thread.viewed
return (
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
onClick={onNavigate}
className={cn(
"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
<>
<Link
to="/agents/$threadId"
params={{ threadId: thread.id }}
onClick={onNavigate}
className={cn(
"size-2 shrink-0 rounded-full",
thread.status === "running"
? "animate-pulse bg-[var(--ui-accent)]"
: thread.status === "finished"
? "bg-[var(--ui-accent)]"
: "bg-[var(--ui-border)]"
"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"
)}
/>
{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>
{thread.status === "running" ? (
<CircleNotchIcon
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`
}
export type ThreadGroup = "today" | "last30" | "older"
export type ThreadGroup = "today" | "last7" | "last30" | "older"
export function groupThreads(
threads: Array<AgentThread>
): Record<ThreadGroup, Array<AgentThread>> {
const todayStart = new Date()
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 groups: Record<ThreadGroup, Array<AgentThread>> = {
today: [],
last7: [],
last30: [],
older: [],
}
@ -153,6 +155,8 @@ export function groupThreads(
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
if (thread.updatedAt >= todayStart.getTime()) {
groups.today.push(thread)
} else if (thread.updatedAt >= sevenDaysAgo) {
groups.last7.push(thread)
} else if (thread.updatedAt >= thirtyDaysAgo) {
groups.last30.push(thread)
} else {

View file

@ -19,6 +19,10 @@ export function useAgentThreads() {
return useQuery({
queryKey: agentThreadKeys.all,
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
source?: AgentSource
status: AgentStatus
viewed: boolean
viewedAt?: number | null
createdAt: number
updatedAt: number
messages: Array<Message>