From a2934692c4bdb900dd28c01d4b66e17d8206f778 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:29:13 +0000 Subject: [PATCH 1/4] feat(dashboard): mark newly finished conversations Co-Authored-By: David Cramer --- .../conversations/ConversationHomeList.tsx | 30 ++++- .../conversations/ConversationSidebar.tsx | 26 ++++- .../conversations/ConversationWorkspace.tsx | 9 ++ .../useConversationFinishedIndicators.ts | 104 ++++++++++++++++++ .../conversation-finished-indicators.test.ts | 53 +++++++++ 5 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/useConversationFinishedIndicators.ts create mode 100644 packages/junior-dashboard/tests/conversation-finished-indicators.test.ts diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx index 17c4103aea..943f080bcc 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx @@ -28,6 +28,7 @@ import { export function ConversationHomeList(props: { conversations: Conversation[]; emptyLabel?: string; + finishedConversationIds: ReadonlySet; loading?: boolean; timeZone: string; }) { @@ -85,6 +86,7 @@ export function ConversationHomeList(props: {
{sections.map((section) => ( ; onArchiveError(conversation: Conversation, wasArchiving: boolean): void; onArchived(conversation: Conversation): void; section: ConversationSection; @@ -175,6 +178,9 @@ function ConversationCardSection(props: { {props.section.conversations.map((conversation) => ( ) : status === "active" ? ( ) : ( diff --git a/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx b/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx index 388b7fd490..f4e591a1e1 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx @@ -43,6 +43,7 @@ const conversationEntryKey = (entry: ConversationSidebarEntry) => entry.key; export function ConversationSidebar(props: { conversations: Conversation[]; error?: string; + finishedConversationIds: ReadonlySet; loading: boolean; query: string; selectedId?: string; @@ -173,6 +174,9 @@ export function ConversationSidebar(props: { ) : ( ); @@ -284,8 +294,9 @@ function ConversationListStatusIcon(props: { aria-hidden="true" className={cn( "size-1.5 shrink-0 rounded-full", - props.status === "failed" && "bg-rose-300", - props.status === "idle" && "bg-white/25", + props.finishedSinceSeen && "bg-orange-300", + !props.finishedSinceSeen && props.status === "failed" && "bg-rose-300", + !props.finishedSinceSeen && props.status === "idle" && "bg-white/25", )} /> ); @@ -293,6 +304,7 @@ function ConversationListStatusIcon(props: { const ConversationSidebarRow = memo(function ConversationSidebarRow(props: { conversation: Conversation; + finishedSinceSeen: boolean; onArchiveError(conversation: Conversation, wasArchiving: boolean): void; onArchived(conversation: Conversation): void; selected: boolean; @@ -337,7 +349,11 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: { >
- +
{title} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx b/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx index 15dd3fc6a3..353961bc61 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx @@ -19,6 +19,7 @@ import { buildConversations, getDashboardTimeZone } from "../format"; import type { Conversation } from "../types"; import { cn, dashboardContainerClass } from "../styles"; import { ConversationPage } from "./ConversationPage"; +import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators"; const CONVERSATION_PAGE_SIZE = 20; @@ -59,6 +60,12 @@ export function ConversationWorkspace() { useEffect(() => { if (page > totalPages) setPage(totalPages); }, [page, totalPages]); + const visibleConversations = home ? pagedConversations : conversations; + const finishedConversationIds = useConversationFinishedIndicators( + visibleConversations, + selectedId, + Boolean(feed.data), + ); const createView = ( @@ -127,6 +135,7 @@ export function ConversationWorkspace() { ; + +/** Track visible conversations that completed after the user last saw them active. */ +export function useConversationFinishedIndicators( + conversations: Conversation[], + selectedId: string | undefined, + ready: boolean, +): ReadonlySet { + const [storedStates, setStoredStates] = useState(readStoredStates); + + useEffect(() => { + if (!ready) return; + + setStoredStates((current) => { + const next = reconcileStoredStates(current, conversations, selectedId); + writeStoredStates(next); + return next; + }); + }, [conversations, ready, selectedId]); + + return useMemo( + () => + new Set( + Object.entries(storedStates) + .filter(([, state]) => state.finishedSinceSeen) + .map(([conversationId]) => conversationId), + ), + [storedStates], + ); +} + +/** Update completion markers and remove conversations outside the visible list. */ +export function reconcileStoredStates( + current: StoredConversationStates, + conversations: Conversation[], + selectedId: string | undefined, +): StoredConversationStates { + return Object.fromEntries( + conversations.map((conversation) => { + const previous = current[conversation.id]; + const finishedSinceSeen = + conversation.id !== selectedId && + conversation.status === "completed" && + (previous?.finishedSinceSeen || previous?.status === "active"); + + return [ + conversation.id, + { finishedSinceSeen, status: conversation.status }, + ]; + }), + ); +} + +function readStoredStates(): StoredConversationStates { + if (typeof window === "undefined") return {}; + + try { + const value: unknown = JSON.parse( + window.localStorage.getItem(STORAGE_KEY) ?? "{}", + ); + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, StoredConversationState] => { + const state: unknown = entry[1]; + return ( + state !== null && + typeof state === "object" && + "finishedSinceSeen" in state && + typeof state.finishedSinceSeen === "boolean" && + "status" in state && + (state.status === "active" || + state.status === "completed" || + state.status === "failed") + ); + }, + ), + ); + } catch { + return {}; + } +} + +function writeStoredStates(states: StoredConversationStates): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(states)); + } catch { + // Keep in-memory indicators usable when storage is unavailable. + } +} diff --git a/packages/junior-dashboard/tests/conversation-finished-indicators.test.ts b/packages/junior-dashboard/tests/conversation-finished-indicators.test.ts new file mode 100644 index 0000000000..5518d7d0bf --- /dev/null +++ b/packages/junior-dashboard/tests/conversation-finished-indicators.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { reconcileStoredStates } from "../src/client/conversations/useConversationFinishedIndicators"; +import type { Conversation } from "../src/client/types"; + +function conversation( + id: string, + status: Conversation["status"], +): Conversation { + return { + cumulativeDurationMs: 0, + displayTitle: id, + id, + lastProgressAt: "2026-09-11T00:00:00.000Z", + lastSeenAt: "2026-09-11T00:00:00.000Z", + startedAt: "2026-09-11T00:00:00.000Z", + status, + surface: "api", + }; +} + +describe("conversation finished indicators", () => { + it("marks a visible conversation when it changes from active to completed", () => { + const active = reconcileStoredStates( + {}, + [conversation("a", "active")], + undefined, + ); + const completed = reconcileStoredStates( + active, + [conversation("a", "completed")], + undefined, + ); + + expect(completed.a).toEqual({ + finishedSinceSeen: true, + status: "completed", + }); + }); + + it("clears selected conversations and removes conversations outside the visible list", () => { + const current = { + a: { finishedSinceSeen: true, status: "completed" as const }, + hidden: { finishedSinceSeen: true, status: "completed" as const }, + }; + + expect( + reconcileStoredStates(current, [conversation("a", "completed")], "a"), + ).toEqual({ + a: { finishedSinceSeen: false, status: "completed" }, + }); + }); +}); From b4fbe8f8d05f7c9ceb9fa6ad974564e569ce02ad Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:09:06 +0000 Subject: [PATCH 2/4] refactor(dashboard): track unread conversation updates Co-Authored-By: David Cramer --- .../conversations/ConversationHomeList.tsx | 40 ++--------- .../ConversationListStatusIcon.tsx | 52 ++++++++++++++ .../conversations/ConversationSidebar.tsx | 55 +------------- .../useConversationFinishedIndicators.ts | 71 ++++++++++++------- .../conversation-finished-indicators.test.ts | 41 ++++++----- 5 files changed, 127 insertions(+), 132 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/ConversationListStatusIcon.tsx diff --git a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx index 943f080bcc..a5f909af9b 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationHomeList.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { Archive, ArchiveRestore, LockKeyhole } from "lucide-react"; +import { Archive, ArchiveRestore } from "lucide-react"; import { Link } from "react-router"; import { @@ -9,7 +9,6 @@ import { slackLocationLabel, visualStatusForConversation, } from "../format"; -import { ActiveIndicator } from "../components/ActiveIndicator"; import { EmptyTelemetry } from "../components/EmptyTelemetry"; import { Skeleton } from "../components/Skeleton"; import { cn } from "../styles"; @@ -17,6 +16,7 @@ import type { Conversation } from "../types"; import { ConversationSidebarAnnotations } from "./ConversationMeta"; import { formatConversationActivityPreview } from "./conversationActivityPreview"; import { ConversationArchiveNotices } from "./ConversationArchiveNotices"; +import { ConversationListStatusIcon } from "./ConversationListStatusIcon"; import { conversationPath } from "./conversationRoutes"; import { useArchiveConversation } from "./queries"; import { @@ -223,37 +223,11 @@ function ConversationCard(props: {
- {isPrivate ? ( - - ) : status === "active" ? ( - - ) : ( - - )} +

{title} diff --git a/packages/junior-dashboard/src/client/conversations/ConversationListStatusIcon.tsx b/packages/junior-dashboard/src/client/conversations/ConversationListStatusIcon.tsx new file mode 100644 index 0000000000..58ca64337c --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/ConversationListStatusIcon.tsx @@ -0,0 +1,52 @@ +import { LockKeyhole } from "lucide-react"; + +import { ActiveIndicator } from "../components/ActiveIndicator"; +import { cn } from "../styles"; +import type { VisualStatus } from "../types"; + +/** Render a conversation status marker, with new completions taking priority. */ +export function ConversationListStatusIcon(props: { + finishedSinceSeen: boolean; + isPrivate: boolean; + status: VisualStatus; +}) { + if (props.finishedSinceSeen) { + return ( + + ); + } + + if (props.isPrivate) { + return ( + + ); + } + + if (props.status === "active") { + return ; + } + + return ( +