From 37e6c051b39fe62067193bff7b155d6d83a403b7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:58:09 +0000 Subject: [PATCH 1/7] feat(dashboard): Keep each reader's place in a Conversation Store one forward-only read position per user and Conversation, so all their devices share it. Detail returns the viewer's position, and the transcript reports the last row the reader saw. When a Conversation opens, rows that were unread get a "New" marker and the transcript opens there. A browser setting can open at the latest message instead. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../e2e/conversations-mobile.spec.ts | 99 + packages/junior-dashboard/src/app.ts | 2 + .../client/conversations/ConversationPage.tsx | 11 + .../conversations/ConversationTranscript.tsx | 57 +- .../client/conversations/TranscriptRows.tsx | 23 + .../client/conversations/TranscriptView.tsx | 20 + .../conversations/transcriptBottomPinning.ts | 76 +- .../conversations/transcriptReadPosition.ts | 212 + .../conversations/transcriptRenderModel.ts | 18 +- .../src/client/pages/SettingsPage.tsx | 62 + .../src/mock-reporting/read-positions.ts | 39 + .../src/mock-reporting/routes.ts | 27 +- .../tests/transcriptRenderGrouping.test.ts | 1 + .../tests/transcriptRenderModel.test.ts | 3 + .../0071_conversation_read_positions.sql | 10 + .../junior/migrations/meta/0071_snapshot.json | 4058 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../junior/src/api/conversations/AGENTS.md | 2 + .../junior/src/api/conversations/detail.ts | 7 +- .../src/api/conversations/read-position.ts | 77 + .../junior/src/api/conversations/routes.ts | 26 + packages/junior/src/api/schema.ts | 2 + .../api/schema/conversation-read-position.ts | 16 + .../junior/src/api/schema/conversation.ts | 3 + packages/junior/src/db/schema.ts | 3 + .../db/schema/conversation-read-positions.ts | 31 + .../api/conversations/read-position.test.ts | 132 + 27 files changed, 5005 insertions(+), 19 deletions(-) create mode 100644 packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts create mode 100644 packages/junior-dashboard/src/mock-reporting/read-positions.ts create mode 100644 packages/junior/migrations/0071_conversation_read_positions.sql create mode 100644 packages/junior/migrations/meta/0071_snapshot.json create mode 100644 packages/junior/src/api/conversations/read-position.ts create mode 100644 packages/junior/src/api/schema/conversation-read-position.ts create mode 100644 packages/junior/src/db/schema/conversation-read-positions.ts create mode 100644 packages/junior/tests/integration/api/conversations/read-position.test.ts diff --git a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts index b8860d79c8..663152da92 100644 --- a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts @@ -728,3 +728,102 @@ test("keeps the message in view while earlier history and late content load", as }); await expect(reading).toBeInViewport({ ratio: 1 }); }); + +test("opens where the reader left off and reports what they read", async ({ + page, + dashboard, +}) => { + await page.setViewportSize(mobile); + const generatedAt = "2026-08-07T11:00:00.000Z"; + const conversationId = "read-position"; + const conversation = { + conversationId, + cumulativeDurationMs: 1_000, + displayTitle: "Read position", + isParticipant: true, + lastProgressAt: generatedAt, + lastSeenAt: generatedAt, + startedAt: generatedAt, + status: "completed", + surface: "internal", + }; + const reported: number[] = []; + + await page.route("**/api/conversations", async (route) => { + if (new URL(route.request().url()).pathname !== "/api/conversations") { + await route.fallback(); + return; + } + await route.fulfill({ + json: { + conversations: [conversation], + generatedAt, + source: "conversation_index", + }, + }); + }); + await page.route( + `**/api/conversations/${conversationId}/pending-messages`, + async (route) => { + await route.fulfill({ + json: { conversationId, generatedAt, messages: [] }, + }); + }, + ); + await page.route( + `**/api/conversations/${conversationId}/read-position`, + async (route) => { + const { lastReadSeq } = route.request().postDataJSON() as { + lastReadSeq: number; + }; + reported.push(lastReadSeq); + await route.fulfill({ json: { lastReadSeq } }); + }, + ); + await page.route(`**/api/conversations/${conversationId}`, async (route) => { + await route.fulfill({ + json: { + ...conversation, + eventHistory: { status: "available" }, + events: Array.from({ length: 60 }, (_, seq) => ({ + createdAt: new Date( + Date.parse(generatedAt) + seq * 1_000, + ).toISOString(), + data: { + type: "message", + messageId: `message-${seq}`, + role: seq % 2 === 0 ? "user" : "assistant", + text: `Transcript message ${seq} with enough content to occupy a visible row.`, + }, + seq, + })), + generatedAt, + readPosition: { lastReadSeq: 20 }, + }, + }); + }); + + await page.goto(`${dashboard.baseURL}/conversations/${conversationId}`); + const transcript = page.getByLabel("Conversation transcript"); + const marker = transcript.getByRole("separator", { name: "New messages" }); + const firstUnread = transcript.getByText("Transcript message 21 "); + const latest = transcript.getByText("Transcript message 59 "); + await expect(marker).toBeInViewport(); + await expect(firstUnread).toBeInViewport(); + await expect(latest).not.toBeInViewport(); + await screenshot(page, "conversation-unread-marker", { view: "mobile" }); + + // Reading the first screen moves the shared position past the marker. + await expect.poll(() => reported.at(-1) ?? 0).toBeGreaterThan(20); + await page.getByRole("button", { name: "Jump to latest" }).click(); + await expect(latest).toBeInViewport(); + await expect.poll(() => reported.at(-1)).toBe(59); + expect(reported).toEqual([...reported].sort((left, right) => left - right)); + + // Readers who prefer the latest message open there, below the marker. + await page.goto(`${dashboard.baseURL}/settings`); + await page.getByRole("radio", { name: /Latest message/ }).click(); + await page.goto(`${dashboard.baseURL}/conversations/${conversationId}`); + await expect(latest).toBeInViewport(); + await expect(marker).not.toBeInViewport(); +}); diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index f05218dd05..44e3846d8b 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -36,6 +36,7 @@ import { } from "./auth"; import { isAuthPath, type AuthenticatedRoute } from "./authenticated-routes"; import { resetMockConversationArchiveState } from "./mock-reporting/fixtures"; +import { resetMockReadPositions } from "./mock-reporting/read-positions"; import { createMockReportingApi } from "./mock-reporting/routes"; import { DASHBOARD_AVATAR_HEADER_PATH, @@ -71,6 +72,7 @@ const mockDisplayNamesByEmail = new Map(); export function resetMockDashboardState(): void { mockDisplayNamesByEmail.clear(); resetMockConversationArchiveState(); + resetMockReadPositions(); } export interface JuniorDashboardOptions { diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index ea3cfde1eb..dbe07dcf8d 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -90,6 +90,11 @@ import { ChatLayout } from "./ChatLayout"; import { ComposerDock } from "./ComposerDock"; import { ConversationReviewBar } from "../review-desk/ConversationReviewBar"; import { Transcript } from "./TranscriptView"; +import { + readConversationStartPosition, + useMarkConversationRead, + useReadPositionAtOpen, +} from "./transcriptReadPosition"; import { TranscriptLoading } from "./TranscriptLoading"; import type { TranscriptViewMode } from "./transcriptRenderModel"; import { @@ -154,6 +159,8 @@ export function ConversationPage(props: { const conversations = buildConversations(summaries); const detail = useConversationData(conversationId); const archive = useArchiveConversation(conversationId); + const markRead = useMarkConversationRead(conversationId); + const readAtOpen = useReadPositionAtOpen(detail.data); const feedConversation = conversations.find( (item) => item.id === conversationId, ); @@ -438,9 +445,11 @@ export function ConversationPage(props: { hasPreviousPage={detail.hasPreviousPage} historyError={detail.historyError} historyVersion={detail.historyVersion} + lastReadSeq={readAtOpen.lastReadSeq} live={live} loadingPreviousPage={detail.isLoadingPreviousPage} onLoadPreviousPage={detail.loadPreviousPage} + onRead={markRead} onSelectedEventSeqChange={setSelectedEventSeq} pendingMessages={detail.inlineMessages} pinRequestVersion={pinRequestVersion} @@ -451,8 +460,10 @@ export function ConversationPage(props: { search={search} onSearchChange={setSearch} selectedEventSeq={selectedEventSeq} + startAtUnread={readConversationStartPosition() === "unread"} transcript={transcript} turnContextPanel={turnContextPanel} + unread={readAtOpen.unread} view={view} /> diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index 99ed8676da..e32ed08cfc 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -49,8 +49,13 @@ import { type RenderedTranscriptEntry, } from "./transcriptRenderModel"; import { entryMatchesSearch, useTranscriptSearch } from "./transcriptSearch"; -import { TranscriptRow, TranscriptRows } from "./TranscriptRows"; +import { + TranscriptRow, + TranscriptRows, + TranscriptUnreadMarker, +} from "./TranscriptRows"; import { TranscriptTimestampProvider } from "./TranscriptTimestamp"; +import type { TranscriptUnreadRange } from "./transcriptReadPosition"; type TranscriptEntry = ReturnType[number]; type TranscriptContextEntry = Extract; @@ -85,6 +90,8 @@ export const ConversationTranscriptView = memo( /** Sends that history does not have yet. History wins on message id. */ pendingMessages?: readonly ConversationMailboxMessage[]; responding?: boolean; + /** Show the "New" marker before the first row in this range. */ + unread?: TranscriptUnreadRange; }) { // Cards can change while their Message events stay unchanged. const { events, annotations } = props.conversation; @@ -106,6 +113,7 @@ export const ConversationTranscriptView = memo( conversation={props.conversation} messages={messages} responding={props.responding} + unread={props.unread} /> @@ -121,6 +129,7 @@ function SegmentEvents(props: { conversation: ConversationTranscript; messages: TranscriptViewMessage[]; responding?: boolean; + unread?: TranscriptUnreadRange; }) { return ( @@ -134,6 +143,7 @@ function SegmentEvents(props: { onOpenSubagentTranscript={props.onOpenSubagentTranscript} transcript={props.messages} conversation={props.conversation} + unread={props.unread} /> ) : props.conversation.eventHistory.status === "redacted" && props.messages.length > 0 ? ( @@ -205,6 +215,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { }) => void; transcript: TranscriptViewMessage[]; conversation: ConversationTranscript; + unread?: TranscriptUnreadRange; }) { const entries = useMemo( () => groupTranscriptMessages(props.transcript), @@ -214,6 +225,7 @@ const VisibleTranscriptEntries = memo(function VisibleTranscriptEntries(props: { ( ReactNode; renderSubagent: (entry: TranscriptSubagentEntry) => ReactNode; renderTool: (entry: TranscriptToolEntry) => ReactNode; + unread?: TranscriptUnreadRange; }) { const search = useTranscriptSearch(); const activityKeys = useRef(new Map()); const claimedActivityKeys = new Set(); const rows: ReactNode[] = []; + // Search results are not a reading order, so they get no marker. + const unread = search.active ? undefined : props.unread; + let unreadMarkerPending = unread !== undefined; + const isUnread = (seq: number | undefined) => + unread !== undefined && + seq !== undefined && + seq > unread.afterSeq && + seq <= unread.throughSeq; + const pushRow = (unreadRow: boolean, row: ReactNode) => { + if (unreadMarkerPending && unreadRow) { + unreadMarkerPending = false; + rows.push(); + } + rows.push(row); + }; const renderEntry = (entry: TranscriptEntry): ReactNode => { if (entry.kind === "subagent") return props.renderSubagent(entry); @@ -359,8 +387,16 @@ function TranscriptEntryList(props: { keyPrefix: props.keyPrefix, knownKeys: activityKeys.current, }); - rows.push( - + const lastSeq = lastEntrySeq(activityEntries); + // The marker goes before the group when any of it is unread. + pushRow( + activityEntries.some((candidate) => isUnread(candidate.seq)), + + pushRow( + isUnread(entry.seq), + {renderEntry(entry)} , ); @@ -391,6 +428,16 @@ function TranscriptEntryList(props: { return rows; } +function lastEntrySeq( + entries: readonly RenderedTranscriptEntry[], +): number | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const seq = entries[index]!.seq; + if (seq !== undefined) return seq; + } + return undefined; +} + /** Keep one activity group mounted while new or historical events extend either edge. */ function stableActivityGroupKey(args: { claimedKeys: Set; diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx index f3540eec7a..2ca947768c 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptRows.tsx @@ -15,17 +15,40 @@ export function TranscriptRows({ ); } +/** + * Mark where unread rows start. The pinning hook can open the transcript here. + */ +export function TranscriptUnreadMarker() { + return ( +
+ + + New + + +
+ ); +} + /** Leave room for control outlines inside the mobile row's paint bounds. */ export function TranscriptRow(props: { /** Stable row id that holds the reader's place while layout changes. */ anchorKey?: string; children: ReactNode; indent?: boolean; + /** Last event sequence in the row. Read positions use it. */ + seq?: number; }) { return (
{props.indent ? (
{props.children}
diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx index 99cb7ee55e..619b78295c 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx @@ -22,6 +22,10 @@ import { usePinnedTranscriptBottom, } from "./transcriptBottomPinning"; import type { TranscriptViewMode } from "./transcriptRenderModel"; +import { + useTranscriptReadReporter, + type TranscriptUnreadRange, +} from "./transcriptReadPosition"; import { TranscriptSearchProvider } from "./transcriptSearch"; import { TurnContextPanelProvider, @@ -34,9 +38,13 @@ export function Transcript(props: { historyError?: Error | null; historyVersion?: string; live?: boolean; + /** The read position when the Conversation opened. */ + lastReadSeq?: number; loadingPreviousPage?: boolean; pinRequestVersion?: number; onLoadPreviousPage?: () => void; + /** Receive the last event sequence the reader saw in the transcript. */ + onRead?: (seq: number) => void; onSelectedEventSeqChange?(seq: number | undefined): void; /** The reader's own sends to show at the end until history has them. */ pendingMessages?: readonly ConversationMailboxMessage[]; @@ -51,6 +59,10 @@ export function Transcript(props: { transcript?: ConversationTranscript; /** Show turn context in the owner's panel. Without it, a drawer opens. */ turnContextPanel?: TurnContextPanel; + /** Open at the "New" marker instead of the latest message. */ + startAtUnread?: boolean; + /** Rows that were unread when the Conversation opened get a marker. */ + unread?: TranscriptUnreadRange; view?: TranscriptViewMode; }) { const view = props.view ?? "rich"; @@ -64,6 +76,7 @@ export function Transcript(props: { historyVersion: props.historyVersion ?? "empty", juniorMessageVersion: transcriptJuniorMessageVersion(props.transcript), pinRequestVersion: props.pinRequestVersion, + startAtUnread: view === "rich" && props.startAtUnread, view, versions: { rich: transcriptBottomVersion(props.transcript, "rich"), @@ -71,6 +84,12 @@ export function Transcript(props: { }, }); + // Read positions follow the transcript rows, not the event log. + useTranscriptReadReporter({ + content: bottomPinning.contentElement, + lastReadSeq: props.lastReadSeq, + onRead: view === "rich" ? props.onRead : undefined, + }); const loadPreviousPage = () => props.onLoadPreviousPage?.(); const previousPageRef = useAutoLoadPreviousPage({ enabled: Boolean( @@ -144,6 +163,7 @@ export function Transcript(props: { conversation={props.transcript} pendingMessages={props.pendingMessages} responding={props.responding ?? props.live ?? false} + unread={props.unread} /> )} diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 921fa0e4ed..e98611a547 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -22,6 +22,8 @@ import { const BOTTOM_PROXIMITY_PX = 96; const PREVIOUS_PAGE_PROXIMITY_PX = 600; const USER_SCROLL_DELTA_PX = 2; +// Leave a little earlier context above the "New" marker. +const UNREAD_START_OFFSET_PX = 48; type ScrollRoot = HTMLElement | Window; type PositionMeasureSource = "measure" | "scroll"; @@ -36,6 +38,7 @@ export type ScrollSnapshot = { type BottomPinResult = { anchorRef: RefObject; + contentElement: HTMLDivElement | null; contentRef: RefCallback; hasPendingUpdate: boolean; jumpToBottom: () => void; @@ -249,14 +252,19 @@ export function terminalReplyPinState(input: { /** * Show the jump control only when the reader left the bottom and a newer tail - * arrived. Staying pinned, or intentionally following, must not flash it. + * arrived, or when the transcript opened at unread rows. Staying pinned, or + * intentionally following, must not flash it. */ export function shouldShowJumpToLatest(input: { enabled: boolean; following: boolean; hasPendingUpdate: boolean; + openedAtUnread?: boolean; }): boolean { - return input.enabled && !input.following && input.hasPendingUpdate; + if (input.following) return false; + return ( + Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate) + ); } /** Resolve scroll intent with user upward movement taking precedence over bottom slack. */ @@ -304,6 +312,8 @@ export function usePinnedTranscriptBottom(input: { historyVersion: string; juniorMessageVersion: string; pinRequestVersion?: number; + /** Open at the "New" marker when the transcript has one. */ + startAtUnread?: boolean; view: TranscriptViewMode; versions: Record; }): BottomPinResult { @@ -326,6 +336,7 @@ export function usePinnedTranscriptBottom(input: { const programmaticScrollGenerationRef = useRef(0); const [following, setFollowing] = useState(false); const [hasPendingUpdate, setHasPendingUpdate] = useState(false); + const [openedAtUnread, setOpenedAtUnread] = useState(false); const [contentElement, setContentElement] = useState( null, ); @@ -343,6 +354,7 @@ export function usePinnedTranscriptBottom(input: { const setFollowingIntent = useCallback((value: boolean) => { followingRef.current = value; setFollowing(value); + if (value) setOpenedAtUnread(false); }, []); const rememberPlace = useCallback(() => { @@ -486,14 +498,43 @@ export function usePinnedTranscriptBottom(input: { if (!root) return; initializedConversationRef.current = input.conversationId; + setHasPendingUpdate(false); + const marker = input.startAtUnread + ? contentElement.querySelector("[data-transcript-unread-marker]") + : null; + if (marker) { + const offset = + marker.getBoundingClientRect().top - viewportBounds(root).top; + setScrollTop( + root, + Math.max( + 0, + scrollSnapshot(root).scrollTop + offset - UNREAD_START_OFFSET_PX, + ), + ); + const snapshot = scrollSnapshot(root); + previousScrollTopRef.current = snapshot.scrollTop; + // Unread rows that fit above the bottom leave the reader at the latest. + const atBottom = isNearScrollBottom(snapshot); + setFollowingIntent(atBottom); + setOpenedAtUnread(!atBottom); + rememberPlace(); + return; + } + setScrollTop(root, scrollSnapshot(root).scrollHeight); previousScrollTopRef.current = scrollSnapshot(root).scrollTop; - // Start every conversation at its latest message. Keep following until the - // reader scrolls up so late content and footer layout cannot expose stale - // space below the transcript. + // Without unread rows, start at the latest message. Keep following until + // the reader scrolls up so late content and footer layout cannot expose + // stale space below the transcript. setFollowingIntent(true); - setHasPendingUpdate(false); - }, [contentElement, input.conversationId, setFollowingIntent]); + }, [ + contentElement, + input.conversationId, + input.startAtUnread, + rememberPlace, + setFollowingIntent, + ]); const syncAfterLayoutChange = useCallback(() => { if (followingRef.current) { @@ -674,6 +715,7 @@ export function usePinnedTranscriptBottom(input: { return useMemo( () => ({ anchorRef, + contentElement, contentRef, hasPendingUpdate, jumpToBottom, @@ -681,9 +723,18 @@ export function usePinnedTranscriptBottom(input: { enabled: input.enabled, following, hasPendingUpdate, + openedAtUnread, }), }), - [contentRef, following, hasPendingUpdate, input.enabled, jumpToBottom], + [ + contentElement, + contentRef, + following, + hasPendingUpdate, + input.enabled, + jumpToBottom, + openedAtUnread, + ], ); } @@ -720,7 +771,8 @@ export function useAutoLoadPreviousPage(input: { return setSentinel; } -function scrollRootFor(element: HTMLElement | null): ScrollRoot | null { +/** Find the nearest element that scrolls the transcript, or the window. */ +export function scrollRootFor(element: HTMLElement | null): ScrollRoot | null { if (typeof window === "undefined") return null; if (!element) return window; @@ -763,7 +815,11 @@ function setScrollTop( root.scrollTo({ behavior, top: scrollTop }); } -function viewportBounds(root: ScrollRoot): { bottom: number; top: number } { +/** Read the visible band of a scroll root in client coordinates. */ +export function viewportBounds(root: ScrollRoot): { + bottom: number; + top: number; +} { if (isWindowRoot(root)) return { bottom: window.innerHeight, top: 0 }; const top = root.getBoundingClientRect().top; return { bottom: top + root.clientHeight, top }; diff --git a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts new file mode 100644 index 0000000000..64592356df --- /dev/null +++ b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts @@ -0,0 +1,212 @@ +/** + * Track how far the reader has read a Conversation transcript. + * + * Rows from stored events carry `data-transcript-seq`. While the page is + * visible, the reporter sends the sequence of the last row that has reached + * the viewport. The server keeps the highest value, so every client of the + * same user shares one position. Detail polls bring it back to other clients. + * + * The page reads the position once when a Conversation opens. That value + * places the "New" marker and, by the reader's preference, where the + * transcript starts. It does not move during the visit, like Slack. + */ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { useEffect, useRef, useState } from "react"; + +import { + conversationReadPositionSchema, + type ConversationDetailReport, + type ConversationReadPosition, +} from "@sentry/junior/api/schema"; +import { put } from "../http"; +import { conversationDetailQueryKey } from "./queries"; +import { scrollRootFor, viewportBounds } from "./transcriptBottomPinning"; + +const READ_REPORT_DELAY_MS = 750; +const START_POSITION_STORAGE_KEY = "junior:conversation:start-position"; + +/** Where a Conversation opens: at the first unread row or at the latest. */ +export type ConversationStartPosition = "unread" | "latest"; + +/** + * Event sequences that were unread when the Conversation opened. Rows that + * arrive later are not in it, so a reader who follows new replies gets no + * marker for them. + */ +export type TranscriptUnreadRange = { afterSeq: number; throughSeq: number }; + +/** The read state when the Conversation opened. */ +export type ReadPositionAtOpen = { + lastReadSeq?: number; + unread?: TranscriptUnreadRange; +}; + +type VerticalBounds = { bottom: number; top: number }; + +/** Edit the browser-only preference for where Conversations open. */ +export function useConversationStartPosition() { + return useLocalStorage( + START_POSITION_STORAGE_KEY, + "unread", + ); +} + +/** + * Read the start preference without subscribing. A Conversation reads it once + * when it opens, and this works where `useLocalStorage` cannot, such as + * server rendering. + */ +export function readConversationStartPosition(): ConversationStartPosition { + if (typeof window === "undefined") return "unread"; + try { + const stored = window.localStorage.getItem(START_POSITION_STORAGE_KEY); + return stored && JSON.parse(stored) === "latest" ? "latest" : "unread"; + } catch { + return "unread"; + } +} + +/** + * Keep the read state from the first loaded detail. The Conversation page is + * keyed by Conversation, so this is the state when the reader opened it. + */ +export function useReadPositionAtOpen( + detail: + | { + events: readonly { seq: number }[]; + readPosition?: ConversationReadPosition; + } + | undefined, +): ReadPositionAtOpen { + const [opened, setOpened] = useState(); + if (opened) return opened; + if (!detail) return {}; + const next = readPositionAtOpen(detail); + // Store derived state during render so the first transcript render has it. + setOpened(next); + return next; +} + +/** Find the rows that were unread in the first loaded detail. */ +export function readPositionAtOpen(detail: { + events: readonly { seq: number }[]; + readPosition?: ConversationReadPosition; +}): ReadPositionAtOpen { + const lastReadSeq = detail.readPosition?.lastReadSeq; + const throughSeq = detail.events.at(-1)?.seq; + if (lastReadSeq === undefined) return {}; + if (throughSeq === undefined || throughSeq <= lastReadSeq) { + return { lastReadSeq }; + } + return { lastReadSeq, unread: { afterSeq: lastReadSeq, throughSeq } }; +} + +/** + * Send read positions for one Conversation. The cached detail keeps the + * stored value, so the next visit in this session opens at the right place. + */ +export function useMarkConversationRead(conversationId: string) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (lastReadSeq: number) => + put( + conversationReadPositionSchema, + `/api/conversations/${encodeURIComponent(conversationId)}/read-position`, + { lastReadSeq }, + ), + onSuccess: (readPosition) => { + queryClient.setQueryData( + conversationDetailQueryKey(conversationId), + (detail) => (detail ? { ...detail, readPosition } : detail), + ); + }, + }); + return mutation.mutate; +} + +/** + * Find the last row that has reached the viewport, in rows that are in + * document order. + */ +export function lastSeenRowIndex( + count: number, + rowAt: (index: number) => VerticalBounds, + viewport: VerticalBounds, +): number | null { + let low = 0; + let high = count; + while (low < high) { + const middle = (low + high) >> 1; + if (rowAt(middle).top < viewport.bottom) low = middle + 1; + else high = middle; + } + return low === 0 ? null : low - 1; +} + +/** + * Report the last transcript row that the reader saw. + * + * Reports wait a short time after scrolling and layout changes, and only + * move forward. A hidden page does not report. + */ +export function useTranscriptReadReporter(input: { + content: HTMLElement | null; + /** The stored position when the Conversation opened. */ + lastReadSeq?: number; + onRead?: (seq: number) => void; +}): void { + const onReadRef = useRef(input.onRead); + onReadRef.current = input.onRead; + const reportedRef = useRef(input.lastReadSeq ?? -1); + const enabled = Boolean(input.onRead); + + useEffect(() => { + const content = input.content; + const root = scrollRootFor(content); + if (!enabled || !content || !root) return; + + let timer: number | undefined; + const report = () => { + timer = undefined; + if (document.visibilityState !== "visible") return; + const seq = lastSeenTranscriptSeq(content, viewportBounds(root)); + if (seq === null || seq <= reportedRef.current) return; + reportedRef.current = seq; + onReadRef.current?.(seq); + }; + const schedule = () => { + timer ??= window.setTimeout(report, READ_REPORT_DELAY_MS); + }; + + const observer = + typeof ResizeObserver === "undefined" + ? undefined + : new ResizeObserver(schedule); + observer?.observe(content); + root.addEventListener("scroll", schedule, { passive: true }); + document.addEventListener("visibilitychange", schedule); + schedule(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + observer?.disconnect(); + root.removeEventListener("scroll", schedule); + document.removeEventListener("visibilitychange", schedule); + }; + }, [enabled, input.content]); +} + +function lastSeenTranscriptSeq( + content: HTMLElement, + viewport: VerticalBounds, +): number | null { + const rows = content.querySelectorAll("[data-transcript-seq]"); + const index = lastSeenRowIndex( + rows.length, + (row) => rows[row]!.getBoundingClientRect(), + viewport, + ); + if (index === null) return null; + const seq = Number(rows[index]!.dataset.transcriptSeq); + return Number.isSafeInteger(seq) ? seq : null; +} diff --git a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts index a2017e5aae..88612fc611 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptRenderModel.ts @@ -75,7 +75,7 @@ export type RenderedMessageEntry = { }; }; -export type RenderedTranscriptEntry = +export type RenderedTranscriptEntry = ( | RenderedAttachmentsDeliveredEntry | RenderedContextEventEntry | RenderedFailureEntry @@ -83,7 +83,11 @@ export type RenderedTranscriptEntry = | RenderedStructuredEventEntry | RenderedReasoningEntry | RenderedSubagentEntry - | RenderedToolEntry; + | RenderedToolEntry +) & { + /** Event sequence of the source message. Pending sends have none. */ + seq?: number; +}; export type TranscriptViewMode = "raw" | "rich"; @@ -94,6 +98,8 @@ export function groupTranscriptMessages( const entries: RenderedTranscriptEntry[] = []; for (const message of messages) { + const seq = message.pending ? undefined : message.sourceSeq; + const source = seq === undefined ? {} : { seq }; let textParts: TranscriptViewTextPart[] = []; let textGroup = 0; const flushMessage = () => { @@ -108,6 +114,7 @@ export function groupTranscriptMessages( key: `${message.sourceSeq}:message:${textGroup}`, kind: "message", message: nextMessage as RenderedMessageEntry["message"], + ...source, }); textParts = []; textGroup += 1; @@ -124,6 +131,7 @@ export function groupTranscriptMessages( entries.push({ key: `tool:${part.id}`, kind: "tool", + ...source, part, timestamp: part.startedTimestamp ?? message.timestamp, }); @@ -131,6 +139,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:reasoning:${partIndex}`, kind: "reasoning", + ...source, part, timestamp: message.timestamp, }); @@ -138,6 +147,7 @@ export function groupTranscriptMessages( entries.push({ key: `subagent:${part.id}`, kind: "subagent", + ...source, part, timestamp: message.timestamp, }); @@ -145,6 +155,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:structured-event:${part.namespace}:${part.name}`, kind: "structured_event", + ...source, part, timestamp: message.timestamp, }); @@ -152,6 +163,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:attachments-delivered`, kind: "attachments_delivered", + ...source, part, timestamp: message.timestamp, }); @@ -159,6 +171,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:context:${partIndex}`, kind: "context", + ...source, part, timestamp: message.timestamp, }); @@ -170,6 +183,7 @@ export function groupTranscriptMessages( entries.push({ key: `${message.sourceSeq}:failure`, kind: "failure", + ...source, failureCode: message.failureCode, ...(message.failureReason ? { failureReason: message.failureReason } diff --git a/packages/junior-dashboard/src/client/pages/SettingsPage.tsx b/packages/junior-dashboard/src/client/pages/SettingsPage.tsx index 0795d8f90d..39bb13be73 100644 --- a/packages/junior-dashboard/src/client/pages/SettingsPage.tsx +++ b/packages/junior-dashboard/src/client/pages/SettingsPage.tsx @@ -13,6 +13,11 @@ import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Textarea } from "../components/ui/textarea"; import { Field, FieldDescription, FieldLabel } from "../components/ui/field"; +import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group"; +import { + useConversationStartPosition, + type ConversationStartPosition, +} from "../conversations/transcriptReadPosition"; import { InlineError } from "../components/InlineError"; import { Card } from "../components/layout/Card"; import { useDocumentTitle } from "../documentTitle"; @@ -104,12 +109,69 @@ export function SettingsPage({ identity }: SettingsPageProps) {
+ ); } +const startPositionOptions: { + description: string; + label: string; + value: ConversationStartPosition; +}[] = [ + { + description: "Open at the first new message, below a “New” line.", + label: "Where I left off", + value: "unread", + }, + { + description: "Always open at the newest message.", + label: "Latest message", + value: "latest", + }, +]; + +/** Let the user choose where a Conversation opens on this browser. */ +function ConversationStartSetting() { + const [startPosition, setStartPosition] = useConversationStartPosition(); + return ( + +
+ + Open conversations at + +

+ Junior keeps your place in each conversation on all your devices. This + choice applies to this browser. +

+ + aria-label="Open conversations at" + className="grid gap-3" + onValueChange={setStartPosition} + value={startPosition} + > + {startPositionOptions.map((option) => ( + + ))} + +
+
+ ); +} + /** * Let the user save text that changes how the agent replies to them. The * server returns 404 when the app does not enable this experiment. diff --git a/packages/junior-dashboard/src/mock-reporting/read-positions.ts b/packages/junior-dashboard/src/mock-reporting/read-positions.ts new file mode 100644 index 0000000000..1cfd3cf2b9 --- /dev/null +++ b/packages/junior-dashboard/src/mock-reporting/read-positions.ts @@ -0,0 +1,39 @@ +import type { + ConversationDetailReport, + ConversationReadPosition, +} from "@sentry/junior/api/schema"; +import { readMockConversationDetail } from "./fixtures"; + +/** Process-local read positions so mock QA can reopen at unread rows. */ +const mockReadPositions = new Map(); + +/** Clear mock read positions so tests sharing a worker do not leak state. */ +export function resetMockReadPositions(): void { + mockReadPositions.clear(); +} + +/** Move a mock read position forward, like the API. */ +export function markMockConversationRead( + conversationId: string, + lastReadSeq: number, +): ConversationReadPosition | undefined { + const detail = readMockConversationDetail(conversationId); + if (!detail) return undefined; + const lastSeq = detail.events.at(-1)?.seq ?? 0; + const stored = Math.max( + mockReadPositions.get(conversationId) ?? -1, + Math.min(lastReadSeq, lastSeq), + ); + mockReadPositions.set(conversationId, stored); + return { lastReadSeq: stored }; +} + +/** Add the mock read position to a detail report. */ +export function withMockReadPosition( + report: ConversationDetailReport, +): ConversationDetailReport { + const lastReadSeq = mockReadPositions.get(report.conversationId); + return lastReadSeq === undefined + ? report + : { ...report, readPosition: { lastReadSeq } }; +} diff --git a/packages/junior-dashboard/src/mock-reporting/routes.ts b/packages/junior-dashboard/src/mock-reporting/routes.ts index e719ff2e09..29d00f47fc 100644 --- a/packages/junior-dashboard/src/mock-reporting/routes.ts +++ b/packages/junior-dashboard/src/mock-reporting/routes.ts @@ -15,6 +15,7 @@ import { conversationFeedQuerySchema, conversationFeedSchema, conversationParamsSchema, + conversationReadPositionSchema, conversationPendingMessagesReportSchema, conversationStatsReportSchema, conversationWatchesReportSchema, @@ -38,6 +39,10 @@ import { automationRunListSchema, } from "@sentry/junior/api/schema"; import { mockChartPng } from "./chart-png"; +import { + markMockConversationRead, + withMockReadPosition, +} from "./read-positions"; import { filterMockConversationsByRepository, readMockCodeRepository, @@ -244,7 +249,27 @@ export function createMockReportingApi(): Hono<{ const { conversationId } = params.data; const report = readMockConversationDetail(conversationId, query.data.limit); return report - ? jsonResponse(conversationDetailReportSchema, report) + ? jsonResponse( + conversationDetailReportSchema, + withMockReadPosition(report), + ) + : errorResponse("Conversation not found.", 404); + }); + app.put("/conversations/:conversationId/read-position", async (c) => { + const params = conversationParamsSchema.safeParse(c.req.param()); + if (!params.success) { + return errorResponse("Invalid route parameters.", 400); + } + const body = conversationReadPositionSchema.safeParse(await c.req.json()); + if (!body.success) { + return errorResponse("Invalid request body.", 400); + } + const result = markMockConversationRead( + params.data.conversationId, + body.data.lastReadSeq, + ); + return result + ? jsonResponse(conversationReadPositionSchema, result) : errorResponse("Conversation not found.", 404); }); app.patch("/conversations/:conversationId/archive", async (c) => { diff --git a/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts b/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts index f8602e22a8..54b7739f4d 100644 --- a/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderGrouping.test.ts @@ -54,6 +54,7 @@ describe("transcript render grouping", () => { key: "42:failure", kind: "failure", failureCode: "model_execution_failed", + seq: 42, timestamp: 1_000, }, ]); diff --git a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts index 2b2bbaff0d..14bb15b2b3 100644 --- a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -174,6 +174,7 @@ describe("canonical event transcript reduction", () => { key: "1:reasoning:0", kind: "reasoning", part: { type: "reasoning", text: "Inspect the inputs." }, + seq: 1, timestamp: Date.parse("2026-01-01T00:00:01.000Z"), }, { @@ -186,12 +187,14 @@ describe("canonical event transcript reduction", () => { startedTimestamp: Date.parse("2026-01-01T00:00:00.000Z"), status: "running", }, + seq: 1, timestamp: Date.parse("2026-01-01T00:00:00.000Z"), }, { key: "1:reasoning:2", kind: "reasoning", part: { type: "reasoning", text: "Check the result." }, + seq: 1, timestamp: Date.parse("2026-01-01T00:00:01.000Z"), }, ]); diff --git a/packages/junior/migrations/0071_conversation_read_positions.sql b/packages/junior/migrations/0071_conversation_read_positions.sql new file mode 100644 index 0000000000..81e301b081 --- /dev/null +++ b/packages/junior/migrations/0071_conversation_read_positions.sql @@ -0,0 +1,10 @@ +CREATE TABLE "junior_conversation_read_positions" ( + "user_id" text NOT NULL, + "conversation_id" text NOT NULL, + "last_read_seq" integer NOT NULL, + "updated_at" timestamp with time zone NOT NULL, + CONSTRAINT "junior_conversation_read_positions_user_conversation_pk" PRIMARY KEY("user_id","conversation_id") +); +--> statement-breakpoint +ALTER TABLE "junior_conversation_read_positions" ADD CONSTRAINT "junior_conversation_read_positions_user_id_junior_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."junior_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "junior_conversation_read_positions" ADD CONSTRAINT "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0071_snapshot.json b/packages/junior/migrations/meta/0071_snapshot.json new file mode 100644 index 0000000000..898f00cc1a --- /dev/null +++ b/packages/junior/migrations/meta/0071_snapshot.json @@ -0,0 +1,4058 @@ +{ + "id": "d40c3aed-3e1d-4e87-ab8b-7ebbc906331d", + "prevId": "63624186-3205-4985-b9c8-8613366e4b04", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": ["automation_id", "automation_version"], + "columnsTo": ["automation_id", "version"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": ["automation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": ["conversation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_plugin_report_idx": { + "name": "junior_conversation_events_plugin_report_idx", + "columns": [ + { + "expression": "(\"payload\"->>'namespace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'name')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'structured_event'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": ["conversation_id", "run_id", "metric"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": ["user_id", "root_conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_read_positions": { + "name": "junior_conversation_read_positions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_seq": { + "name": "last_read_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_read_positions_user_id_junior_users_id_fk": { + "name": "junior_conversation_read_positions_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_read_positions_user_conversation_pk": { + "name": "junior_conversation_read_positions_user_conversation_pk", + "columns": ["user_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["forked_from_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": ["location_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_assignees": { + "name": "junior_review_item_assignees", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_assignees_user_idx": { + "name": "junior_review_item_assignees_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_assignees_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_assignees_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_assignees_user_id_junior_users_id_fk": { + "name": "junior_review_item_assignees_user_id_junior_users_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_assignees_item_id_user_id_pk": { + "name": "junior_review_item_assignees_item_id_user_id_pk", + "columns": ["item_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_conversations": { + "name": "junior_review_item_conversations", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_conversations_conversation_idx": { + "name": "junior_review_item_conversations_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_conversations_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_conversations_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_conversations_item_id_conversation_id_pk": { + "name": "junior_review_item_conversations_item_id_conversation_id_pk", + "columns": ["item_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_slack_cards": { + "name": "junior_review_item_slack_cards", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_review_item_slack_cards_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_slack_cards_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_slack_cards_item_id_conversation_id_pk": { + "name": "junior_review_item_slack_cards_item_id_conversation_id_pk", + "columns": ["item_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_items": { + "name": "junior_review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assigned_to_everyone": { + "name": "assigned_to_everyone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_title": { + "name": "subject_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_url": { + "name": "subject_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by_conversation_id": { + "name": "added_by_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_items_open_dedupe_uidx": { + "name": "junior_review_items_open_dedupe_uidx", + "columns": [ + { + "expression": "coalesce(\"provider\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"provider_tenant_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_review_items\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_status_idx": { + "name": "junior_review_items_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_subject_idx": { + "name": "junior_review_items_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_automation_idx": { + "name": "junior_review_items_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_conversations", + "columnsFrom": ["added_by_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_items_automation_id_junior_automations_id_fk": { + "name": "junior_review_items_automation_id_junior_automations_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_review_items_closed_by_user_id_junior_users_id_fk": { + "name": "junior_review_items_closed_by_user_id_junior_users_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_users", + "columnsFrom": ["closed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_review_items_kind_check": { + "name": "junior_review_items_kind_check", + "value": "(\"junior_review_items\".\"kind\" = 'notice' and \"junior_review_items\".\"request\" is null and \"junior_review_items\".\"answer\" is null) or (\"junior_review_items\".\"kind\" = 'action' and \"junior_review_items\".\"request\" is not null)" + }, + "junior_review_items_status_check": { + "name": "junior_review_items_status_check", + "value": "(\"junior_review_items\".\"status\" = 'open' and \"junior_review_items\".\"closed_at\" is null) or (\"junior_review_items\".\"status\" in ('done', 'dismissed') and \"junior_review_items\".\"closed_at\" is not null)" + }, + "junior_review_items_tenant_check": { + "name": "junior_review_items_tenant_check", + "value": "(\"junior_review_items\".\"provider\" is null) = (\"junior_review_items\".\"provider_tenant_id\" is null)" + }, + "junior_review_items_visibility_check": { + "name": "junior_review_items_visibility_check", + "value": "\"junior_review_items\".\"visibility\" = 'public' or not \"junior_review_items\".\"assigned_to_everyone\"" + } + }, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customization": { + "name": "customization", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": ["workspace_id", "provider", "repo"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "vcpus": { + "name": "vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 43850f90fc..f3152e9cc0 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -498,6 +498,13 @@ "when": 1793561992655, "tag": "0070_review_item_foreign_keys", "breakpoints": true + }, + { + "idx": 71, + "version": "7", + "when": 1793570432539, + "tag": "0071_conversation_read_positions", + "breakpoints": true } ] } diff --git a/packages/junior/src/api/conversations/AGENTS.md b/packages/junior/src/api/conversations/AGENTS.md index 949ef68d63..f28d0b8b5c 100644 --- a/packages/junior/src/api/conversations/AGENTS.md +++ b/packages/junior/src/api/conversations/AGENTS.md @@ -29,3 +29,5 @@ Web input and Turn state live in `chat/conversations`. The worker lives in Junior user. - Attachments can appear after the event. Clients compare attachment metadata as well as event sequence. +- A read position belongs to one viewer. It only moves forward and stops at + the last event, so devices can report in any order. diff --git a/packages/junior/src/api/conversations/detail.ts b/packages/junior/src/api/conversations/detail.ts index e017cf7185..a218f271dc 100644 --- a/packages/junior/src/api/conversations/detail.ts +++ b/packages/junior/src/api/conversations/detail.ts @@ -33,6 +33,7 @@ import { readConversationArchivedAt } from "./archive"; import { readConversationParticipants } from "./participants"; import { readConversationForks } from "./fork"; import { readConversationAgentTree } from "./agent-tree"; +import { readConversationReadPosition } from "./read-position"; /** Project stored metadata and a bounded event page into a signed history cursor. */ function projectConversationDetail(args: { @@ -237,13 +238,17 @@ export async function readConversationDetail( limit: options.limit ?? 500, }); if (!report) return undefined; - const [forks, agentTree] = await Promise.all([ + const [forks, agentTree, readPosition] = await Promise.all([ readConversationForks(conversationId, options.viewer), readConversationAgentTree(conversationId, options.viewer), + options.viewer + ? readConversationReadPosition(options.viewer, conversationId) + : undefined, ]); return conversationDetailReportSchema.parse({ ...report, ...forks, ...(agentTree ? { agentTree } : undefined), + ...(readPosition ? { readPosition } : undefined), }); } diff --git a/packages/junior/src/api/conversations/read-position.ts b/packages/junior/src/api/conversations/read-position.ts new file mode 100644 index 0000000000..7622ee3dcb --- /dev/null +++ b/packages/junior/src/api/conversations/read-position.ts @@ -0,0 +1,77 @@ +import type { User } from "@sentry/junior-plugin-api"; +import { and, eq, max, sql } from "drizzle-orm"; +import { getDb } from "@/chat/db"; +import { + juniorConversationEvents, + juniorConversationReadPositions, + juniorConversations, +} from "@/db/schema"; +import { throwApiError } from "../http"; +import type { ConversationReadPosition } from "../schema/conversation-read-position"; + +/** Read how far the viewer has read one Conversation, if they have read it. */ +export async function readConversationReadPosition( + viewer: User, + conversationId: string, +): Promise { + const [row] = await getDb() + .select({ lastReadSeq: juniorConversationReadPositions.lastReadSeq }) + .from(juniorConversationReadPositions) + .where( + and( + eq(juniorConversationReadPositions.userId, viewer.id), + eq(juniorConversationReadPositions.conversationId, conversationId), + ), + ) + .limit(1); + return row; +} + +/** + * Move the viewer's read position forward. + * + * The stored value never goes back, so devices can report in any order. A + * value past the last event is cut to the last event, so a bad client cannot + * hide messages that arrive later. + */ +export async function markConversationRead( + viewer: User, + conversationId: string, + body: ConversationReadPosition, +): Promise { + const db = getDb(); + const [conversation] = await db + .select({ lastSeq: max(juniorConversationEvents.seq) }) + .from(juniorConversations) + .leftJoin( + juniorConversationEvents, + eq( + juniorConversationEvents.conversationId, + juniorConversations.conversationId, + ), + ) + .where(eq(juniorConversations.conversationId, conversationId)) + .groupBy(juniorConversations.conversationId); + if (!conversation) throwApiError(404, "Conversation not found."); + + const [row] = await db + .insert(juniorConversationReadPositions) + .values({ + conversationId, + lastReadSeq: Math.min(body.lastReadSeq, conversation.lastSeq ?? 0), + updatedAt: new Date(), + userId: viewer.id, + }) + .onConflictDoUpdate({ + target: [ + juniorConversationReadPositions.userId, + juniorConversationReadPositions.conversationId, + ], + set: { + lastReadSeq: sql`greatest(${juniorConversationReadPositions.lastReadSeq}, excluded.last_read_seq)`, + updatedAt: sql`excluded.updated_at`, + }, + }) + .returning({ lastReadSeq: juniorConversationReadPositions.lastReadSeq }); + return row!; +} diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index 175dfb5544..00b5287be4 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -24,6 +24,7 @@ import { createConversationBodySchema, createConversationMessageBodySchema, } from "../schema/conversation"; +import { conversationReadPositionSchema } from "../schema/conversation-read-position"; import { conversationStatsReportSchema } from "../schema/conversation-stats"; import { stopConversationTurnResponseSchema } from "../schema/conversation-stop"; import { conversationChildAgentsReportSchema } from "../schema/child-agent"; @@ -45,6 +46,7 @@ import { readConversationDetail } from "./detail"; import { forkConversationForViewer } from "./fork"; import { readConversationEvents } from "./event-list"; import { readConversationFeed } from "./list"; +import { markConversationRead } from "./read-position"; import { cancelConversationPendingMessagesForViewer } from "./cancel-pending-messages"; import { steerConversationPendingMessagesForViewer } from "./steer-pending-messages"; import { requireConversationPendingMessages } from "./pending-messages"; @@ -208,6 +210,30 @@ export function createConversationRoutes( }, ); + app.put( + "/:conversationId/read-position", + requireViewer, + validateRequest( + "param", + conversationParamsSchema, + "Invalid route parameters.", + ), + validateRequest( + "json", + conversationReadPositionSchema, + "Invalid request body.", + ), + async (context) => { + const viewer = context.get("viewer"); + const { conversationId } = context.req.valid("param"); + const body = context.req.valid("json"); + return jsonResponse( + conversationReadPositionSchema, + await markConversationRead(viewer, conversationId, body), + ); + }, + ); + app.get( "/:conversationId/events", validateRequest( diff --git a/packages/junior/src/api/schema.ts b/packages/junior/src/api/schema.ts index 2568e4bb19..77ba90d516 100644 --- a/packages/junior/src/api/schema.ts +++ b/packages/junior/src/api/schema.ts @@ -87,6 +87,8 @@ export type { } from "./schema/conversation-stats"; export { stopConversationTurnResponseSchema } from "./schema/conversation-stop"; export type { StopConversationTurnResponse } from "./schema/conversation-stop"; +export { conversationReadPositionSchema } from "./schema/conversation-read-position"; +export type { ConversationReadPosition } from "./schema/conversation-read-position"; export type { AcceptedConversationMessage, ArchiveConversationBody, diff --git a/packages/junior/src/api/schema/conversation-read-position.ts b/packages/junior/src/api/schema/conversation-read-position.ts new file mode 100644 index 0000000000..92abf31292 --- /dev/null +++ b/packages/junior/src/api/schema/conversation-read-position.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +/** + * How far the signed-in viewer has read a Conversation transcript. + * + * `lastReadSeq` is the event sequence of the last transcript row the viewer + * saw. The server keeps the highest value it receives, so it only moves + * forward. + */ +export const conversationReadPositionSchema = z + .object({ lastReadSeq: z.number().int().nonnegative() }) + .strict(); + +export type ConversationReadPosition = z.infer< + typeof conversationReadPositionSchema +>; diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 6b0df23f0c..bd554a29af 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -6,6 +6,7 @@ import { } from "@/chat/attachments/input"; import { messageCardRefSchema } from "@/chat/conversations/cards"; import { z } from "zod"; +import { conversationReadPositionSchema } from "./conversation-read-position"; import { objectTypeSchema } from "@sentry/junior-plugin-api"; import { conversationTurnFailureCodeSchema, @@ -832,6 +833,8 @@ export const conversationDetailReportSchema = conversationSummaryReportSchema agentTree: z.array(conversationAgentTreeNodeSchema).min(2).optional(), /** The viewer can fork this conversation from an assistant reply. */ canFork: z.boolean().optional(), + /** How far the signed-in viewer has read. Absent until they read. */ + readPosition: conversationReadPositionSchema.optional(), forkedFromConversationId: z.string().optional(), /** Title of the fork source, when the viewer can open it. */ forkedFromTitle: z.string().optional(), diff --git a/packages/junior/src/db/schema.ts b/packages/junior/src/db/schema.ts index 045023ea66..5810bcaf3c 100644 --- a/packages/junior/src/db/schema.ts +++ b/packages/junior/src/db/schema.ts @@ -8,6 +8,7 @@ import { juniorConversationMetrics } from "./schema/conversation-metrics"; import { juniorConversationBriefs } from "./schema/conversation-briefs"; import { juniorConversationBindings } from "./schema/conversation-bindings"; import { juniorConversationParticipants } from "./schema/conversation-participants"; +import { juniorConversationReadPositions } from "./schema/conversation-read-positions"; import { juniorConversations } from "./schema/conversations"; import { juniorAgentBindings, @@ -44,6 +45,7 @@ export { juniorConversationBriefs, juniorConversationBindings, juniorConversationParticipants, + juniorConversationReadPositions, juniorConversations, juniorLocationConfigurations, juniorDestinations, @@ -76,6 +78,7 @@ export const juniorSqlSchema = { juniorConversationBriefs, juniorConversationBindings, juniorConversationParticipants, + juniorConversationReadPositions, juniorConversations, juniorLocationConfigurations, juniorDestinations, diff --git a/packages/junior/src/db/schema/conversation-read-positions.ts b/packages/junior/src/db/schema/conversation-read-positions.ts new file mode 100644 index 0000000000..7eaa98c3c6 --- /dev/null +++ b/packages/junior/src/db/schema/conversation-read-positions.ts @@ -0,0 +1,31 @@ +import { integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core"; +import { juniorConversations } from "./conversations"; +import { juniorUsers } from "./users"; +import { timestamptz } from "./timestamps"; + +/** + * How far one signed-in user has read one Conversation transcript. + * + * `last_read_seq` is the event sequence of the last transcript row the user + * saw. It only moves forward, so clients on several devices can write in any + * order. + */ +export const juniorConversationReadPositions = pgTable( + "junior_conversation_read_positions", + { + userId: text("user_id") + .notNull() + .references(() => juniorUsers.id), + conversationId: text("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + lastReadSeq: integer("last_read_seq").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + primaryKey({ + name: "junior_conversation_read_positions_user_conversation_pk", + columns: [table.userId, table.conversationId], + }), + ], +); diff --git a/packages/junior/tests/integration/api/conversations/read-position.test.ts b/packages/junior/tests/integration/api/conversations/read-position.test.ts new file mode 100644 index 0000000000..15a660b177 --- /dev/null +++ b/packages/junior/tests/integration/api/conversations/read-position.test.ts @@ -0,0 +1,132 @@ +import { Hono } from "hono"; +import { afterEach, describe, expect, it } from "vitest"; +import { createJuniorApi } from "@/api"; +import { readConversationDetail } from "@/api/conversations/detail"; +import type { JuniorApiEnv } from "@/api/route"; +import { + conversationDetailReportSchema, + conversationReadPositionSchema, +} from "@/api/schema"; +import { closeDb, getConversationStore, getDb } from "@/chat/db"; +import { resolveViewerUser } from "@/chat/plugins/viewer"; +import { juniorConversationEvents } from "@/db/schema"; + +async function authenticatedApi(email: string) { + const viewer = await resolveViewerUser(email); + if (!viewer) throw new Error(`missing viewer for ${email}`); + const app = new Hono(); + app.use("*", async (context, next) => { + context.set("viewer", viewer); + await next(); + }); + app.route("/", createJuniorApi()); + return { app, viewer }; +} + +async function seedConversation(conversationId: string, eventCount: number) { + await getConversationStore().recordActivity({ + destination: { platform: "local" as const, conversationId }, + conversationId, + nowMs: 1_000, + source: "internal", + title: "Read position", + }); + await getDb() + .insert(juniorConversationEvents) + .values( + Array.from({ length: eventCount }, (_, seq) => ({ + conversationId, + createdAt: new Date(1_000 + seq), + historyVersion: 0, + payload: { + content: `Message ${seq}`, + provenance: { + authority: "instruction", + actor: { name: "event", platform: "system" }, + }, + timestamp: 1_000 + seq, + }, + schemaVersion: 1, + seq, + type: "user_message", + })), + ); +} + +function putReadPosition( + app: Hono, + conversationId: string, + lastReadSeq: number, +) { + return app.request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/read-position`, + { + body: JSON.stringify({ lastReadSeq }), + headers: { "content-type": "application/json" }, + method: "PUT", + }, + ); +} + +describe("conversation read position API", () => { + afterEach(async () => { + await closeDb(); + }); + + it("keeps one forward-only read position per viewer and shows it on detail", async () => { + const conversationId = "local:test:read-position"; + await seedConversation(conversationId, 5); + const { app, viewer } = await authenticatedApi("reader@example.com"); + const { viewer: otherViewer } = await authenticatedApi( + "other-reader@example.com", + ); + + const first = await putReadPosition(app, conversationId, 2); + expect(first.status).toBe(200); + expect(conversationReadPositionSchema.parse(await first.json())).toEqual({ + lastReadSeq: 2, + }); + + // Another device that reports an older place does not move it back. + const older = await putReadPosition(app, conversationId, 1); + expect(conversationReadPositionSchema.parse(await older.json())).toEqual({ + lastReadSeq: 2, + }); + + // A place after the last event stops at the last event. + const ahead = await putReadPosition(app, conversationId, 99); + expect(conversationReadPositionSchema.parse(await ahead.json())).toEqual({ + lastReadSeq: 4, + }); + + expect( + conversationDetailReportSchema.parse( + await readConversationDetail(conversationId, { viewer }), + ).readPosition, + ).toEqual({ lastReadSeq: 4 }); + expect( + conversationDetailReportSchema.parse( + await readConversationDetail(conversationId, { viewer: otherViewer }), + ).readPosition, + ).toBeUndefined(); + }); + + it("requires a viewer and an existing conversation", async () => { + const conversationId = "local:test:read-position-missing"; + await seedConversation(conversationId, 1); + + const unauthenticated = await createJuniorApi().request( + `http://localhost/api/conversations/${encodeURIComponent(conversationId)}/read-position`, + { + body: JSON.stringify({ lastReadSeq: 0 }), + headers: { "content-type": "application/json" }, + method: "PUT", + }, + ); + expect(unauthenticated.status).toBe(401); + + const { app } = await authenticatedApi("reader@example.com"); + const missing = await putReadPosition(app, "local:test:no-such", 0); + expect(missing.status).toBe(404); + }); +}); From bd958a71a7220f62e15a161914fe1b3d78794a4d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:28:55 +0000 Subject: [PATCH 2/7] fix(dashboard): Keep search and stale polls from moving read positions Search results skip rows, so the transcript does not report reads while a search is active. A detail poll that started before a read report finished could put an older read position back in the cache. Keep the higher position, since read positions only move forward. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../client/conversations/TranscriptView.tsx | 6 ++++-- .../src/client/conversations/queries.ts | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx index 619b78295c..c1ed23caf1 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx @@ -84,11 +84,13 @@ export function Transcript(props: { }, }); - // Read positions follow the transcript rows, not the event log. + // Read positions follow the transcript rows, not the event log. Search + // results skip rows, so they do not move the read position. + const reportsRead = view === "rich" && search.trim().length === 0; useTranscriptReadReporter({ content: bottomPinning.contentElement, lastReadSeq: props.lastReadSeq, - onRead: view === "rich" ? props.onRead : undefined, + onRead: reportsRead ? props.onRead : undefined, }); const loadPreviousPage = () => props.onLoadPreviousPage?.(); const previousPageRef = useAutoLoadPreviousPage({ diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index ddf47067b9..96687910ba 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -178,9 +178,12 @@ export function conversationDetailQueryOptions( structuralSharing: (previous, next) => { if (!next || typeof next !== "object") return next; if (!previous || typeof previous !== "object") return next; - return reuseConversationEventReferences( + return keepLatestReadPosition( previous as ConversationSnapshot, - next as ConversationSnapshot, + reuseConversationEventReferences( + previous as ConversationSnapshot, + next as ConversationSnapshot, + ), ); }, refetchInterval: (query) => @@ -196,6 +199,20 @@ export function conversationDetailQueryOptions( }); } +/** + * Read positions only move forward. A poll that started before a read report + * finished must not put an older position back in the cache. + */ +function keepLatestReadPosition( + previous: ConversationSnapshot, + next: ConversationSnapshot, +): ConversationSnapshot { + const kept = previous.readPosition; + if (!kept) return next; + if ((next.readPosition?.lastReadSeq ?? -1) >= kept.lastReadSeq) return next; + return { ...next, readPosition: kept }; +} + function outboxActorIdentity( queryClient: QueryClient, ): ActorIdentity | undefined { From b0c2dd8faec0f1f1a9fe6b71d4f2b06bbdf30bc5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:26:58 +0000 Subject: [PATCH 3/7] fix(dashboard): Open at new messages after a cached reopen A reopen showed the cached detail and froze the unread range from it, so messages that arrived while the reader was away got no New marker. Fetch the detail on each visit, take the last unread row from that fetch, and move to the marker when it appears before the reader scrolls. Leaving the page right after a scroll also dropped the pending read report. Send the measured row when the page unmounts or hides. --- .../e2e/conversations-mobile.spec.ts | 93 ++++++++++++++++--- .../client/conversations/ConversationPage.tsx | 2 +- .../client/conversations/TranscriptView.tsx | 4 +- .../src/client/conversations/queries.ts | 14 ++- .../conversations/transcriptBottomPinning.ts | 72 ++++++++------ .../conversations/transcriptReadPosition.ts | 79 +++++++++++----- 6 files changed, 195 insertions(+), 69 deletions(-) diff --git a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts index 663152da92..6697d485ea 100644 --- a/packages/junior-dashboard/e2e/conversations-mobile.spec.ts +++ b/packages/junior-dashboard/e2e/conversations-mobile.spec.ts @@ -1,3 +1,5 @@ +import type { Page } from "@playwright/test"; + import { expect, test } from "./test"; import { mobile, screenshot } from "./screenshot"; @@ -729,11 +731,11 @@ test("keeps the message in view while earlier history and late content load", as await expect(reading).toBeInViewport({ ratio: 1 }); }); -test("opens where the reader left off and reports what they read", async ({ - page, - dashboard, -}) => { - await page.setViewportSize(mobile); +/** Mock one completed Conversation whose server read position moves forward. */ +async function mockReadPositionConversation( + page: Page, + input: { eventCount: number; lastReadSeq: number }, +) { const generatedAt = "2026-08-07T11:00:00.000Z"; const conversationId = "read-position"; const conversation = { @@ -747,7 +749,14 @@ test("opens where the reader left off and reports what they read", async ({ status: "completed", surface: "internal", }; - const reported: number[] = []; + const state = { + conversationId, + eventCount: input.eventCount, + /** Holds detail responses until it resolves. */ + detailGate: Promise.resolve(), + reported: [] as number[], + }; + let stored = input.lastReadSeq; await page.route("**/api/conversations", async (route) => { if (new URL(route.request().url()).pathname !== "/api/conversations") { @@ -776,16 +785,18 @@ test("opens where the reader left off and reports what they read", async ({ const { lastReadSeq } = route.request().postDataJSON() as { lastReadSeq: number; }; - reported.push(lastReadSeq); - await route.fulfill({ json: { lastReadSeq } }); + state.reported.push(lastReadSeq); + stored = Math.max(stored, lastReadSeq); + await route.fulfill({ json: { lastReadSeq: stored } }); }, ); await page.route(`**/api/conversations/${conversationId}`, async (route) => { + await state.detailGate; await route.fulfill({ json: { ...conversation, eventHistory: { status: "available" }, - events: Array.from({ length: 60 }, (_, seq) => ({ + events: Array.from({ length: state.eventCount }, (_, seq) => ({ createdAt: new Date( Date.parse(generatedAt) + seq * 1_000, ).toISOString(), @@ -798,12 +809,25 @@ test("opens where the reader left off and reports what they read", async ({ seq, })), generatedAt, - readPosition: { lastReadSeq: 20 }, + readPosition: { lastReadSeq: stored }, }, }); }); + return state; +} - await page.goto(`${dashboard.baseURL}/conversations/${conversationId}`); +test("opens where the reader left off and reports what they read", async ({ + page, + dashboard, +}) => { + await page.setViewportSize(mobile); + const mock = await mockReadPositionConversation(page, { + eventCount: 60, + lastReadSeq: 20, + }); + const { reported } = mock; + + await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`); const transcript = page.getByLabel("Conversation transcript"); const marker = transcript.getByRole("separator", { name: "New messages" }); const firstUnread = transcript.getByText("Transcript message 21 "); @@ -823,7 +847,52 @@ test("opens where the reader left off and reports what they read", async ({ // Readers who prefer the latest message open there, below the marker. await page.goto(`${dashboard.baseURL}/settings`); await page.getByRole("radio", { name: /Latest message/ }).click(); - await page.goto(`${dashboard.baseURL}/conversations/${conversationId}`); + await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`); await expect(latest).toBeInViewport(); await expect(marker).not.toBeInViewport(); }); + +test("opens at new messages after a cached reopen and keeps a quick exit", async ({ + page, + dashboard, +}) => { + await page.setViewportSize(mobile); + const mock = await mockReadPositionConversation(page, { + eventCount: 40, + lastReadSeq: 39, + }); + await page.goto(`${dashboard.baseURL}/conversations/${mock.conversationId}`); + const transcript = page.getByLabel("Conversation transcript"); + const marker = transcript.getByRole("separator", { name: "New messages" }); + await expect(transcript.getByText("Transcript message 39 ")).toBeInViewport(); + await expect(marker).toHaveCount(0); + + // New messages arrive while the reader is away. The reopen shows the cached + // copy first, then the fetch brings the new messages and the marker. + await page.getByRole("link", { name: "Back to conversations" }).click(); + mock.eventCount = 60; + let releaseDetail = () => {}; + mock.detailGate = new Promise((resolve) => { + releaseDetail = resolve; + }); + await page + .getByRole("link", { name: /Read position/ }) + .first() + .click(); + await expect(transcript.getByText("Transcript message 39 ")).toBeVisible(); + await expect(marker).toHaveCount(0); + releaseDetail(); + await expect(marker).toBeInViewport(); + await expect(transcript.getByText("Transcript message 40 ")).toBeInViewport(); + await expect( + transcript.getByText("Transcript message 59 "), + ).not.toBeInViewport(); + + // Leaving right after a scroll still saves the place. + const reportCount = mock.reported.length; + await transcript.getByText("Transcript message 52 ").scrollIntoViewIfNeeded(); + await page.getByRole("link", { name: "Back to conversations" }).click(); + await expect + .poll(() => mock.reported.slice(reportCount).at(-1) ?? 0) + .toBeGreaterThanOrEqual(52); +}); diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index dbe07dcf8d..fb575a21fc 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -160,7 +160,7 @@ export function ConversationPage(props: { const detail = useConversationData(conversationId); const archive = useArchiveConversation(conversationId); const markRead = useMarkConversationRead(conversationId); - const readAtOpen = useReadPositionAtOpen(detail.data); + const readAtOpen = useReadPositionAtOpen(detail.data, detail.isFresh); const feedConversation = conversations.find( (item) => item.id === conversationId, ); diff --git a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx index c1ed23caf1..b08cbfa5d3 100644 --- a/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx +++ b/packages/junior-dashboard/src/client/conversations/TranscriptView.tsx @@ -86,11 +86,11 @@ export function Transcript(props: { // Read positions follow the transcript rows, not the event log. Search // results skip rows, so they do not move the read position. - const reportsRead = view === "rich" && search.trim().length === 0; useTranscriptReadReporter({ content: bottomPinning.contentElement, lastReadSeq: props.lastReadSeq, - onRead: reportsRead ? props.onRead : undefined, + onRead: props.onRead, + paused: view !== "rich" || search.trim().length > 0, }); const loadPreviousPage = () => props.onLoadPreviousPage?.(); const previousPageRef = useAutoLoadPreviousPage({ diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index 96687910ba..56f9981ac5 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -721,14 +721,17 @@ export function useConversationData(conversationId: string | undefined) { 0, ...awaitingRows.map((message) => Date.parse(message.createdAt)), ); - const detail = useQuery( - conversationDetailQueryOptions(conversationId, { + const detail = useQuery({ + ...conversationDetailQueryOptions(conversationId, { awaitingMessage: awaitingRows.length > 0, fastPollUntilMs: latestSendAtMs ? latestSendAtMs + FAST_POLL_WINDOW_MS : undefined, }), - ); + // A reopened Conversation shows the cached copy at once, but it can miss + // messages that arrived while the reader was away. Fetch it each visit. + refetchOnMount: "always", + }); // Defer the whole server snapshot, not history alone. Queue removal and the // matching transcript must paint together while composer input stays urgent. const deferredDetail = useDeferredValue(detail.data); @@ -910,6 +913,11 @@ export function useConversationData(conversationId: string | undefined) { ? history.hasNextPage : Boolean(detailData?.previousCursor), isPending: detail.isPending, + /** The shown detail was fetched in this visit, not only read from cache. */ + isFresh: + detail.isFetchedAfterMount && + !detail.isFetching && + detailData === detail.data, isLoadingPreviousPage, pendingAuthorization: mailbox?.authorization, pendingGeneratedAt: mailbox?.generatedAt, diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index e98611a547..001654b971 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -351,10 +351,14 @@ export function usePinnedTranscriptBottom(input: { if (!input.enabled) setHasPendingUpdate(false); }, [input.enabled]); + // The transcript opened at the latest message, but the reader prefers the + // "New" marker. The marker can appear when a fetch replaces a cached detail. + const awaitingUnreadMarkerRef = useRef(false); const setFollowingIntent = useCallback((value: boolean) => { followingRef.current = value; setFollowing(value); if (value) setOpenedAtUnread(false); + else awaitingUnreadMarkerRef.current = false; }, []); const rememberPlace = useCallback(() => { @@ -490,6 +494,32 @@ export function usePinnedTranscriptBottom(input: { [setFollowingIntent], ); + /** Scroll to the "New" marker, if the transcript has one. */ + const openAtUnreadMarker = useCallback((): boolean => { + const content = contentElementRef.current; + const root = scrollRootFor(content); + const marker = content?.querySelector("[data-transcript-unread-marker]"); + if (!root || !marker) return false; + awaitingUnreadMarkerRef.current = false; + const offset = + marker.getBoundingClientRect().top - viewportBounds(root).top; + setScrollTop( + root, + Math.max( + 0, + scrollSnapshot(root).scrollTop + offset - UNREAD_START_OFFSET_PX, + ), + ); + const snapshot = scrollSnapshot(root); + previousScrollTopRef.current = snapshot.scrollTop; + // Unread rows that fit above the bottom leave the reader at the latest. + const atBottom = isNearScrollBottom(snapshot); + setFollowingIntent(atBottom); + setOpenedAtUnread(!atBottom); + rememberPlace(); + return true; + }, [rememberPlace, setFollowingIntent]); + useBrowserLayoutEffect(() => { if (!contentElement || !input.conversationId) return; if (initializedConversationRef.current === input.conversationId) return; @@ -499,28 +529,7 @@ export function usePinnedTranscriptBottom(input: { initializedConversationRef.current = input.conversationId; setHasPendingUpdate(false); - const marker = input.startAtUnread - ? contentElement.querySelector("[data-transcript-unread-marker]") - : null; - if (marker) { - const offset = - marker.getBoundingClientRect().top - viewportBounds(root).top; - setScrollTop( - root, - Math.max( - 0, - scrollSnapshot(root).scrollTop + offset - UNREAD_START_OFFSET_PX, - ), - ); - const snapshot = scrollSnapshot(root); - previousScrollTopRef.current = snapshot.scrollTop; - // Unread rows that fit above the bottom leave the reader at the latest. - const atBottom = isNearScrollBottom(snapshot); - setFollowingIntent(atBottom); - setOpenedAtUnread(!atBottom); - rememberPlace(); - return; - } + if (input.startAtUnread && openAtUnreadMarker()) return; setScrollTop(root, scrollSnapshot(root).scrollHeight); previousScrollTopRef.current = scrollSnapshot(root).scrollTop; @@ -528,23 +537,31 @@ export function usePinnedTranscriptBottom(input: { // the reader scrolls up so late content and footer layout cannot expose // stale space below the transcript. setFollowingIntent(true); + awaitingUnreadMarkerRef.current = Boolean(input.startAtUnread); }, [ contentElement, input.conversationId, input.startAtUnread, - rememberPlace, + openAtUnreadMarker, setFollowingIntent, ]); + // Follow the latest message, unless the "New" marker appeared before the + // reader moved. Then open at the marker, as if it was there at the start. + const followLatest = useCallback(() => { + if (awaitingUnreadMarkerRef.current && openAtUnreadMarker()) return; + scrollToBottom("auto"); + }, [openAtUnreadMarker, scrollToBottom]); + const syncAfterLayoutChange = useCallback(() => { if (followingRef.current) { - scrollToBottom("auto"); + followLatest(); return; } restorePlace(); measurePosition("measure"); - }, [measurePosition, restorePlace, scrollToBottom]); + }, [followLatest, measurePosition, restorePlace]); // An earlier page renders above the reader. Hold their place before paint; // a reader at the bottom stays at the latest message. @@ -585,7 +602,7 @@ export function usePinnedTranscriptBottom(input: { following: followingRef.current, }) ) { - scrollToBottom("auto"); + followLatest(); setHasPendingUpdate(false); return; } @@ -598,7 +615,7 @@ export function usePinnedTranscriptBottom(input: { input.view, input.versions.rich, input.versions.raw, - scrollToBottom, + followLatest, setFollowingIntent, ]); @@ -672,6 +689,7 @@ export function usePinnedTranscriptBottom(input: { }, [contentElement, syncAfterLayoutChange]); const jumpToBottom = useCallback(() => { + awaitingUnreadMarkerRef.current = false; setFollowingIntent(true); setHasPendingUpdate(false); scrollToBottom(preferredExplicitScrollBehavior()); diff --git a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts index 64592356df..6cbaa8497c 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts @@ -68,8 +68,12 @@ export function readConversationStartPosition(): ConversationStartPosition { } /** - * Keep the read state from the first loaded detail. The Conversation page is - * keyed by Conversation, so this is the state when the reader opened it. + * Keep the read state from when the reader opened the Conversation. The page + * is keyed by Conversation, so this runs once for each visit. + * + * The read position comes from the first detail, which can be a cached copy. + * The last unread row waits for the first detail fetched in this visit, so a + * stale cache cannot hide messages that arrived while the reader was away. */ export function useReadPositionAtOpen( detail: @@ -78,23 +82,31 @@ export function useReadPositionAtOpen( readPosition?: ConversationReadPosition; } | undefined, + /** The detail was fetched in this visit, not only read from the cache. */ + fresh: boolean, ): ReadPositionAtOpen { - const [opened, setOpened] = useState(); - if (opened) return opened; - if (!detail) return {}; - const next = readPositionAtOpen(detail); // Store derived state during render so the first transcript render has it. - setOpened(next); - return next; + const [opened, setOpened] = useState<{ lastReadSeq?: number }>(); + const [freshEnd, setFreshEnd] = useState<{ throughSeq?: number }>(); + const read = + opened ?? + (detail ? { lastReadSeq: detail.readPosition?.lastReadSeq } : undefined); + if (!opened && read) setOpened(read); + const end = + freshEnd ?? + (detail && fresh ? { throughSeq: detail.events.at(-1)?.seq } : undefined); + if (!freshEnd && end) setFreshEnd(end); + return readPositionAtOpen( + read?.lastReadSeq, + end ? end.throughSeq : detail?.events.at(-1)?.seq, + ); } -/** Find the rows that were unread in the first loaded detail. */ -export function readPositionAtOpen(detail: { - events: readonly { seq: number }[]; - readPosition?: ConversationReadPosition; -}): ReadPositionAtOpen { - const lastReadSeq = detail.readPosition?.lastReadSeq; - const throughSeq = detail.events.at(-1)?.seq; +/** Find the rows after the read position, through the last row at open. */ +function readPositionAtOpen( + lastReadSeq: number | undefined, + throughSeq: number | undefined, +): ReadPositionAtOpen { if (lastReadSeq === undefined) return {}; if (throughSeq === undefined || throughSeq <= lastReadSeq) { return { lastReadSeq }; @@ -147,17 +159,27 @@ export function lastSeenRowIndex( /** * Report the last transcript row that the reader saw. * - * Reports wait a short time after scrolling and layout changes, and only - * move forward. A hidden page does not report. + * Each scroll or layout change measures the row. Reports wait a short time + * and only move forward. A hidden or paused page does not measure. Leaving + * the page or hiding it sends the measured row at once, so a quick exit keeps + * progress. */ export function useTranscriptReadReporter(input: { content: HTMLElement | null; /** The stored position when the Conversation opened. */ lastReadSeq?: number; onRead?: (seq: number) => void; + /** + * Stop measuring while the rows are not in reading order, such as search + * results. This is read when a measure runs, so the render that changes + * the rows also stops their measure. + */ + paused?: boolean; }): void { const onReadRef = useRef(input.onRead); onReadRef.current = input.onRead; + const pausedRef = useRef(input.paused); + pausedRef.current = input.paused; const reportedRef = useRef(input.lastReadSeq ?? -1); const enabled = Boolean(input.onRead); @@ -167,16 +189,25 @@ export function useTranscriptReadReporter(input: { if (!enabled || !content || !root) return; let timer: number | undefined; - const report = () => { + let seenSeq: number | null = null; + const send = () => { + if (timer !== undefined) window.clearTimeout(timer); timer = undefined; - if (document.visibilityState !== "visible") return; - const seq = lastSeenTranscriptSeq(content, viewportBounds(root)); + const seq = seenSeq; + seenSeq = null; if (seq === null || seq <= reportedRef.current) return; reportedRef.current = seq; onReadRef.current?.(seq); }; const schedule = () => { - timer ??= window.setTimeout(report, READ_REPORT_DELAY_MS); + if (pausedRef.current || document.visibilityState !== "visible") return; + const seq = lastSeenTranscriptSeq(content, viewportBounds(root)); + if (seq !== null) seenSeq = seq; + timer ??= window.setTimeout(send, READ_REPORT_DELAY_MS); + }; + const onVisibilityChange = () => { + if (document.visibilityState === "visible") schedule(); + else send(); }; const observer = @@ -185,13 +216,13 @@ export function useTranscriptReadReporter(input: { : new ResizeObserver(schedule); observer?.observe(content); root.addEventListener("scroll", schedule, { passive: true }); - document.addEventListener("visibilitychange", schedule); + document.addEventListener("visibilitychange", onVisibilityChange); schedule(); return () => { - if (timer !== undefined) window.clearTimeout(timer); + send(); observer?.disconnect(); root.removeEventListener("scroll", schedule); - document.removeEventListener("visibilitychange", schedule); + document.removeEventListener("visibilitychange", onVisibilityChange); }; }, [enabled, input.content]); } From 6ddeb8cd5879c53a8a05ffdc888d301d56fac4b9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:47:39 +0000 Subject: [PATCH 4/7] fix(dashboard): Freeze the unread range at the first fetch The unread range waited for the deferred render to catch up with the fetched detail. A poll could land first and add replies that arrived after open, so a reader at the latest message jumped to a late New marker. Take the last unread row from the first fetched detail, not the deferred copy. --- .../client/conversations/ConversationPage.tsx | 2 +- .../src/client/conversations/queries.ts | 10 +++---- .../conversations/transcriptReadPosition.ts | 29 ++++++++++--------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx index fb575a21fc..14153ab40d 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationPage.tsx @@ -160,7 +160,7 @@ export function ConversationPage(props: { const detail = useConversationData(conversationId); const archive = useArchiveConversation(conversationId); const markRead = useMarkConversationRead(conversationId); - const readAtOpen = useReadPositionAtOpen(detail.data, detail.isFresh); + const readAtOpen = useReadPositionAtOpen(detail.data, detail.fetchedDetail); const feedConversation = conversations.find( (item) => item.id === conversationId, ); diff --git a/packages/junior-dashboard/src/client/conversations/queries.ts b/packages/junior-dashboard/src/client/conversations/queries.ts index 56f9981ac5..6cd2409cb9 100644 --- a/packages/junior-dashboard/src/client/conversations/queries.ts +++ b/packages/junior-dashboard/src/client/conversations/queries.ts @@ -913,11 +913,11 @@ export function useConversationData(conversationId: string | undefined) { ? history.hasNextPage : Boolean(detailData?.previousCursor), isPending: detail.isPending, - /** The shown detail was fetched in this visit, not only read from cache. */ - isFresh: - detail.isFetchedAfterMount && - !detail.isFetching && - detailData === detail.data, + /** The latest detail after a fetch in this visit, before it is deferred. */ + fetchedDetail: + detail.isFetchedAfterMount && !detail.isFetching + ? detail.data + : undefined, isLoadingPreviousPage, pendingAuthorization: mailbox?.authorization, pendingGeneratedAt: mailbox?.generatedAt, diff --git a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts index 6cbaa8497c..1ef7a3ec0f 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts @@ -72,36 +72,37 @@ export function readConversationStartPosition(): ConversationStartPosition { * is keyed by Conversation, so this runs once for each visit. * * The read position comes from the first detail, which can be a cached copy. - * The last unread row waits for the first detail fetched in this visit, so a - * stale cache cannot hide messages that arrived while the reader was away. + * The last unread row comes from the first detail fetched in this visit, so a + * stale cache cannot hide messages that arrived while the reader was away, + * and later polls cannot add rows that arrived after open. */ export function useReadPositionAtOpen( - detail: - | { - events: readonly { seq: number }[]; - readPosition?: ConversationReadPosition; - } - | undefined, - /** The detail was fetched in this visit, not only read from the cache. */ - fresh: boolean, + detail: DetailAtOpen | undefined, + /** The first detail fetched in this visit, not only read from the cache. */ + fetched: DetailAtOpen | undefined, ): ReadPositionAtOpen { // Store derived state during render so the first transcript render has it. const [opened, setOpened] = useState<{ lastReadSeq?: number }>(); - const [freshEnd, setFreshEnd] = useState<{ throughSeq?: number }>(); + const [fetchedEnd, setFetchedEnd] = useState<{ throughSeq?: number }>(); const read = opened ?? (detail ? { lastReadSeq: detail.readPosition?.lastReadSeq } : undefined); if (!opened && read) setOpened(read); const end = - freshEnd ?? - (detail && fresh ? { throughSeq: detail.events.at(-1)?.seq } : undefined); - if (!freshEnd && end) setFreshEnd(end); + fetchedEnd ?? + (fetched ? { throughSeq: fetched.events.at(-1)?.seq } : undefined); + if (!fetchedEnd && end) setFetchedEnd(end); return readPositionAtOpen( read?.lastReadSeq, end ? end.throughSeq : detail?.events.at(-1)?.seq, ); } +type DetailAtOpen = { + events: readonly { seq: number }[]; + readPosition?: ConversationReadPosition; +}; + /** Find the rows after the read position, through the last row at open. */ function readPositionAtOpen( lastReadSeq: number | undefined, From 5f6a0bc6639d4089266f86e98297e4d8290954dd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:50:32 +0000 Subject: [PATCH 5/7] ref(dashboard): Find the New marker seq once Transcript entries are in seq order. Find the first seq after the read position once, then put the marker before the first row that reaches it, instead of checking each row and each activity group entry against the whole unread range. --- .../conversations/ConversationTranscript.tsx | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index e32ed08cfc..aca7587479 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -333,15 +333,14 @@ function TranscriptEntryList(props: { const claimedActivityKeys = new Set(); const rows: ReactNode[] = []; // Search results are not a reading order, so they get no marker. - const unread = search.active ? undefined : props.unread; - let unreadMarkerPending = unread !== undefined; - const isUnread = (seq: number | undefined) => - unread !== undefined && - seq !== undefined && - seq > unread.afterSeq && - seq <= unread.throughSeq; - const pushRow = (unreadRow: boolean, row: ReactNode) => { - if (unreadMarkerPending && unreadRow) { + const markerSeq = search.active + ? undefined + : unreadMarkerSeq(props.entries, props.unread); + let unreadMarkerPending = markerSeq !== undefined; + // Entries are in seq order, so the first row that reaches the marker seq + // gets the marker. + const pushRow = (seq: number | undefined, row: ReactNode) => { + if (unreadMarkerPending && seq !== undefined && seq >= markerSeq!) { unreadMarkerPending = false; rows.push(); } @@ -390,7 +389,7 @@ function TranscriptEntryList(props: { const lastSeq = lastEntrySeq(activityEntries); // The marker goes before the group when any of it is unread. pushRow( - activityEntries.some((candidate) => isUnread(candidate.seq)), + lastSeq, {renderEntry(entry)} , @@ -428,6 +427,22 @@ function TranscriptEntryList(props: { return rows; } +/** + * Find the seq of the first unread row. Entries are in seq order, so this is + * the first seq after the read position. Rows after the range arrived after + * open, so they get no marker. + */ +function unreadMarkerSeq( + entries: readonly TranscriptEntry[], + unread: TranscriptUnreadRange | undefined, +): number | undefined { + if (!unread) return undefined; + const seq = entries.find( + (entry) => entry.seq !== undefined && entry.seq > unread.afterSeq, + )?.seq; + return seq !== undefined && seq <= unread.throughSeq ? seq : undefined; +} + function lastEntrySeq( entries: readonly RenderedTranscriptEntry[], ): number | undefined { From da0d25b13949dbf3569e991890cfdd265f92286c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:41:48 +0000 Subject: [PATCH 6/7] ref(dashboard): Track read rows with an IntersectionObserver The read reporter measured rows on each scroll. An IntersectionObserver now keeps the set of rows in the viewport, and a MutationObserver adds rows as they mount. Reports send the highest seq in that set. A report at or before the cached server position is not sent. The New marker seq uses a binary search over the sorted transcript entries. --- .../conversations/ConversationTranscript.tsx | 19 ++- .../conversations/transcriptBottomPinning.ts | 9 +- .../conversations/transcriptReadPosition.ts | 130 ++++++++++-------- 3 files changed, 86 insertions(+), 72 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx index aca7587479..f13f974cfa 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationTranscript.tsx @@ -428,18 +428,25 @@ function TranscriptEntryList(props: { } /** - * Find the seq of the first unread row. Entries are in seq order, so this is - * the first seq after the read position. Rows after the range arrived after - * open, so they get no marker. + * Find the seq of the first unread row. Entries are in seq order, and pending + * sends without a seq come last, so a binary search finds the first seq after + * the read position. Rows after the range arrived after open, so they get no + * marker. */ function unreadMarkerSeq( entries: readonly TranscriptEntry[], unread: TranscriptUnreadRange | undefined, ): number | undefined { if (!unread) return undefined; - const seq = entries.find( - (entry) => entry.seq !== undefined && entry.seq > unread.afterSeq, - )?.seq; + let low = 0; + let high = entries.length; + while (low < high) { + const middle = (low + high) >> 1; + const seq = entries[middle]!.seq; + if (seq !== undefined && seq <= unread.afterSeq) low = middle + 1; + else high = middle; + } + const seq = entries[low]?.seq; return seq !== undefined && seq <= unread.throughSeq ? seq : undefined; } diff --git a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts index 001654b971..3704400420 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptBottomPinning.ts @@ -261,9 +261,9 @@ export function shouldShowJumpToLatest(input: { hasPendingUpdate: boolean; openedAtUnread?: boolean; }): boolean { - if (input.following) return false; return ( - Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate) + !input.following && + (Boolean(input.openedAtUnread) || (input.enabled && input.hasPendingUpdate)) ); } @@ -834,10 +834,7 @@ function setScrollTop( } /** Read the visible band of a scroll root in client coordinates. */ -export function viewportBounds(root: ScrollRoot): { - bottom: number; - top: number; -} { +function viewportBounds(root: ScrollRoot): { bottom: number; top: number } { if (isWindowRoot(root)) return { bottom: window.innerHeight, top: 0 }; const top = root.getBoundingClientRect().top; return { bottom: top + root.clientHeight, top }; diff --git a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts index 1ef7a3ec0f..9ac813cec1 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts @@ -12,7 +12,7 @@ */ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useLocalStorage } from "@uidotdev/usehooks"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { conversationReadPositionSchema, @@ -21,9 +21,10 @@ import { } from "@sentry/junior/api/schema"; import { put } from "../http"; import { conversationDetailQueryKey } from "./queries"; -import { scrollRootFor, viewportBounds } from "./transcriptBottomPinning"; +import { scrollRootFor } from "./transcriptBottomPinning"; const READ_REPORT_DELAY_MS = 750; +const ROW_SELECTOR = "[data-transcript-seq]"; const START_POSITION_STORAGE_KEY = "junior:conversation:start-position"; /** Where a Conversation opens: at the first unread row or at the latest. */ @@ -42,8 +43,6 @@ export type ReadPositionAtOpen = { unread?: TranscriptUnreadRange; }; -type VerticalBounds = { bottom: number; top: number }; - /** Edit the browser-only preference for where Conversations open. */ export function useConversationStartPosition() { return useLocalStorage( @@ -116,12 +115,14 @@ function readPositionAtOpen( } /** - * Send read positions for one Conversation. The cached detail keeps the - * stored value, so the next visit in this session opens at the right place. + * Send read positions for one Conversation. A position at or before the one + * in the cached detail is not sent, because the server already has it. The + * cached detail keeps the stored value, so the next visit in this session + * opens at the right place. */ export function useMarkConversationRead(conversationId: string) { const queryClient = useQueryClient(); - const mutation = useMutation({ + const { mutate } = useMutation({ mutationFn: (lastReadSeq: number) => put( conversationReadPositionSchema, @@ -135,35 +136,26 @@ export function useMarkConversationRead(conversationId: string) { ); }, }); - return mutation.mutate; -} - -/** - * Find the last row that has reached the viewport, in rows that are in - * document order. - */ -export function lastSeenRowIndex( - count: number, - rowAt: (index: number) => VerticalBounds, - viewport: VerticalBounds, -): number | null { - let low = 0; - let high = count; - while (low < high) { - const middle = (low + high) >> 1; - if (rowAt(middle).top < viewport.bottom) low = middle + 1; - else high = middle; - } - return low === 0 ? null : low - 1; + return useCallback( + (lastReadSeq: number) => { + const stored = queryClient.getQueryData( + conversationDetailQueryKey(conversationId), + )?.readPosition?.lastReadSeq; + if (stored !== undefined && lastReadSeq <= stored) return; + mutate(lastReadSeq); + }, + [conversationId, mutate, queryClient], + ); } /** * Report the last transcript row that the reader saw. * - * Each scroll or layout change measures the row. Reports wait a short time - * and only move forward. A hidden or paused page does not measure. Leaving - * the page or hiding it sends the measured row at once, so a quick exit keeps - * progress. + * An IntersectionObserver keeps the set of rows in the viewport, so a scroll + * does not measure rows. Reports send the highest seq in that set. They wait + * a short time and only move forward. A hidden or paused page does not + * report. Leaving the page or hiding it sends the pending row at once, so a + * quick exit keeps progress. */ export function useTranscriptReadReporter(input: { content: HTMLElement | null; @@ -171,9 +163,9 @@ export function useTranscriptReadReporter(input: { lastReadSeq?: number; onRead?: (seq: number) => void; /** - * Stop measuring while the rows are not in reading order, such as search - * results. This is read when a measure runs, so the render that changes - * the rows also stops their measure. + * Stop reporting while the rows are not in reading order, such as search + * results. This is read when a report is scheduled, so the render that + * changes the rows also stops their report. */ paused?: boolean; }): void { @@ -188,7 +180,9 @@ export function useTranscriptReadReporter(input: { const content = input.content; const root = scrollRootFor(content); if (!enabled || !content || !root) return; + if (typeof IntersectionObserver === "undefined") return; + const visibleRows = new Set(); let timer: number | undefined; let seenSeq: number | null = null; const send = () => { @@ -202,8 +196,12 @@ export function useTranscriptReadReporter(input: { }; const schedule = () => { if (pausedRef.current || document.visibilityState !== "visible") return; - const seq = lastSeenTranscriptSeq(content, viewportBounds(root)); - if (seq !== null) seenSeq = seq; + // Read the seq now: an activity group row moves its seq as it grows. + for (const row of visibleRows) { + const seq = Number(row.dataset.transcriptSeq); + if (Number.isSafeInteger(seq)) seenSeq = Math.max(seenSeq ?? -1, seq); + } + if (seenSeq === null) return; timer ??= window.setTimeout(send, READ_REPORT_DELAY_MS); }; const onVisibilityChange = () => { @@ -211,34 +209,46 @@ export function useTranscriptReadReporter(input: { else send(); }; - const observer = - typeof ResizeObserver === "undefined" - ? undefined - : new ResizeObserver(schedule); - observer?.observe(content); - root.addEventListener("scroll", schedule, { passive: true }); + const intersections = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const row = entry.target as HTMLElement; + if (entry.isIntersecting && row.isConnected) visibleRows.add(row); + else visibleRows.delete(row); + } + schedule(); + }, + { root: root === window ? null : (root as HTMLElement) }, + ); + const rowsIn = (node: Node): HTMLElement[] => { + if (!(node instanceof HTMLElement)) return []; + const rows = [...node.querySelectorAll(ROW_SELECTOR)]; + return node.matches(ROW_SELECTOR) ? [node, ...rows] : rows; + }; + const observeRows = (node: Node) => { + for (const row of rowsIn(node)) intersections.observe(row); + }; + const forgetRows = (node: Node) => { + for (const row of rowsIn(node)) { + intersections.unobserve(row); + visibleRows.delete(row); + } + }; + // Rows mount and unmount as history loads and replies arrive. + const mutations = new MutationObserver((records) => { + for (const record of records) { + record.addedNodes.forEach(observeRows); + record.removedNodes.forEach(forgetRows); + } + }); + mutations.observe(content, { childList: true, subtree: true }); + observeRows(content); document.addEventListener("visibilitychange", onVisibilityChange); - schedule(); return () => { send(); - observer?.disconnect(); - root.removeEventListener("scroll", schedule); + intersections.disconnect(); + mutations.disconnect(); document.removeEventListener("visibilitychange", onVisibilityChange); }; }, [enabled, input.content]); } - -function lastSeenTranscriptSeq( - content: HTMLElement, - viewport: VerticalBounds, -): number | null { - const rows = content.querySelectorAll("[data-transcript-seq]"); - const index = lastSeenRowIndex( - rows.length, - (row) => rows[row]!.getBoundingClientRect(), - viewport, - ); - if (index === null) return null; - const seq = Number(rows[index]!.dataset.transcriptSeq); - return Number.isSafeInteger(seq) ? seq : null; -} From ffcece48a0a204b56f87b3cbdeeb55e37f744bcf Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:52:46 +0000 Subject: [PATCH 7/7] fix(dashboard): Report a visible row when its seq grows An activity group row keeps its node and raises data-transcript-seq as tools arrive. Watch that attribute so the read reporter sees the new seq while the row stays in view. --- .../conversations/transcriptReadPosition.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts index 9ac813cec1..06ec8a49bf 100644 --- a/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts +++ b/packages/junior-dashboard/src/client/conversations/transcriptReadPosition.ts @@ -24,7 +24,8 @@ import { conversationDetailQueryKey } from "./queries"; import { scrollRootFor } from "./transcriptBottomPinning"; const READ_REPORT_DELAY_MS = 750; -const ROW_SELECTOR = "[data-transcript-seq]"; +const SEQ_ATTRIBUTE = "data-transcript-seq"; +const ROW_SELECTOR = `[${SEQ_ATTRIBUTE}]`; const START_POSITION_STORAGE_KEY = "junior:conversation:start-position"; /** Where a Conversation opens: at the first unread row or at the latest. */ @@ -234,14 +235,26 @@ export function useTranscriptReadReporter(input: { visibleRows.delete(row); } }; - // Rows mount and unmount as history loads and replies arrive. + // Rows mount and unmount as history loads and replies arrive. An + // activity group row keeps its node and raises its seq as tools arrive. const mutations = new MutationObserver((records) => { + let seqChanged = false; for (const record of records) { record.addedNodes.forEach(observeRows); record.removedNodes.forEach(forgetRows); + if (record.type === "attributes") { + const row = record.target as HTMLElement; + intersections.observe(row); + seqChanged ||= visibleRows.has(row); + } } + if (seqChanged) schedule(); + }); + mutations.observe(content, { + attributeFilter: [SEQ_ATTRIBUTE], + childList: true, + subtree: true, }); - mutations.observe(content, { childList: true, subtree: true }); observeRows(content); document.addEventListener("visibilitychange", onVisibilityChange); return () => {