mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 11:33:14 +00:00
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:
parent
bf8407d39c
commit
ec41f138fa
7 changed files with 468 additions and 132 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
103
tests/test_dashboard_thread_api_activity.py
Normal file
103
tests/test_dashboard_thread_api_activity.py
Normal 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"]
|
||||
|
|
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue