Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified assets/screenshots/lookout-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 8 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/reviewAnchor.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 34 additions & 5 deletions src/reviewTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type WorkspaceChange
} from './gitReview';
import type { SessionManager } from './sessionManager';
import { anchoredSessions } from './reviewAnchor';
import {
boundedReviewItemLimit,
normalizeReviewGlobs,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions test/reviewAnchor.test.ts
Original file line number Diff line number Diff line change
@@ -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), []);
});
71 changes: 71 additions & 0 deletions test/reviewViewActivation.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});