diff --git a/design-system/cockpit/pages/chat.md b/design-system/cockpit/pages/chat.md index fef1c63..10ff501 100644 --- a/design-system/cockpit/pages/chat.md +++ b/design-system/cockpit/pages/chat.md @@ -107,8 +107,22 @@ Header min-height is 52px — it's the drag region, keep it a real grab target. `request_user_input`), never from the raw JSON in the renderer. - Tool/system glyphs are text-presentation unicode (`⚙︎` with U+FE0E, `↳`) — if these ever grow, switch to SVGs from `logos.tsx`; never bare emoji-presentation glyphs. -- **DOM bound:** only the last `RENDER_LAST` (400) messages render, with an explicit - `(showing the last N of M messages)` sys-row. Keep both when touching this. +- **DOM bound, with a way up:** only the last `RENDER_LAST` (400) messages render, and + the sys-row that says so (`EarlierRow`, `transcript-window.tsx`: "showing the last + 400 of 1,200 messages · show 400 earlier") is the control that shows the next batch. + Rows prepend above the viewport, so `useTranscriptWindow` re-adds the height that + landed above the reader in a layout effect and `.messages` carries + `overflow-anchor: none` — one adjustment, not the browser's and ours. The window + resets when the conversation changes. Keep all of it when touching this. +- **A transcript-search hit opens at its message.** The palette hands the hit over as + the chat's `anchor` (`TranscriptAnchor`, `chat-binding.ts`); once the log is in, + `findAnchor` (`transcript-anchor.ts`) names the row by its words, speaker and time — + never by index, since the searcher and the parser count messages differently — the + window is raised to hold it with `ANCHOR_CONTEXT` rows above, it scrolls to the + middle (`data-log-key` on every row is what finds it), wears `.anchored` — an accent + halo, `ANCHOR_RING_MS` — and the `role=status` region says so. Applied once per + anchor: the log keeps growing under a live session and must not re-scroll. Words the + log no longer says open at the bottom as before. - Consecutive duplicate system notices are filtered — providers repeat them. - `Message` is memoized; keys are absolute log offsets (`log.length - visible.length + i`), stable because the log is append-only. Don't "fix" this to item ids or bare indexes. @@ -117,6 +131,20 @@ Header min-height is 52px — it's the drag region, keep it a real grab target. elsewhere…", `title` explaining why — and the transcript re-reads from disk as the index sees each write (App's `diskLogRef`), so the log grows under the reader as a turn of Cockpit's own would. Cockpit's own turn outranks it: one line, never two. +- **A way down for a reader who scrolled up:** the auto-scroll never hijacks a + scroll-up, so rows arriving below are news — `useUnseenBelow` marks them the moment + the log grows while the scroller is off the bottom, and `JumpToLatest` (`.jump-latest`, + the transcript's last child: a sticky zero-height line whose `.btn-ghost.small` "New + messages" key hangs above the bottom edge without moving a row) takes them there. + Reaching the bottom by hand clears it. Hidden it is `visibility: hidden` and out of + the tab order; the status region already announced the turn, so the key is the way + there, not the announcement. Shared with the roundtable — never rebuild it per view. +- **The pin is per conversation, never per binding object.** App re-makes the binding + mid-turn (the native id from the CLI's first event, a parent chip arriving), and a + reset keyed on the object re-pinned the transcript to the bottom — so a reader who had + scrolled up was yanked back on the next row, exactly the hijack the rule forbids. + `atBottomRef` and the DOM window reset on `conversation` (provider · cwd · native id) + instead; the probe in the tour's `chat-new-below` shot is what caught it. - Auto-scroll pins to bottom on new messages/busy; busy shows `.pulse` + " is working…" — the `.thinking` line renders in the placard register (mono uppercase annunciator; the transform is CSS, the DOM text stays sentence diff --git a/design-system/cockpit/pages/palette.md b/design-system/cockpit/pages/palette.md index b7718ec..b008897 100644 --- a/design-system/cockpit/pages/palette.md +++ b/design-system/cockpit/pages/palette.md @@ -81,9 +81,10 @@ Two lines in one `.palette-opt`: the session it came from in the session-row gra time in `.palette-meta`), then `.palette-snippet` — the message windowed around its first match, `--fs-sm`, clamped to two lines, led by a mono `.palette-role` micro-label (`you` / `agent` / `tool`) and with the match in a `` re-voiced as an accent wash under -`--fg` (the UA's yellow-on-black never shows). Picking a hit opens its session; the -transcript view has no per-message anchor yet, so it opens at the bottom like any -session. Row `aria-label` = ` session: — <role>: <snippet>`. +`--fg` (the UA's yellow-on-black never shows). Picking a hit opens its session **at +that message**: the hit itself rides `onOpenSession` as the chat's `anchor`, and the +chat finds the row by its words (see `pages/chat.md`), rings it and says so. Row +`aria-label` = `<Agent> session: <title> — <role>: <snippet>`. ## Keyboard & ARIA diff --git a/design-system/cockpit/pages/roundtable.md b/design-system/cockpit/pages/roundtable.md index 8eba8ed..a0ac360 100644 --- a/design-system/cockpit/pages/roundtable.md +++ b/design-system/cockpit/pages/roundtable.md @@ -42,7 +42,11 @@ attribution, never a parallel message grammar. failed turns → `.sys-row` annotations ("<Agent> turn failed: …"), never bubbles; live tool calls → the shared `Message` tool rows, transient (cleared when the entry lands). - Streaming renders plain text (`.streaming-plain`) like chat — markdownify on entry. -- DOM bound: the last 200 entries, with the explicit "(showing the last N of M)" sys-row. +- DOM bound: the last 200 entries, with the chat's `EarlierRow` ("showing the last 200 + of 250 messages · show 50 earlier") and its `JumpToLatest` key for a reader who + scrolled up while a seat wrote — both from `transcript-window.tsx`, shared with + the chat (see `pages/chat.md`); a seat streaming into its live block counts as + news the same as an entry landing. - Auto-scroll pins to bottom unless the user scrolled up (same 48px rule as chat). ## Composer / rounds diff --git a/docs/guide/chat.md b/docs/guide/chat.md index f2071da..425e255 100644 --- a/docs/guide/chat.md +++ b/docs/guide/chat.md @@ -36,6 +36,11 @@ for its result. Your draft stays in the composer meanwhile. Once you send from Cockpit, the turn streams in as usual and the transcript is Cockpit's until it ends; a turn typed in the terminal after that shows up here again as it lands. +Long transcripts open on their newest 400 messages; the line at the top says how many +there are and shows the next 400 when you ask, without moving what you were reading. If +you scroll up while the agent is still writing, the transcript stays where you put it and a +**New messages** key appears at the bottom edge — press it to go back to the latest. + The permission mode sits beside **Send** and applies to the next turn you send. Tool activity reads one row per call: the command or file it touched, and its result's first line on the right — expand the row for the full input and output. In a narrow window a long first line steps aside so the command stays readable; a short verdict such as `ok` or `20 passed` keeps its place. Replies render as markdown: code blocks carry a **Copy** button, and a link opens in your diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index 7b2b48e..412107d 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -29,7 +29,8 @@ answer. Press <kbd>⌘K</kbd>, type the words, and pick **search transcripts for the session matches. Cockpit streams through the transcripts on demand (nothing is indexed or uploaded), scoped to the repo you are looking at — a row in the results widens the search to every repo. Each hit shows the message around the match, marked, with who -said it; picking one opens that session. +said it; picking one opens that session **at that message** — scrolled into view and +briefly highlighted, however far back it is. Only what you and the agents *said* is searched: tool calls and their output stay out, so a file that every session read doesn't match every session. Large transcripts are diff --git a/scripts/ui-tour/tour.mts b/scripts/ui-tour/tour.mts index fe1244a..ede2808 100644 --- a/scripts/ui-tour/tour.mts +++ b/scripts/ui-tour/tour.mts @@ -293,6 +293,20 @@ const STATIC: readonly Shot[] = [ }, { view: 'roundtable', name: 'roundtable-open', go: (w) => open(w, /Monorepo or polyrepo/) }, { view: 'chat', name: 'chat-claude', go: (w) => open(w, /Fix the login flake/) }, + // a transcript-search hit opens its session at the message: ringed, mid-viewport + { + view: 'chat', + name: 'chat-from-search', + go: async (w) => { + await home(w) + await w.keyboard.press('ControlOrMeta+k') + await w.keyboard.type('spans') + await w.getByRole('option', { name: /Search transcripts for/ }).click() + await w.getByRole('option', { name: /agent:/ }).first().click() + await w.locator('.messages .anchored').waitFor() + await pause(w, 400) + } + }, { view: 'chat', name: 'chat-work-log-open', @@ -351,6 +365,37 @@ const LIVE: readonly Shot[] = [ await pause(w, 1500) } }, + // a turn read from the top: the reply keeps arriving below, and the key says so. The + // chat stays open from the shot above (re-opening a session detaches the view from + // the turn Cockpit is running in it), and this turn waits for that one's Send to be + // back before it starts + { + view: 'live', + name: 'chat-new-below', + go: async (w) => { + if (!(await w.locator('.chat-title').isVisible())) await open(w, /Fix the login flake/) + const box = w.locator('.composer textarea') + await box.fill('And the slow DNS case?') + // Send comes back when the turn above ends — and stays held a beat longer while the + // log's own liveness settles ("working elsewhere"), so wait for it to be pressable + await w.waitForFunction( + () => { + const send = [...document.querySelectorAll<HTMLButtonElement>('.composer button')].find( + (b) => b.textContent?.trim() === 'Send' + ) + return !!send && !send.disabled + }, + undefined, + { timeout: 40_000 } + ) + await box.press('Enter') + await w.locator('.messages').evaluate((el) => el.scrollTo({ top: 0 })) + // the key's line is zero-height on purpose (Playwright reads that as hidden), so + // wait for the key itself + await w.locator('.jump-latest.on button').waitFor({ timeout: 15_000 }) + await pause(w, 300) + } + }, { view: 'live', name: 'home-flying', diff --git a/scripts/ui-tour/world.mts b/scripts/ui-tour/world.mts index 9bd7fd7..70cdfc1 100644 --- a/scripts/ui-tour/world.mts +++ b/scripts/ui-tour/world.mts @@ -497,7 +497,7 @@ function populate(world: World): void { { speaker: 'user', text: 'Monorepo or one repo per SDK?', at: t1 }, { speaker: 'claude', seat: 0, text: 'Monorepo: shared spec, one CI, atomic changes.', at: t1 + 60_000 }, { speaker: 'codex', seat: 1, text: 'Polyrepo keeps each ecosystem idiomatic.', at: t1 + 120_000 }, - { speaker: 'codex', seat: 1, text: 'turn failed: process exited with code 1', at: t1 + 1_200_000, error: true } + { speaker: 'codex', seat: 1, text: 'codex exited with code 1', at: t1 + 1_200_000, error: true } ] }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 049a71b..cdb59e4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -43,7 +43,7 @@ import { import { preloadMarkdown } from './Markdown' import { initTimeFormat } from './time' import type { StartSessionRequest } from './NewSession' -import type { ChatBinding, PendingPermission } from './chat-binding' +import type { ChatBinding, PendingPermission, TranscriptAnchor } from './chat-binding' import type { AccountsSnapshot, AgentOptions } from '../../shared/types' /** `--rail` on the grid: the width the rail was dragged to, in CSS pixels. */ @@ -120,6 +120,8 @@ export function App(): JSX.Element { const [prs, setPrs] = useState<PrStatus[]>([]) const [binding, setBinding] = useState<ChatBinding | null>(null) const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null) + /** Where the open chat should land: the message a transcript-search hit named */ + const [anchor, setAnchor] = useState<TranscriptAnchor | null>(null) const [activeTurn, setActiveTurn] = useState<string | null>(null) const [creating, setCreating] = useState(false) const [creatingPr, setCreatingPr] = useState(false) @@ -393,12 +395,13 @@ export function App(): JSX.Element { ) const openSession = useCallback( - async (s: SessionMeta) => { + async (s: SessionMeta, opts: { readonly anchor?: TranscriptAnchor } = {}) => { const seq = ++openSeqRef.current setChatLog([]) diskLogRef.current = null setActiveTurn(null) setSelectedSessionId(s.id) + setAnchor(opts.anchor ?? null) // restore the account this session's source dir belongs to — otherwise a // reopened session would silently continue on the default account. // (SessionMeta.source is the source LABEL; copilot's historical user is @@ -456,6 +459,7 @@ export function App(): JSX.Element { diskLogRef.current = null setActiveTurn(null) setSelectedSessionId(entry.sessionId) + setAnchor(null) setBinding(entry.binding) if (entry.sessionId) { void api @@ -926,13 +930,14 @@ export function App(): JSX.Element { onOpenLineage={(id) => void openLineage(id)} permissions={permissions} onAnswerPermission={answerPermission} + anchor={anchor} /> )} {paletteOpen && ( <CommandPalette repos={visibleRepos} scopeRepo={scopeRepo} - onOpenSession={(s) => void openSession(s)} + onOpenSession={(s, at) => void openSession(s, at ? { anchor: at } : {})} onNewSession={(repo) => setView({ kind: 'new', repo })} onGoto={(v: PaletteViewKey) => setView(v === 'extensions' ? { kind: v, repoRoot: null } : { kind: v }) diff --git a/src/renderer/src/ChatView.tsx b/src/renderer/src/ChatView.tsx index 62a1ba2..db7e74d 100644 --- a/src/renderer/src/ChatView.tsx +++ b/src/renderer/src/ChatView.tsx @@ -1,20 +1,27 @@ -import { memo, useEffect, useMemo, useRef, useState, type JSX } from 'react' +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type JSX } from 'react' import type { PermissionMode, Provider, PrStatus, SessionMessage } from '../../shared/types' import { api } from './api' import { AskPicker } from './AskPicker' -import type { ChatBinding, PendingPermission } from './chat-binding' +import type { ChatBinding, PendingPermission, TranscriptAnchor } from './chat-binding' import { AttachRow, useImageAttachments } from './attachments' import { CHAT_WIDTH_CSS, useChatWidth } from './chat-width' -import { useChatLog, useChatStatus } from './chat-log' +import { announceChat, useChatLog, useChatStatus } from './chat-log' import { Markdown } from './Markdown' import { MODES } from './NewSession' import { cwdLabel } from '../../shared/library' import { BranchChip, CockpitLogo, DiffIcon, HandoffIcon, PrBadge, ProviderLogo, PROVIDER_LABEL } from './logos' import { ReviewPanel } from './ReviewPanel' import { Select } from './Select' +import { findAnchor } from './transcript-anchor' +import { EarlierRow, JumpToLatest, useTranscriptWindow, useUnseenBelow } from './transcript-window' -/** Big transcripts are already tail-capped in main; this bounds the DOM too. */ +/** Big transcripts are already tail-capped in main; this bounds the DOM too — the + * newest rows first, and "show earlier" brings the next batch of this size. */ const RENDER_LAST = 400 +/** Rows kept above a message a search landed on, so it reads in its context */ +const ANCHOR_CONTEXT = 8 +/** How long the ring stays on a message a search landed on */ +const ANCHOR_RING_MS = 3_000 export function ChatView({ binding, @@ -29,7 +36,8 @@ export function ChatView({ onOpenHandoff, onOpenLineage, permissions, - onAnswerPermission + onAnswerPermission, + anchor = null }: { binding: ChatBinding | null prs: PrStatus[] @@ -47,6 +55,8 @@ export function ChatView({ onOpenLineage: (sourceId: string) => void permissions: readonly PendingPermission[] onAnswerPermission: (ask: PendingPermission, optionId: string) => void + /** The message to open on, from a transcript-search hit; null opens at the bottom */ + anchor?: TranscriptAnchor | null }): JSX.Element { // the transcript is the app's hottest state and this is its only reader — // subscribing here keeps a streaming turn out of every other view (chat-log.ts) @@ -64,11 +74,56 @@ export function ChatView({ const composerRef = useRef<HTMLTextAreaElement>(null) /** Auto-scroll only while the user is pinned to the bottom — never hijack a scroll-up. */ const atBottomRef = useRef(true) + /** What a reader is looking at, stable across the binding objects App makes for it */ + const conversation = binding ? `${binding.provider}|${binding.cwd}|${binding.nativeSessionId ?? ''}` : null + // the DOM window over the log, and the way down for a reader who scrolled up. The + // window resets per conversation, not per binding object: App re-makes the binding + // when a parent chip or a native id arrives, and that must not shrink the window an + // anchor just raised + const { limit, showEarlier, raise } = useTranscriptWindow(scrollRef, RENDER_LAST, conversation) + const below = useUnseenBelow(scrollRef, atBottomRef, log) useEffect(() => { if (atBottomRef.current) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) }, [log, busy, elsewhere]) + // a transcript-search hit: find the message it meant once the log is in, bring it + // into the DOM window, scroll it to the middle and ring it for a moment. Applied once + // per anchor — the log keeps growing under a live session and must not re-scroll. + const [anchoredKey, setAnchoredKey] = useState<number | null>(null) + const appliedAnchor = useRef<TranscriptAnchor | null>(null) + const scrolledKey = useRef<number | null>(null) + useEffect(() => { + // a new anchor, or none (every open sets one): the old ring and scroll are forgotten + // first, so the apply below — same commit, declared after — is what a new one gets + setAnchoredKey(null) + scrolledKey.current = null + appliedAnchor.current = null + }, [anchor]) + useEffect(() => { + // the log lands after the binding and the anchor do, so this waits for it + if (!anchor || anchor === appliedAnchor.current || log.length === 0) return + appliedAnchor.current = anchor + const idx = findAnchor(log, anchor) + if (idx < 0) return + raise(log.length - idx + ANCHOR_CONTEXT) + setAnchoredKey(idx) + }, [anchor, log, raise]) + useLayoutEffect(() => { + if (anchoredKey === null || scrolledKey.current === anchoredKey) return + const el = scrollRef.current?.querySelector<HTMLElement>(`[data-log-key="${anchoredKey}"]`) + if (!el) return + scrolledKey.current = anchoredKey + atBottomRef.current = false + el.scrollIntoView({ block: 'center' }) + announceChat('Showing the message that matched your search') + }, [anchoredKey, limit]) + useEffect(() => { + if (anchoredKey === null) return + const t = setTimeout(() => setAnchoredKey(null), ANCHOR_RING_MS) + return () => clearTimeout(t) + }, [anchoredKey]) + useEffect(() => { if (!cwdCopied) return const t = setTimeout(() => setCwdCopied(false), 1500) @@ -80,10 +135,13 @@ export function ChatView({ if (binding) composerRef.current?.focus() }, [binding?.cwd, binding?.nativeSessionId === null]) - // a freshly opened session always starts pinned to the bottom + // a freshly opened session always starts pinned to the bottom — per conversation, + // not per binding object: App re-makes the binding mid-turn (the native id from the + // CLI's first event, a parent chip), and re-pinning then yanked a reader who had + // scrolled up back to the bottom on the next row useEffect(() => { atBottomRef.current = true - }, [binding]) + }, [conversation]) // attachments belong to the conversation they were pasted into — drop them on switch useEffect(() => { @@ -137,7 +195,7 @@ export function ChatView({ }, [binding?.repoRoot]) const onDefaultBranch = !!binding?.branch && binding.branch === defaultBranch - const sliced = log.length > RENDER_LAST ? log.slice(-RENDER_LAST) : log + const sliced = log.length > limit ? log.slice(-limit) : log const base = log.length - sliced.length // providers repeat identical system notices; consecutive duplicates add nothing. // each row keeps its absolute log offset as the key — stable because the log is @@ -344,9 +402,12 @@ export function ChatView({ onScroll={(e) => { const el = e.currentTarget atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 + if (atBottomRef.current) below.settle() }} > - {hidden > 0 && <div className="sys-row">(showing the last {RENDER_LAST} of {log.length} messages)</div>} + {hidden > 0 && ( + <EarlierRow shown={sliced.length} total={log.length} step={RENDER_LAST} onShow={showEarlier} /> + )} {blocks.map((b) => b.kind === 'run' ? ( <ToolRun key={b.rows[0].key} rows={b.rows} provider={binding.provider} cwd={binding.cwd} /> @@ -367,6 +428,8 @@ export function ChatView({ provider={binding.provider} result={b.row.result} cwd={binding.cwd} + logKey={b.row.key} + anchored={b.row.key === anchoredKey} /> ) )} @@ -385,6 +448,7 @@ export function ChatView({ {log.length === 0 && !sendBlocked && ( <div className="empty-chat small">Send a prompt to start this session.</div> )} + <JumpToLatest on={below.unseen} onJump={below.jump} /> </div> )} <div className="sr-only" role="status" aria-live="polite"> @@ -551,7 +615,9 @@ export const Message = memo(function Message({ m, provider, result, - cwd + cwd, + logKey, + anchored = false }: { m: SessionMessage provider: Provider @@ -559,12 +625,17 @@ export const Message = memo(function Message({ result?: SessionMessage /** The session's directory — paths under it render relative */ cwd?: string + /** The row's absolute log offset, on the element so a search anchor can find it */ + logKey?: number + /** The message a transcript search landed on — rings for a moment */ + anchored?: boolean }): JSX.Element { + const ring = anchored ? ' anchored' : '' if (m.kind === 'tool_call' || m.kind === 'tool_result') { const call = m.kind === 'tool_call' const peek = result ? firstLine(result.text) : '' return ( - <details className="tool-row"> + <details className={`tool-row${ring}`} data-log-key={logKey}> <summary> <span className="tool-chip"> {/* ︎ forces text presentation — the bare gear renders as color emoji on some @@ -593,11 +664,15 @@ export const Message = memo(function Message({ ) } if (m.kind === 'system') { - return <div className="sys-row">{m.text}</div> + return ( + <div className={`sys-row${ring}`} data-log-key={logKey}> + {m.text} + </div> + ) } if (m.role === 'user') { return ( - <div className="msg msg-user"> + <div className={`msg msg-user${ring}`} data-log-key={logKey}> <div className="bubble bubble-user"> <pre>{m.text}</pre> </div> @@ -605,7 +680,10 @@ export const Message = memo(function Message({ ) } return ( - <div className={`msg msg-assistant ${m.streaming ? 'streaming' : ''} ${m.kind === 'reasoning' ? 'reasoning' : ''}`}> + <div + className={`msg msg-assistant ${m.streaming ? 'streaming' : ''} ${m.kind === 'reasoning' ? 'reasoning' : ''}${ring}`} + data-log-key={logKey} + > <span className={`avatar plogo-${provider}`} aria-hidden="true"> <ProviderLogo p={provider} size={14} /> </span> diff --git a/src/renderer/src/CommandPalette.tsx b/src/renderer/src/CommandPalette.tsx index 9ac45f9..9e05971 100644 --- a/src/renderer/src/CommandPalette.tsx +++ b/src/renderer/src/CommandPalette.tsx @@ -8,6 +8,7 @@ import type { TranscriptSearchResult } from '../../shared/types' import { api } from './api' +import type { TranscriptAnchor } from './chat-binding' import { useBusyMap } from './busy' import { useLandedMap } from './landed' import { @@ -153,7 +154,8 @@ export function CommandPalette({ repos: RepoGroup[] /** The repo the window is looking at — transcript search starts scoped to it */ scopeRepo: RepoGroup | null - onOpenSession: (s: SessionMeta) => void + /** A transcript hit opens its session *at* the message: the hit rides along as the anchor */ + onOpenSession: (s: SessionMeta, anchor?: TranscriptAnchor) => void onNewSession: (repo: RepoGroup) => void onRepoSetup: (repoRoot: string) => void onGoto: (view: PaletteViewKey) => void @@ -346,7 +348,7 @@ export function CommandPalette({ } onClose() if (it.kind === 'session') onOpenSession(it.s) - else if (it.kind === 'hit') onOpenSession(it.s) + else if (it.kind === 'hit') onOpenSession(it.s, it.h) else if (it.kind === 'repo') onNewSession(it.r) else if (it.kind === 'repo-setup') onRepoSetup(it.r.root as string) else onGoto(it.v.key) diff --git a/src/renderer/src/RoundtableView.tsx b/src/renderer/src/RoundtableView.tsx index 8eb372d..8feacca 100644 --- a/src/renderer/src/RoundtableView.tsx +++ b/src/renderer/src/RoundtableView.tsx @@ -25,6 +25,7 @@ import { looksSignedOut } from '../../shared/agent-auth' import { SignInFix } from './SignInFix' import { limitOptions, MESSAGE_LIMITS, TABLE_LIMITS } from './NewRoundtable' import { Select } from './Select' +import { EarlierRow, JumpToLatest, useTranscriptWindow, useUnseenBelow } from './transcript-window' import { BranchChip, ChatIcon, ProviderLogo, PROVIDER_LABEL } from './logos' /** Same DOM bound as ChatView, scaled to discussion-length transcripts. */ @@ -219,6 +220,14 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { useEffect(() => { if (atBottomRef.current) scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) }, [entries, live, running]) + // the DOM window over the entries, and the way down for a reader who scrolled up + const { limit, showEarlier } = useTranscriptWindow(scrollRef, RENDER_LAST, id) + const below = useUnseenBelow(scrollRef, atBottomRef, entries) + const { markUnseen } = below + useEffect(() => { + // a seat streaming into its live block while the reader is scrolled up counts too + markUnseen() + }, [live, markUnseen]) useEffect(() => { if (!running) return @@ -304,7 +313,7 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { } // the table cannot afford another round — said before the user tries, with the way on const outOfTurns = !running && roundRefusal(rt.limits, { participants: rt.participants, entries }) !== null - const sliced = entries.length > RENDER_LAST ? entries.slice(-RENDER_LAST) : entries + const sliced = entries.length > limit ? entries.slice(-limit) : entries const base = entries.length - sliced.length /** Seat indexes streaming right now, in seat order. */ const speaking = rt.participants.map((_, i) => i).filter((i) => live[i] !== undefined) @@ -428,10 +437,11 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { onScroll={(e) => { const el = e.currentTarget atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 + if (atBottomRef.current) below.settle() }} > {base > 0 && ( - <div className="sys-row">(showing the last {RENDER_LAST} of {entries.length} messages)</div> + <EarlierRow shown={sliced.length} total={entries.length} step={RENDER_LAST} onShow={showEarlier} /> )} {sliced.map((e, i) => ( <EntryRow @@ -602,6 +612,7 @@ export function RoundtableView({ id }: { id: string }): JSX.Element { )} </div> )} + <JumpToLatest on={below.unseen} onJump={below.jump} /> </div> <div className="sr-only" role="status" aria-live="polite"> {status} diff --git a/src/renderer/src/chat-binding.ts b/src/renderer/src/chat-binding.ts index 383592a..78acce9 100644 --- a/src/renderer/src/chat-binding.ts +++ b/src/renderer/src/chat-binding.ts @@ -1,4 +1,4 @@ -import type { AcpPermissionOption, AgentOptions, Provider } from '../../shared/types' +import type { AcpPermissionOption, AgentOptions, Provider, TranscriptHit } from '../../shared/types' /** * What a chat view is bound to — which agent, in which directory, as whom — and what @@ -28,6 +28,14 @@ export type PendingPermission = { readonly options: readonly AcpPermissionOption[] } +/** + * Where a chat opens when it is reached from a transcript-search hit: the message the + * palette showed, named by what it said, who said it and when (`findAnchor` in + * transcript-anchor.ts turns it into a row). A `TranscriptHit` is one, so the palette + * hands its hit straight over. + */ +export type TranscriptAnchor = Pick<TranscriptHit, 'role' | 'snippet' | 'timestamp'> + export type ChatBinding = { readonly provider: Provider readonly cwd: string diff --git a/src/renderer/src/logos.tsx b/src/renderer/src/logos.tsx index f0e894a..c3a556f 100644 --- a/src/renderer/src/logos.tsx +++ b/src/renderer/src/logos.tsx @@ -265,6 +265,12 @@ const OCTICON_CHECK = const OCTICON_X = 'M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z' const OCTICON_DOT_FILL = 'M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z' +/** octicon arrow-down — "there is more below" (the transcript's jump-to-latest key) */ +const OCTICON_ARROW_DOWN = + 'M13.03 8.72a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.22 3.22V2.75a.75.75 0 0 1 1.5 0v9.19l3.22-3.22a.75.75 0 0 1 1.06 0Z' +export function ArrowDownIcon({ size = 12 }: { size?: number }): JSX.Element { + return <Octicon d={OCTICON_ARROW_DOWN} size={size} /> +} /** GitHub's question mark in a circle: an agent has stopped to ask you something. */ const OCTICON_QUESTION = 'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16 1.1 1.1 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z' diff --git a/src/renderer/src/style.css b/src/renderer/src/style.css index f3c1033..a0b9570 100644 --- a/src/renderer/src/style.css +++ b/src/renderer/src/style.css @@ -909,10 +909,32 @@ body.rail-dragging { cursor: col-resize; user-select: none; } user's preferred width — user bubbles stay right, but near the replies */ padding-inline: max(var(--s6), calc((100% - var(--chat-col)) / 2)); /* the transcript must not shift sideways the moment it grows enough to scroll */ - scrollbar-gutter: stable; } + scrollbar-gutter: stable; + /* "show earlier" prepends rows and keeps the offset itself (useTranscriptWindow); + the browser's own anchoring would add the same height a second time */ + overflow-anchor: none; } /* rows must never shrink to fit the viewport — overflow scrolls instead; without this, tool-rows (overflow:hidden → tiny min-content) compress to slivers in long transcripts */ .messages > * { flex-shrink: 0; } +/* "New messages" for a reader who scrolled up while the agent wrote: the transcript's + last child is a sticky, zero-height line (the negative margin cancels the gap it + would add), and the key hangs above it — absolute, so it takes no room, and centred + without a transform. Hidden it is visibility: hidden, out of the tab order and the + accessibility tree; the opacity fades and visibility waits for it. */ +.jump-latest { position: sticky; bottom: var(--s3); height: 0; margin-top: calc(-1 * var(--s4)); + z-index: 1; visibility: hidden; opacity: 0; + transition: opacity var(--dur) var(--ease), visibility 0s var(--dur); } +.jump-latest.on { visibility: visible; opacity: 1; transition: opacity var(--dur) var(--ease); } +.jump-latest .btn-ghost { position: absolute; bottom: 0; left: 0; right: 0; width: max-content; + margin: 0 auto; display: inline-flex; align-items: center; gap: var(--s1); + color: var(--fg); background: var(--bg2); border-color: var(--border-strong); + box-shadow: var(--shadow-pop); } +/* the message a transcript search landed on: an accent halo for a few seconds, so the + eye finds it in a long transcript; the sr-only status says so for everyone else */ +.bubble-user, .assistant-body, .sys-row, .tool-row { + transition: box-shadow var(--dur) var(--ease); } +.msg.anchored .bubble-user, .msg.anchored .assistant-body, .sys-row.anchored, .tool-row.anchored { + box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.28); border-radius: var(--radius); } .msg { display: flex; gap: var(--s3); max-width: 100%; } .msg-user { justify-content: flex-end; } diff --git a/src/renderer/src/transcript-anchor.ts b/src/renderer/src/transcript-anchor.ts new file mode 100644 index 0000000..de935f8 --- /dev/null +++ b/src/renderer/src/transcript-anchor.ts @@ -0,0 +1,60 @@ +import type { SessionMessage } from '../../shared/types' +import type { TranscriptAnchor } from './chat-binding' + +/** + * Which message of an open transcript a transcript-search hit meant. + * + * The search names a message by what it said — a snippet windowed around the match, + * whitespace collapsed, an ellipsis at whichever edge was cut — and by who said it and + * when. It cannot name it by index: the searcher and the chat's parser count messages + * differently (one collapses Codex's echoed items, the other folds tool results into + * their calls), so an index would not survive the trip. The words survive it. + * + * Returns the message's index in `log`, or -1 when nothing in it says those words — + * a transcript that changed since the search, or a hit on something the chat does not + * render. The chat then opens at the bottom as it always did. + */ +export function findAnchor(log: readonly SessionMessage[], anchor: TranscriptAnchor): number { + const core = collapse(anchor.snippet.replace(/^…/, '').replace(/…$/, '')) + if (!core) return -1 + // exact words first, then any case, then any speaker: the search read the same file, + // so an exact match is the normal case and the fallbacks are for a log that moved + const passes: ReadonlyArray<{ readonly role: boolean; readonly exact: boolean }> = [ + { role: true, exact: true }, + { role: true, exact: false }, + { role: false, exact: false } + ] + const lowered = core.toLowerCase() + for (const pass of passes) { + const found: number[] = [] + log.forEach((m, i) => { + if (pass.role && !speaks(m, anchor.role)) return + const text = collapse(m.text) + if (pass.exact ? text.includes(core) : text.toLowerCase().includes(lowered)) found.push(i) + }) + if (found.length === 0) continue + if (found.length === 1 || anchor.timestamp === null) return found[found.length - 1] + // the same words said twice: the one said nearest the hit's own time + let best = found[found.length - 1] + let gap = Infinity + for (const i of found) { + const ts = log[i].ts + const d = typeof ts === 'number' ? Math.abs(ts - anchor.timestamp) : Infinity + if (d < gap) { + gap = d + best = i + } + } + return best + } + return -1 +} + +/** The search's roles against the chat's: `tool` covers a call and its result alike. */ +function speaks(m: SessionMessage, role: TranscriptAnchor['role']): boolean { + if (role === 'tool') return m.kind === 'tool_call' || m.kind === 'tool_result' || m.role === 'tool' + if (role === 'user') return m.role === 'user' + return m.role === 'assistant' && m.kind !== 'tool_call' && m.kind !== 'tool_result' +} + +const collapse = (s: string): string => s.replace(/\s+/g, ' ').trim() diff --git a/src/renderer/src/transcript-window.tsx b/src/renderer/src/transcript-window.tsx new file mode 100644 index 0000000..f9dba37 --- /dev/null +++ b/src/renderer/src/transcript-window.tsx @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type JSX, type RefObject } from 'react' +import { ArrowDownIcon } from './logos' + +/** + * The two things a bounded, auto-scrolling transcript owes its reader, shared by the + * chat and the roundtable so the two can never drift: + * + * - a way *up*: the DOM renders only the newest rows, and the line that says so is a + * control that shows the next batch — without moving what is on screen, since the + * rows land above it (`useTranscriptWindow`); + * - a way *down*: while the reader is scrolled up and the agent writes, the auto-scroll + * deliberately stays off (never hijack a scroll-up) — so a key says new rows arrived + * below and takes them there (`useUnseenBelow`, `JumpToLatest`). + */ + +/** + * How many of the newest rows render. Starts at `step`, grows by `step` per + * `showEarlier`, and resets when `resetKey` changes (a different conversation). + * `raise` is for a caller that needs a particular older row on screen (an anchor). + * + * Growing prepends rows above the viewport, which would otherwise carry the reader + * to the top: the scroll offset is kept by re-adding the height that landed above it, + * in a layout effect so the frame never paints the jump. `.messages` turns the + * browser's own scroll anchoring off (`overflow-anchor: none`), so this is the one + * adjustment made rather than two. + */ +export function useTranscriptWindow( + scrollRef: RefObject<HTMLElement | null>, + step: number, + resetKey: unknown +): { readonly limit: number; readonly showEarlier: () => void; readonly raise: (to: number) => void } { + const [limit, setLimit] = useState(step) + const keep = useRef<{ readonly top: number; readonly height: number } | null>(null) + useEffect(() => { + setLimit(step) + }, [resetKey, step]) + useLayoutEffect(() => { + const el = scrollRef.current + const k = keep.current + if (!el || !k) return + keep.current = null + el.scrollTop = k.top + (el.scrollHeight - k.height) + }, [limit, scrollRef]) + const showEarlier = useCallback(() => { + const el = scrollRef.current + if (el) keep.current = { top: el.scrollTop, height: el.scrollHeight } + setLimit((l) => l + step) + }, [scrollRef, step]) + const raise = useCallback((to: number) => setLimit((l) => Math.max(l, to)), []) + return { limit, showEarlier, raise } +} + +/** The line at the top of a capped transcript: what is shown, and the way to more. */ +export function EarlierRow({ + shown, + total, + step, + onShow +}: { + shown: number + total: number + step: number + onShow: () => void +}): JSX.Element { + const more = Math.min(step, total - shown) + return ( + <div className="sys-row"> + showing the last {shown.toLocaleString()} of {total.toLocaleString()} messages ·{' '} + <button type="button" className="link-btn" onClick={onShow}> + show {more.toLocaleString()} earlier + </button> + </div> + ) +} + +/** + * Whether rows arrived below a reader who has scrolled up. `signal` is the transcript + * (or whatever grows with it): every change while the scroller is not at the bottom + * marks rows unseen; reaching the bottom, by scroll or by `jump`, clears it. The + * scroller's own `onScroll` keeps `atBottomRef` and calls `settle` when it gets there. + */ +export function useUnseenBelow( + scrollRef: RefObject<HTMLElement | null>, + atBottomRef: RefObject<boolean>, + signal: unknown +): { + readonly unseen: boolean + readonly jump: () => void + readonly settle: () => void + /** For a second thing that grows below (a roundtable's live blocks beside its entries) */ + readonly markUnseen: () => void +} { + const [unseen, setUnseen] = useState(false) + const first = useRef(true) + const markUnseen = useCallback(() => { + if (!atBottomRef.current) setUnseen(true) + }, [atBottomRef]) + useEffect(() => { + // the transcript's first arrival is the conversation opening, not news + if (first.current) { + first.current = false + return + } + markUnseen() + }, [signal, markUnseen]) + const settle = useCallback(() => setUnseen(false), []) + const jump = useCallback(() => { + const el = scrollRef.current + if (el) el.scrollTo({ top: el.scrollHeight }) + atBottomRef.current = true + setUnseen(false) + }, [scrollRef, atBottomRef]) + return { unseen, jump, settle, markUnseen } +} + +/** + * The key that takes a scrolled-up reader to what arrived below. Rendered as the + * transcript's last child: a sticky, zero-height line whose key floats above the + * scroller's bottom edge, so showing and hiding it never moves a row. Hidden it is + * `visibility: hidden` — out of the tab order and the accessibility tree — and the + * `aria-live` status already said the turn ended, so the key is the way there, not + * the announcement. + */ +export function JumpToLatest({ on, onJump }: { on: boolean; onJump: () => void }): JSX.Element { + return ( + <div className={`jump-latest${on ? ' on' : ''}`}> + <button type="button" className="btn-ghost small" tabIndex={on ? 0 : -1} onClick={onJump}> + <ArrowDownIcon size={12} /> + New messages + </button> + </div> + ) +} diff --git a/tests/component/chat-view.test.tsx b/tests/component/chat-view.test.tsx index 1db6647..33102f4 100644 --- a/tests/component/chat-view.test.tsx +++ b/tests/component/chat-view.test.tsx @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ChatView } from '../../src/renderer/src/ChatView' import type { ChatBinding } from '../../src/renderer/src/chat-binding' -import type { PrStatus } from '../../src/shared/types' +import type { PrStatus, SessionMessage } from '../../src/shared/types' +import { addChatMessage, setChatLog } from '../../src/renderer/src/chat-log' +import type { TranscriptAnchor } from '../../src/renderer/src/chat-binding' import { pasteImage, stubObjectUrls } from './paste' import { openPr } from './stub-api' @@ -18,7 +20,7 @@ const binding: ChatBinding = { function renderChat( onSend = vi.fn(), - over: { binding?: ChatBinding; busy?: boolean; elsewhere?: boolean; prs?: PrStatus[] } = {} + over: { binding?: ChatBinding; busy?: boolean; elsewhere?: boolean; prs?: PrStatus[]; anchor?: TranscriptAnchor } = {} ): { onSend: ReturnType<typeof vi.fn>; onOpenHandoff: ReturnType<typeof vi.fn>; onOpenLineage: ReturnType<typeof vi.fn> } { const onOpenHandoff = vi.fn() const onOpenLineage = vi.fn() @@ -37,11 +39,30 @@ function renderChat( onOpenLineage={onOpenLineage} permissions={[]} onAnswerPermission={vi.fn()} + anchor={over.anchor ?? null} /> ) return { onSend, onOpenHandoff, onOpenLineage } } +/** A transcript of `n` numbered assistant lines, the newest last */ +function longLog(n: number): SessionMessage[] { + return Array.from({ length: n }, (_, i) => ({ + role: 'assistant' as const, + kind: 'text' as const, + text: `line ${i} of the transcript`, + ts: 1_000 + i + })) +} + +/** jsdom has no layout: give the scroller a height so "at the bottom" can be false */ +function scrollAway(el: HTMLElement, { top = 0 }: { top?: number } = {}): void { + Object.defineProperty(el, 'scrollHeight', { value: 5000, configurable: true }) + Object.defineProperty(el, 'clientHeight', { value: 600, configurable: true }) + Object.defineProperty(el, 'scrollTop', { value: top, writable: true, configurable: true }) + fireEvent.scroll(el) +} + beforeEach(() => { stubObjectUrls() }) @@ -318,3 +339,66 @@ describe('ChatView pull-request affordance', () => { expect(await screen.findByRole('button', { name: 'Create PR' })).toBeInTheDocument() }) }) + +describe('ChatView transcript window', () => { + it('renders the newest rows and shows the next batch on request, keeping the offset', async () => { + setChatLog(longLog(500)) + renderChat() + const messages = document.querySelector<HTMLElement>('.messages')! + expect(screen.getByText(/showing the last 400 of 500 messages/)).toBeInTheDocument() + expect(screen.queryByText('line 99 of the transcript')).not.toBeInTheDocument() + expect(screen.getByText('line 100 of the transcript')).toBeInTheDocument() + // the reader is at the top of the window; the rows land above and the offset moves + // with them, so the row they were looking at stays where it was + scrollAway(messages, { top: 40 }) + await userEvent.click(screen.getByRole('button', { name: 'show 100 earlier' })) + Object.defineProperty(messages, 'scrollHeight', { value: 6200, configurable: true }) + expect(screen.getByText('line 0 of the transcript')).toBeInTheDocument() + expect(screen.queryByText(/showing the last/)).not.toBeInTheDocument() + }) + + it('a transcript hit opens at its message, rings it, and brings it into the window', () => { + const spy = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {}) + setChatLog(longLog(500)) + renderChat(vi.fn(), { + anchor: { role: 'assistant', snippet: '…line 12 of the transcript', timestamp: 1_012 } + }) + // row 12 is 488 from the end — past the 400 the window opens with + const row = document.querySelector('[data-log-key="12"]')! + expect(row).toHaveClass('anchored') + expect(spy).toHaveBeenCalledOnce() + expect(spy.mock.instances[0]).toBe(row) + expect(screen.getByRole('status')).toHaveTextContent('Showing the message that matched your search') + spy.mockRestore() + }) + + it('a hit whose words are gone from the log opens at the bottom as before', () => { + const spy = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {}) + setChatLog(longLog(5)) + renderChat(vi.fn(), { anchor: { role: 'user', snippet: 'not in this transcript', timestamp: null } }) + expect(document.querySelector('.anchored')).toBeNull() + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('offers "New messages" only to a reader who scrolled up while rows arrived, and takes them down', async () => { + setChatLog(longLog(3)) + renderChat() + const messages = document.querySelector<HTMLElement>('.messages')! + const key = screen.getByRole('button', { name: 'New messages' }) + expect(key.parentElement).not.toHaveClass('on') + // pinned to the bottom: a new row is auto-scrolled to, nothing to offer + act(() => addChatMessage({ role: 'assistant', kind: 'text', text: 'four' })) + expect(key.parentElement).not.toHaveClass('on') + // scrolled up: the next row is news + scrollAway(messages, { top: 0 }) + act(() => addChatMessage({ role: 'assistant', kind: 'text', text: 'five' })) + expect(key.parentElement).toHaveClass('on') + expect(key).toHaveAttribute('tabindex', '0') + const scrollTo = vi.spyOn(messages, 'scrollTo').mockImplementation(() => {}) + await userEvent.click(key) + expect(scrollTo).toHaveBeenCalledWith({ top: 5000 }) + expect(key.parentElement).not.toHaveClass('on') + expect(key).toHaveAttribute('tabindex', '-1') + }) +}) diff --git a/tests/component/command-palette.test.tsx b/tests/component/command-palette.test.tsx index 33e21c2..bce283b 100644 --- a/tests/component/command-palette.test.tsx +++ b/tests/component/command-palette.test.tsx @@ -204,7 +204,8 @@ describe('CommandPalette', () => { expect(onClose).not.toHaveBeenCalled() await userEvent.click(row) - expect(onOpenSession).toHaveBeenCalledWith(hit().sessions[0]) + // the hit rides along as the anchor: the chat opens at that message + expect(onOpenSession).toHaveBeenCalledWith(hit().sessions[0], hit().hits[0]) expect(onClose).toHaveBeenCalled() }) diff --git a/tests/component/roundtable-view.test.tsx b/tests/component/roundtable-view.test.tsx index c72b55a..cdb79bd 100644 --- a/tests/component/roundtable-view.test.tsx +++ b/tests/component/roundtable-view.test.tsx @@ -449,3 +449,21 @@ describe('RoundtableView while a round runs', () => { ) }) }) + +describe('RoundtableView transcript window', () => { + it('renders the newest entries and shows the next batch on request', async () => { + const entries = Array.from({ length: 250 }, (_, i) => ({ + speaker: (i % 2 === 0 ? 'user' : 'claude') as 'user' | 'claude', + seat: i % 2 === 0 ? undefined : 0, + text: `entry ${i}`, + at: i + 1 + })) + vi.mocked(window.cockpit.getRoundtable).mockResolvedValue(fixture({ entries })) + render(<RoundtableView id="rt-1" />) + expect(await screen.findByText(/showing the last 200 of 250 messages/)).toBeInTheDocument() + expect(screen.queryByText('entry 49')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'show 50 earlier' })) + expect(screen.getByText('entry 0')).toBeInTheDocument() + expect(screen.queryByText(/showing the last/)).not.toBeInTheDocument() + }) +}) diff --git a/tests/transcript-anchor.test.ts b/tests/transcript-anchor.test.ts new file mode 100644 index 0000000..d686880 --- /dev/null +++ b/tests/transcript-anchor.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { findAnchor } from '../src/renderer/src/transcript-anchor' +import type { SessionMessage } from '../src/shared/types' + +const user = (text: string, ts?: number): SessionMessage => ({ role: 'user', kind: 'text', text, ts }) +const agent = (text: string, ts?: number): SessionMessage => ({ role: 'assistant', kind: 'text', text, ts }) +const tool = (text: string): SessionMessage => ({ role: 'assistant', kind: 'tool_call', toolName: 'Bash', text }) + +const log: readonly SessionMessage[] = [ + user('The login e2e test flakes on CI roughly one run in five.', 1000), + agent('Let me reproduce it first.', 2000), + tool('npm run test:e2e -- --grep login'), + agent('I traced the flake to the retry loop in\nsrc/auth/login.ts.\n\nThe client gives up after one attempt.', 3000), + user('Yes, add the regression test. Then commit.', 4000), + agent('Adding a test that stubs a slow DNS resolver.', 5000) +] + +describe('findAnchor', () => { + it('names the message by its words, ellipses and line breaks aside', () => { + expect( + findAnchor(log, { role: 'assistant', snippet: '…the retry loop in src/auth/login.ts. The client gives…', timestamp: 3000 }) + ).toBe(3) + }) + + it('a user hit finds the user turn, not an agent quoting it', () => { + const quoted = [...log, agent('You said: add the regression test. Then commit.', 6000)] + expect(findAnchor(quoted, { role: 'user', snippet: 'add the regression test. Then commit.', timestamp: 4000 })).toBe(4) + expect(findAnchor(quoted, { role: 'assistant', snippet: 'add the regression test. Then commit.', timestamp: 6000 })).toBe(6) + }) + + it('a tool hit lands on the call row', () => { + expect(findAnchor(log, { role: 'tool', snippet: 'npm run test:e2e -- --grep login', timestamp: null })).toBe(2) + }) + + it('the same words twice: the one said nearest the hit, else the newest', () => { + const twice = [agent('Running the suite.', 100), agent('Running the suite.', 900), agent('Running the suite.', 2000)] + expect(findAnchor(twice, { role: 'assistant', snippet: 'Running the suite.', timestamp: 950 })).toBe(1) + expect(findAnchor(twice, { role: 'assistant', snippet: 'Running the suite.', timestamp: null })).toBe(2) + }) + + it('falls back through case, then speaker, before giving up', () => { + expect(findAnchor(log, { role: 'assistant', snippet: 'let me REPRODUCE it', timestamp: null })).toBe(1) + expect(findAnchor(log, { role: 'user', snippet: 'stubs a slow DNS resolver', timestamp: null })).toBe(5) + expect(findAnchor(log, { role: 'assistant', snippet: 'nothing in this log says this', timestamp: null })).toBe(-1) + expect(findAnchor(log, { role: 'assistant', snippet: '…', timestamp: null })).toBe(-1) + }) +})