mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-03 20:13:21 +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"
|
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]:
|
||||||
|
|
|
||||||
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 { 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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue