/** * The unified changes tab: one tab, two lenses on "what changed?" — Git * (repository truth: staged/unstaged files, commit box, history) and the * session round (agent truth: every file the model read, wrote, or edited). * Both lenses preview their selections in a shared resizable bottom pane * ({@link DiffPane}); git targets expand into the dedicated diff tab docked * in the workbench's diff pane. The active lens and the pane height persist * in the tab's meta, so the tab * reopens exactly where it was left. * * The session events ride the host's `changes.ops` route (the client * runtime exposes no event-log face): the tab pulls the delta past its * cursor while visible, folds it into ops, and publishes the op count to a * module-level cache the tab-strip badge reads. */ import { useCallback, useEffect, useRef, useState } from 'react' import type { SidebarSessionEvent } from '../../context-types.ts' import type { TabComponentProps } from '../service.ts' import { t } from '../locales.ts' import { api } from '../api.ts' import { usePolling } from '../use-polling.ts' import type { SidebarDiffRef } from '../state.ts' import { GitLens } from './GitLens.tsx' import { SessionLens } from './SessionLens.tsx' import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx' import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts' import css from './changes.module.css' /** The default preview pane height (px) before the first drag. */ const PANE_HEIGHT_DEFAULT = 300 /** Cap on accumulated events: the lens shows the recent window, not eternity * (the host enforces the same bound per response). */ const EVENTS_CAP = 4000 /** Live op count per session: the tab's poller writes, the badge reads (a * badge cannot fetch — it must resolve synchronously during render). */ const opCounts = new Map() /** The session's traced-op count as of the last poll (undefined before the * tab has ever pulled; 0 hides the badge pill). */ export function opCountOf(sessionId: string): number | undefined { return opCounts.get(sessionId) } type Lens = 'git' | 'session' /** The persisted tab meta (JSON-serializable; rides the layout). */ interface ChangesMeta { lens?: Lens previewH?: number } export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) { const meta = (tab.meta ?? {}) as ChangesMeta const [lens, setLens] = useState(meta.lens === 'session' ? 'session' : 'git') const [preview, setPreview] = useState(null) const [paneHeight, setPaneHeight] = useState( typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT, ) // ── Session-event accumulation: one pull on mount, then a 2.5s delta // poll while visible (paused otherwise; the next visible tick catches // up). The cursor is the last delivered seq, so each poll ships only // what the accumulator lacks. ──────────────────────────────────────── const eventsRef = useRef([]) // The fold of eventsRef as of the last poll. extractFileOps parses every // accumulated tool/call (up to EVENTS_CAP events); running it once per // poll and REUSING the result across renders (the render used to re-fold // the whole window, twice per tick, and the fresh array defeated the // downstream memo on every poll) keeps the 2.5s tick at one fold. const opsRef = useRef([]) const seqRef = useRef(0) const pollGen = useRef(0) const [opsError, setOpsError] = useState(false) const [tick, setTick] = useState(0) const pull = useCallback(async (): Promise => { const generation = pollGen.current try { const { events, lastSeq } = await api.changesOps(scope, seqRef.current) if (generation !== pollGen.current) return if (events.length > 0) { const merged = [...eventsRef.current, ...events] eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged } if (lastSeq > seqRef.current) seqRef.current = lastSeq const folded = extractFileOps(eventsRef.current) opsRef.current = folded opCounts.set(scope.sessionId, folded.length) setOpsError(false) setTick(value => value + 1) } catch { // Offline / route unavailable: keep the last fold; surface it inline // only while nothing has ever loaded. if (generation === pollGen.current) setOpsError(true) } // Granular scope fields: the scope object's identity churns, only its // sessionId / cwd fields gate the poll target. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd]) useEffect(() => { pollGen.current += 1 eventsRef.current = [] opsRef.current = [] seqRef.current = 0 setOpsError(false) }, [scope.sessionId]) // One pull on every input change — mount, scope change, and visibility // flip all re-pull (a hidden tab still catches up on the flip); only the // poll CADENCE below is gated by visibility. useEffect(() => { void pull() }, [visible, pull]) usePolling(visible, pull, { intervalMs: 2_500 }) // tick only forces the re-render; the fold reads the ref directly. void tick const ops = opsRef.current /** Persist a meta patch onto the tab (lens choice, pane height). */ const patchMeta = (patch: ChangesMeta): void => { ctx.get('betterSidebar')?.updateTab(tab.id, { meta: { ...(tab.meta as ChangesMeta | undefined ?? {}), ...patch }, }) } const chooseLens = (next: Lens): void => { if (next === lens) return setLens(next) patchMeta({ lens: next }) } /** Preview one git change (worktree file or commit) from the Git lens. */ const previewGit = (ref: SidebarDiffRef): void => { setPreview({ kind: 'git', ref }) } /** Preview one session op (with its best-effort prior content snapshot). */ const previewOp = (path: string, op: FileOp): void => { setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) }) } /** Expand the current git preview into the dedicated diff tab, docked * into the workbench's diff pane. */ const expandPreview = (): void => { if (preview?.kind !== 'git') return onOpenDiff?.(diffTabOf(preview.ref)) } const previewKey = (target: ChangesPreview): string => target.kind === 'git' ? (target.ref.kind === 'worktree' ? `git:w:${target.ref.path}:${target.ref.staged ? 's' : 'u'}` : `git:c:${target.ref.hashFull}`) : `op:${target.op.callId}` return (
{lens === 'git' ? ( { /* no-op */ })} onPreview={previewGit} selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null} /> ) : ( )} {preview !== null && ( { setPaneHeight(height); patchMeta({ previewH: height }) }} onClose={() => { setPreview(null) }} onExpand={expandPreview} /> )}
) }