diff --git a/packages/junior-dashboard/src/client/components/Transcript.tsx b/packages/junior-dashboard/src/client/components/Transcript.tsx index 77454f6c7..bdc3d6600 100644 --- a/packages/junior-dashboard/src/client/components/Transcript.tsx +++ b/packages/junior-dashboard/src/client/components/Transcript.tsx @@ -1,18 +1,30 @@ import { useState, type ReactNode } from "react"; +import { ArrowDownToLine } from "lucide-react"; import type { ConversationTurn } from "../types"; +import { cn } from "../styles"; +import { Button } from "./Button"; import { TranscriptHeader } from "./TranscriptHeader"; import { ConversationTranscriptSegment } from "./TranscriptTurn"; +import { + transcriptBottomVersion, + usePinnedTranscriptBottom, +} from "./transcriptBottomPinning"; import type { TranscriptViewMode } from "./transcriptRenderModel"; import { transcriptEmptyClass } from "./transcriptStyles"; /** Render ordered conversation transcript segments as message and tool events. */ export function Transcript(props: { actions?: ReactNode; + live?: boolean; turns: ConversationTurn[]; }) { const [view, setView] = useState("rich"); const hasRedactedTurns = props.turns.some((turn) => turn.transcriptRedacted); + const bottomPinning = usePinnedTranscriptBottom({ + enabled: props.live ?? false, + version: transcriptBottomVersion(props.turns), + }); if (props.turns.length === 0) { return ( @@ -23,7 +35,10 @@ export function Transcript(props: { } return ( -
+
{props.turns.map((turn) => ( - + ))} + + ); +} + +function JumpToLatestButton(props: { + hasPendingUpdate: boolean; + onClick: () => void; + visible: boolean; +}) { + if (!props.visible) return null; + + const label = props.hasPendingUpdate + ? "Jump to latest update" + : "Jump to latest"; + + return ( +
+
); } diff --git a/packages/junior-dashboard/src/client/components/transcriptBottomPinning.ts b/packages/junior-dashboard/src/client/components/transcriptBottomPinning.ts new file mode 100644 index 000000000..d6eab0c8f --- /dev/null +++ b/packages/junior-dashboard/src/client/components/transcriptBottomPinning.ts @@ -0,0 +1,331 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefCallback, + type RefObject, +} from "react"; + +import type { ConversationTurn, TranscriptPart } from "../types"; + +const BOTTOM_PROXIMITY_PX = 96; +const USER_SCROLL_DELTA_PX = 2; + +type ScrollRoot = HTMLElement | Window; +type PositionMeasureSource = "measure" | "scroll"; + +export type TranscriptFollowIntent = "follow" | "pause" | "preserve"; + +export type ScrollSnapshot = { + clientHeight: number; + scrollHeight: number; + scrollTop: number; +}; + +type BottomPinResult = { + anchorRef: RefObject; + contentRef: RefCallback; + hasPendingUpdate: boolean; + jumpToBottom: () => void; + showJumpToLatest: boolean; +}; + +const useBrowserLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** Detect proximity with slack so fractional pixels and mobile chrome do not break follow mode. */ +export function isNearScrollBottom( + snapshot: ScrollSnapshot, + thresholdPx = BOTTOM_PROXIMITY_PX, +): boolean { + const remaining = + snapshot.scrollHeight - snapshot.scrollTop - snapshot.clientHeight; + return remaining <= thresholdPx; +} + +/** Build a compact transcript-tail key so polling without content changes does not look new. */ +export function transcriptBottomVersion(turns: ConversationTurn[]): string { + const lastTurn = turns.at(-1); + if (!lastTurn) return "empty"; + + const messages = + lastTurn.transcript.length > 0 + ? lastTurn.transcript + : (lastTurn.transcriptMetadata ?? []); + const lastMessage = messages.at(-1); + const lastPart = lastMessage?.parts.at(-1); + + return [ + turns.length, + lastTurn.id, + lastTurn.status, + messages.length, + lastMessage?.role ?? "", + lastMessage?.timestamp ?? "", + lastMessage?.parts.length ?? 0, + transcriptPartVersion(lastPart), + ].join("|"); +} + +/** Require both live mode and reader intent before moving the viewport. */ +export function shouldAutoPinTranscriptBottom(input: { + enabled: boolean; + following: boolean; +}): boolean { + return input.enabled && input.following; +} + +/** Resolve scroll intent with user upward movement taking precedence over bottom slack. */ +export function transcriptFollowIntent(input: { + previousScrollTop: number | null; + snapshot: ScrollSnapshot; + source: PositionMeasureSource; +}): TranscriptFollowIntent { + if ( + input.source === "scroll" && + input.previousScrollTop != null && + input.snapshot.scrollTop < input.previousScrollTop - USER_SCROLL_DELTA_PX + ) { + return "pause"; + } + + if (isNearScrollBottom(input.snapshot)) return "follow"; + return "preserve"; +} + +/** Keep live transcript updates visually pinned only while the reader intends to follow them. */ +export function usePinnedTranscriptBottom(input: { + enabled: boolean; + version: string; +}): BottomPinResult { + const anchorRef = useRef(null); + const contentElementRef = useRef(null); + const enabledRef = useRef(input.enabled); + const everEnabledRef = useRef(input.enabled); + const followingRef = useRef(false); + const initializedRef = useRef(false); + const previousScrollTopRef = useRef(null); + const [following, setFollowing] = useState(false); + const [hasPendingUpdate, setHasPendingUpdate] = useState(false); + const [contentElement, setContentElement] = useState( + null, + ); + + const contentRef = useCallback((node: HTMLDivElement | null) => { + contentElementRef.current = node; + setContentElement(node); + }, []); + + useEffect(() => { + enabledRef.current = input.enabled; + if (input.enabled) { + everEnabledRef.current = true; + } else { + followingRef.current = false; + setFollowing(false); + setHasPendingUpdate(false); + } + }, [input.enabled]); + + const setFollowingIntent = useCallback((value: boolean) => { + followingRef.current = value; + setFollowing(value); + }, []); + + const measurePosition = useCallback( + (source: PositionMeasureSource) => { + const root = scrollRootFor(contentElementRef.current); + if (!root) return; + + const snapshot = scrollSnapshot(root); + const previousScrollTop = previousScrollTopRef.current; + previousScrollTopRef.current = snapshot.scrollTop; + + const intent = transcriptFollowIntent({ + previousScrollTop, + snapshot, + source, + }); + if (intent === "follow") { + setFollowingIntent(true); + setHasPendingUpdate(false); + return; + } + + if (intent === "pause") { + setFollowingIntent(false); + } + }, + [setFollowingIntent], + ); + + const scrollToBottom = useCallback((behavior: ScrollBehavior) => { + anchorRef.current?.scrollIntoView({ behavior, block: "end" }); + }, []); + + const syncAfterLayoutChange = useCallback(() => { + if ( + shouldAutoPinTranscriptBottom({ + enabled: enabledRef.current, + following: followingRef.current, + }) + ) { + scrollToBottom("auto"); + return; + } + + measurePosition("measure"); + }, [measurePosition, scrollToBottom]); + + useBrowserLayoutEffect(() => { + const wasEnabled = enabledRef.current; + const shouldTrack = input.enabled || wasEnabled; + enabledRef.current = input.enabled; + if (input.enabled) everEnabledRef.current = true; + if (!shouldTrack) return; + + const wasInitialized = initializedRef.current; + if (!initializedRef.current) { + initializedRef.current = true; + measurePosition("measure"); + } + + if ( + shouldAutoPinTranscriptBottom({ + enabled: input.enabled, + following: followingRef.current, + }) + ) { + scrollToBottom("auto"); + setHasPendingUpdate(false); + return; + } + + if (input.enabled && wasInitialized) { + setHasPendingUpdate(true); + } + }, [input.enabled, input.version, measurePosition, scrollToBottom]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const root = scrollRootFor(contentElement); + if (!root) return; + + const target: HTMLElement | Window = root === window ? window : root; + const onScroll = () => measurePosition("scroll"); + + measurePosition("measure"); + target.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", syncAfterLayoutChange); + return () => { + target.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", syncAfterLayoutChange); + }; + }, [contentElement, measurePosition, syncAfterLayoutChange]); + + useEffect(() => { + if (typeof ResizeObserver === "undefined") return; + if (!contentElement) return; + + const observer = new ResizeObserver(() => { + syncAfterLayoutChange(); + }); + observer.observe(contentElement); + return () => observer.disconnect(); + }, [contentElement, syncAfterLayoutChange]); + + const jumpToBottom = useCallback(() => { + setFollowingIntent(true); + setHasPendingUpdate(false); + scrollToBottom(preferredExplicitScrollBehavior()); + }, [scrollToBottom, setFollowingIntent]); + + return useMemo( + () => ({ + anchorRef, + contentRef, + hasPendingUpdate, + jumpToBottom, + showJumpToLatest: input.enabled && !following, + }), + [following, hasPendingUpdate, input.enabled, jumpToBottom], + ); +} + +function transcriptPartVersion(part: TranscriptPart | undefined): string { + if (!part) return ""; + + return [ + part.type, + part.id ?? "", + part.name ?? "", + part.chars ?? part.text?.length ?? "", + part.bytes ?? "", + part.inputSizeChars ?? "", + part.inputSizeBytes ?? "", + part.outputSizeChars ?? outputLength(part.output), + part.outputSizeBytes ?? "", + part.redacted ? "redacted" : "", + ].join(":"); +} + +function outputLength(output: unknown): number | string { + if (typeof output === "string") return output.length; + if (output == null) return ""; + return ""; +} + +function scrollRootFor(element: HTMLElement | null): ScrollRoot | null { + if (typeof window === "undefined") return null; + if (!element) return window; + + let current = element.parentElement; + while (current && current !== document.body) { + const style = window.getComputedStyle(current); + if ( + /(auto|scroll|overlay)/.test(style.overflowY) && + current.scrollHeight > current.clientHeight + ) { + return current; + } + current = current.parentElement; + } + + return window; +} + +function scrollSnapshot(root: ScrollRoot): ScrollSnapshot { + if (isWindowRoot(root)) { + const element = document.scrollingElement ?? document.documentElement; + return { + clientHeight: window.innerHeight, + scrollHeight: element.scrollHeight, + scrollTop: window.scrollY || element.scrollTop, + }; + } + + return { + clientHeight: root.clientHeight, + scrollHeight: root.scrollHeight, + scrollTop: root.scrollTop, + }; +} + +function isWindowRoot(root: ScrollRoot): root is Window { + return root === window; +} + +function preferredExplicitScrollBehavior(): ScrollBehavior { + if (typeof window === "undefined") return "auto"; + if ( + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return "auto"; + } + return "smooth"; +} diff --git a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx index 44dda399b..df1c83f73 100644 --- a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx @@ -91,6 +91,7 @@ export function ConversationPage(props: { data?: DashboardData }) { detail={detail.data} /> } + live={conversationIsLive(visualStatus, detail.data)} turns={detail.data?.turns ?? []} /> )} @@ -99,6 +100,14 @@ export function ConversationPage(props: { data?: DashboardData }) { ); } +function conversationIsLive( + visualStatus: ReturnType | undefined, + detail: ConversationDetailFeed | undefined, +): boolean { + if (detail) return detail.turns.some((turn) => turn.status === "active"); + return visualStatus === "active"; +} + function CopyMarkdownButton(props: { conversation: Conversation | undefined; detail: ConversationDetailFeed | undefined; diff --git a/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts b/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts new file mode 100644 index 000000000..b0cc9b694 --- /dev/null +++ b/packages/junior-dashboard/tests/transcriptBottomPinning.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { + isNearScrollBottom, + shouldAutoPinTranscriptBottom, + transcriptFollowIntent, + transcriptBottomVersion, +} from "../src/client/components/transcriptBottomPinning"; +import type { ConversationTurn } from "../src/client/types"; + +function activeTurn( + overrides: Partial = {}, +): ConversationTurn { + return { + conversationId: "conversation-1", + cumulativeDurationMs: 0, + id: "turn-1", + lastProgressAt: "2026-01-01T00:00:10.000Z", + lastSeenAt: "2026-01-01T00:00:10.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + status: "active", + surface: "slack", + title: "Turn turn-1", + transcript: [ + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "text", text: "checking" }], + }, + ], + transcriptAvailable: true, + ...overrides, + } as ConversationTurn; +} + +describe("transcript bottom pinning", () => { + it("treats near-bottom scroll positions as followable", () => { + expect( + isNearScrollBottom({ + clientHeight: 800, + scrollHeight: 2_000, + scrollTop: 1_112, + }), + ).toBe(true); + + expect( + isNearScrollBottom({ + clientHeight: 800, + scrollHeight: 2_000, + scrollTop: 1_000, + }), + ).toBe(false); + }); + + it("changes the tail version when streamed text grows", () => { + const before = transcriptBottomVersion([activeTurn()]); + const after = transcriptBottomVersion([ + activeTurn({ + transcript: [ + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "text", text: "checking the deployment" }], + }, + ], + }), + ]); + + expect(after).not.toBe(before); + }); + + it("keeps the tail version stable when only polling timestamps change", () => { + const before = transcriptBottomVersion([activeTurn()]); + const after = transcriptBottomVersion([ + activeTurn({ + lastProgressAt: "2026-01-01T00:01:00.000Z", + lastSeenAt: "2026-01-01T00:01:00.000Z", + }), + ]); + + expect(after).toBe(before); + }); + + it("changes the tail version when the live turn completes", () => { + const before = transcriptBottomVersion([activeTurn()]); + const after = transcriptBottomVersion([ + activeTurn({ + completedAt: "2026-01-01T00:00:12.000Z", + status: "completed", + }), + ]); + + expect(after).not.toBe(before); + }); + + it("does not auto-pin after live mode turns off", () => { + expect( + shouldAutoPinTranscriptBottom({ enabled: true, following: true }), + ).toBe(true); + expect( + shouldAutoPinTranscriptBottom({ enabled: false, following: true }), + ).toBe(false); + }); + + it("pauses follow when the reader scrolls up inside bottom slack", () => { + expect( + transcriptFollowIntent({ + previousScrollTop: 1_120, + snapshot: { + clientHeight: 800, + scrollHeight: 2_000, + scrollTop: 1_112, + }, + source: "scroll", + }), + ).toBe("pause"); + }); +});