diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c8df2..f5aa350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 1.0.0 — Unreleased +- Populate Review on first render so Workspace Changes, Problems, Running, and + Plans & Docs are no longer left empty and unpolled when the view is already + open as the extension activates. +- Anchor each Review context on its earliest open agent, so a closed session can + no longer impose a stale baseline branch, an inflated change count, or + unrelated documents as agent artifacts. - Show Codex, Claude, or Custom directly on every local and cross-window agent row while preserving status icons for activity and attention. - Clear stale attention indicators as soon as updates are read, prefer unread diff --git a/README.md b/README.md index c271820..081a88f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ The Lookout sidebar groups agents in the current workspace separately from live agents in other windows, with review evidence, plans, and usage limits alongside them. +![The Lookout sidebar showing three agents under Current Workspace with their repository, branch, and status, a live agent from another window under Live in Other Windows, a Review view listing Workspace Changes for the attached agents alongside Problems, Running, and Plans and Docs, and a Usage Limits view with per-agent context plus Codex and Claude quota windows](assets/screenshots/lookout-overview.png) + ## What Lookout adds - **Agents** — launch Codex, Claude Code, or a custom command in a named native diff --git a/assets/screenshots/lookout-overview.png b/assets/screenshots/lookout-overview.png index d058eeb..55dce49 100644 Binary files a/assets/screenshots/lookout-overview.png and b/assets/screenshots/lookout-overview.png differ diff --git a/src/extension.ts b/src/extension.ts index 26cfc79..1b8f1dd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -83,6 +83,13 @@ export async function activate( const reviewTreeView = vscode.window.createTreeView('lookout.review', { treeDataProvider: reviewTree }); + // Subscribe before sampling `visible`. A freshly created tree view reports + // false until it renders, and the render event must not land in a gap before + // this listener exists: losing it leaves the view unpopulated and unpolled + // until the user manually toggles the container. + const reviewVisibility = reviewTreeView.onDidChangeVisibility((event) => + reviewTree.setVisible(event.visible) + ); reviewTree.setVisible(reviewTreeView.visible); const usage = new UsageManager(context, sessions); const usageTree = new UsageTreeProvider(usage, sessions); @@ -144,9 +151,7 @@ export async function activate( doctorOutput, runtimeLog, reviewTreeView, - reviewTreeView.onDidChangeVisibility((event) => - reviewTree.setVisible(event.visible) - ), + reviewVisibility, vscode.workspace.registerTextDocumentContentProvider( 'lookout-baseline', reviewTree diff --git a/src/reviewAnchor.ts b/src/reviewAnchor.ts new file mode 100644 index 0000000..795e931 --- /dev/null +++ b/src/reviewAnchor.ts @@ -0,0 +1,23 @@ +import type { AgentSession } from './types'; + +/** + * Chooses which of a worktree's attached sessions anchor its review context. + * + * One physical worktree has one review context, anchored on the earliest + * attached session so that already-reviewed changes do not disappear as newer + * sessions attach. "Attached" means open: a closed session holds no terminal, so + * anchoring on one lets long-dead history dictate the baseline branch — marking + * the group permanently stale against the current branch — and the context start + * time, which admits unrelated documents as agent artifacts. + * + * Sessions are expected to be pre-sorted oldest first. The full list is returned + * only when nothing is open, so a context mid-teardown still renders instead of + * vanishing. + */ +export function anchoredSessions( + sorted: readonly AgentSession[], + isOpen: (sessionId: string) => boolean +): readonly AgentSession[] { + const open = sorted.filter((session) => isOpen(session.id)); + return open.length > 0 ? open : sorted; +} diff --git a/src/reviewTree.ts b/src/reviewTree.ts index 8a83a02..cdf7c3c 100644 --- a/src/reviewTree.ts +++ b/src/reviewTree.ts @@ -14,6 +14,7 @@ import { type WorkspaceChange } from './gitReview'; import type { SessionManager } from './sessionManager'; +import { anchoredSessions } from './reviewAnchor'; import { boundedReviewItemLimit, normalizeReviewGlobs, @@ -251,6 +252,8 @@ export class ReviewTreeProvider private worktreeRefreshTimer: NodeJS.Timeout | undefined; private initialized = false; private visible = true; + private rendered = false; + private populated = false; public readonly onDidChangeTreeData = this.changedEmitter.event; public readonly onDidChange = this.contentChangedEmitter.event; @@ -338,9 +341,28 @@ export class ReviewTreeProvider this.reconcileVerificationContexts(); this.refreshRuntime(); if (this.visible) { + this.populated = true; await this.refresh(); this.startWorktreePolling(); + } else { + // The view may already have rendered while initialization was in flight. + this.ensurePopulated(); + } + } + + /** + * Populates the view once, without depending on a single visibility event + * arriving. `getChildren` only runs when the view genuinely renders, so + * pairing it with initialization covers both orderings while still doing no + * Git work for a view the user never opens. + */ + private ensurePopulated(): void { + if (!this.initialized || !this.rendered || this.populated) { + return; } + this.populated = true; + this.startWorktreePolling(); + this.runBackground('initial-refresh', () => this.refresh()); } public setVisible(visible: boolean): void { @@ -365,6 +387,8 @@ export class ReviewTreeProvider public getChildren(element?: ReviewTreeItem): ReviewTreeItem[] { if (!element) { + this.rendered = true; + this.ensurePopulated(); const groups = [ new ReviewTreeItem('group', 'Workspace Changes', { group: 'changes', @@ -1348,7 +1372,12 @@ async function loadWorktreeChanges( // One physical worktree has one review context. Keep the earliest valid // launch baseline stable while more sessions attach; switching to the // newest session would make already-reviewed changes disappear. - const session = sorted[0]; + // + // Closed sessions must never anchor a live context; see anchoredSessions. + const anchored = anchoredSessions(sorted, (sessionId) => + sessions.isOpen(sessionId) + ); + const session = anchored[0]; const linkedRegistration = 'registration' in entry ? entry.registration : undefined; @@ -1389,12 +1418,12 @@ async function loadWorktreeChanges( return { key, session, - sessions: sorted, + sessions: anchored, baseline, linked: entry.linked, - startedAt: linkedRegistration?.createdAt ?? sorted[0].createdAt, - agentLabels: sorted.map((attached) => attached.label), - agentDetails: sorted.map( + startedAt: linkedRegistration?.createdAt ?? anchored[0].createdAt, + agentLabels: anchored.map((attached) => attached.label), + agentDetails: anchored.map( (attached) => `${attached.label} (${attached.kind})` ), changes, diff --git a/test/reviewAnchor.test.ts b/test/reviewAnchor.test.ts new file mode 100644 index 0000000..a887616 --- /dev/null +++ b/test/reviewAnchor.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { anchoredSessions } from '../src/reviewAnchor'; +import type { AgentSession } from '../src/types'; + +function session( + id: string, + createdAt: number, + branch: string +): AgentSession { + return { + id, + label: id, + kind: 'claude', + status: 'active', + createdAt, + updatedAt: createdAt, + cwd: '/repo', + baseline: { + repoRoot: '/repo', + commit: `${id}-commit`, + branch, + capturedAt: createdAt + } + } as unknown as AgentSession; +} + +const closedLastWeek = session('closed-last-week', 1_000, 'main'); +const openToday = session('open-today', 5_000, 'feature'); +const openLater = session('open-later', 6_000, 'feature'); +const sorted = [closedLastWeek, openToday, openLater]; +const isOpen = (id: string): boolean => id !== 'closed-last-week'; + +test('a closed session never anchors a context that has open sessions', () => { + const anchored = anchoredSessions(sorted, isOpen); + + assert.equal( + anchored[0]?.id, + 'open-today', + 'the earliest OPEN session must anchor the context' + ); + assert.equal( + anchored[0]?.baseline?.branch, + 'feature', + 'the baseline branch must come from a live session, not dead history' + ); + assert.ok( + !anchored.some((entry) => entry.id === 'closed-last-week'), + 'a closed session must not be reported as attached' + ); +}); + +test('the earliest open session anchors, keeping reviewed changes stable', () => { + // Attaching a newer session must not move the anchor, or already-reviewed + // changes would disappear from the group. + assert.equal(anchoredSessions([openToday, openLater], isOpen)[0]?.id, 'open-today'); + assert.equal(anchoredSessions([openLater], isOpen)[0]?.id, 'open-later'); +}); + +test('a context with nothing open still renders rather than vanishing', () => { + const anchored = anchoredSessions(sorted, () => false); + + assert.equal(anchored.length, 3, 'the full list is the teardown fallback'); + assert.equal(anchored[0]?.id, 'closed-last-week'); +}); + +test('an empty context yields no anchor', () => { + assert.deepEqual(anchoredSessions([], isOpen), []); +}); diff --git a/test/reviewViewActivation.test.ts b/test/reviewViewActivation.test.ts new file mode 100644 index 0000000..6a7dcaf --- /dev/null +++ b/test/reviewViewActivation.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import test from 'node:test'; + +const repositoryRoot = path.resolve(__dirname, '..', '..'); +const extensionSource = readFileSync( + path.join(repositoryRoot, 'src', 'extension.ts'), + 'utf8' +); +const reviewTreeSource = readFileSync( + path.join(repositoryRoot, 'src', 'reviewTree.ts'), + 'utf8' +); + +// A freshly created tree view reports visible === false until it renders, so the +// render event is the only signal that the Review view is on screen. If that +// event lands before its listener is attached it is lost for good, and the view +// stays empty and unpolled until the user manually switches containers. These +// are ordering invariants that read as harmless to rearrange, so guard them. + +test('the review view subscribes to visibility before sampling or awaiting', () => { + const createIndex = extensionSource.indexOf( + "createTreeView('lookout.review'" + ); + assert.ok(createIndex > 0, 'the review tree view must be created'); + + const subscribeIndex = extensionSource.indexOf( + 'onDidChangeVisibility', + createIndex + ); + assert.ok( + subscribeIndex > createIndex, + 'the visibility subscription must follow view creation' + ); + + const sampleIndex = extensionSource.indexOf( + 'setVisible(reviewTreeView.visible)', + createIndex + ); + assert.ok( + sampleIndex > subscribeIndex, + 'sampling .visible must not precede the visibility subscription' + ); + + const awaitIndex = extensionSource.indexOf('await ', createIndex); + assert.ok( + awaitIndex > subscribeIndex, + 'no await may separate view creation from the visibility subscription' + ); +}); + +test('first review population does not depend on one visibility event', () => { + assert.ok( + reviewTreeSource.includes('private ensurePopulated()'), + 'reviewTree must expose a single-shot population guard' + ); + + const getChildrenIndex = reviewTreeSource.indexOf('public getChildren('); + assert.ok(getChildrenIndex > 0, 'reviewTree must implement getChildren'); + + const ensureIndex = reviewTreeSource.indexOf( + 'this.ensurePopulated()', + getChildrenIndex + ); + assert.ok( + ensureIndex > getChildrenIndex && ensureIndex - getChildrenIndex < 400, + 'getChildren must populate on first render, since rendering is the one ' + + 'signal that cannot be missed' + ); +});