From ec41f138fa1d6829ee178c01edf385a20cc83672 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Mon, 8 Jun 2026 10:38:31 -0700 Subject: [PATCH] fix: update sidebar thread activity indicators (#1441) * fix: update sidebar thread activity indicators Co-authored-by: open-swe[bot] * fix: improve sidebar thread organization Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] --- agent/dashboard/thread_api.py | 147 ++++++++++++-- tests/test_dashboard_thread_api_activity.py | 103 ++++++++++ ui/src/components/agents/AgentThreadView.tsx | 147 ++++++++------ ui/src/components/agents/AgentsSidebar.tsx | 191 +++++++++++++------ ui/src/lib/agents/api.ts | 6 +- ui/src/lib/agents/queries.ts | 4 + ui/src/lib/agents/types.ts | 2 + 7 files changed, 468 insertions(+), 132 deletions(-) create mode 100644 tests/test_dashboard_thread_api_activity.py diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 06c1a588..ad3e4a73 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -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]: diff --git a/tests/test_dashboard_thread_api_activity.py b/tests/test_dashboard_thread_api_activity.py new file mode 100644 index 00000000..017ec0b7 --- /dev/null +++ b/tests/test_dashboard_thread_api_activity.py @@ -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"] diff --git a/ui/src/components/agents/AgentThreadView.tsx b/ui/src/components/agents/AgentThreadView.tsx index 089af4e1..855a5afe 100644 --- a/ui/src/components/agents/AgentThreadView.tsx +++ b/ui/src/components/agents/AgentThreadView.tsx @@ -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): boolean { +function isPendingPromptConfirmed( + entry: PendingPrompt, + messages: Array +): 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>(() => - 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>( + () => getPendingPrompts(thread.id) + ) - const { models, defaultSelection } = useModelOptions(); + const { models, defaultSelection } = useModelOptions() const threadSelection = useMemo(() => { - 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(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(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 | undefined>( + agentThreadKeys.all, + (threads) => + threads?.map((item) => + item.id === thread.id + ? { ...item, ...thread, messages: item.messages } + : item + ) + ) + }, [queryClient, thread]) const displayMessages = useMemo>(() => { - 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 ( @@ -109,7 +136,7 @@ export function AgentThreadView({ user, thread }: AgentThreadViewProps) { contentWidthClass="max-w-3xl" />
-
+
) : (
-

This thread has no messages yet.

+

+ This thread has no messages yet. +

- ); + ) } diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index 3447a35b..58e9dc35 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -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} /> + activeThreadId?: string onNavigate?: () => void + defaultCollapsed?: boolean }) { + const [collapsed, setCollapsed] = useState(defaultCollapsed) if (threads.length === 0) return null + const ToggleIcon = collapsed ? CaretRightIcon : CaretDownIcon + return (
-
- {label} -
- {threads.map((thread) => ( - - ))} + + {!collapsed && + threads.map((thread) => ( + + ))}
) } @@ -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 ( - - + - {source && SourceIcon && ( - - {source.label} - - )} - {thread.title} - {badge && ( - - {badge} - - )} - - + {thread.status === "running" ? ( + + ) : ( + + )} + {source && SourceIcon && ( + + {source.label} + + )} + {thread.title} + {badge && ( + + {badge} + + )} + + + + + + +
+ + Delete thread + + + Delete "{thread.title}"? This cannot be undone. + +
+ + +
+
+
+
+
+ ) } diff --git a/ui/src/lib/agents/api.ts b/ui/src/lib/agents/api.ts index ae8ecb3b..7e87df39 100644 --- a/ui/src/lib/agents/api.ts +++ b/ui/src/lib/agents/api.ts @@ -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 ): Record> { 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> = { 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 { diff --git a/ui/src/lib/agents/queries.ts b/ui/src/lib/agents/queries.ts index 9b28ff33..6246ddbf 100644 --- a/ui/src/lib/agents/queries.ts +++ b/ui/src/lib/agents/queries.ts @@ -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, }) } diff --git a/ui/src/lib/agents/types.ts b/ui/src/lib/agents/types.ts index 07dba61a..3333bb40 100644 --- a/ui/src/lib/agents/types.ts +++ b/ui/src/lib/agents/types.ts @@ -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