Fix tree selector focus behavior (#1142)

* fix(coding-agent): tree selector focuses nearest visible ancestor

When the selected entry is not visible (filtered out by mode change or a
metadata entry like model_change), walk up the parent chain to find the
nearest visible ancestor instead of jumping to the last item.

Fixes selection behavior for:

- Initial selection when currentLeafId is a metadata entry
- Filter switching, e.g. Ctrl+U for user-only mode

* fix(coding-agent): tree selector preserves selection through empty filters

When switching to a filter with no results, e.g. labeled-only with no
labels and back, the cursor would reset to the first message instead of
the original selection.

Track lastSelectedId as a class member and only update it when
filteredNodes is non-empty, preserving the selection across empty filter
results.

* test(coding-agent): add tree selector filter and selection tests

- Test metadata entry handling (model_change, thinking_level_change)
- Test filter switching with parent traversal (default ↔ user-only)
- Test empty filter preservation (labeled-only with no labels)
This commit is contained in:
Sviatoslav Abakumov 2026-02-01 16:23:52 +04:00 committed by GitHub
parent 43be54c237
commit 4ca7bbe450
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 332 additions and 29 deletions

View file

@ -59,6 +59,7 @@ class TreeList implements Component {
private toolCallMap: Map<string, ToolCallInfo> = new Map();
private multipleRoots = false;
private activePathIds: Set<string> = new Set();
private lastSelectedId: string | null = null;
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
@ -79,12 +80,38 @@ class TreeList implements Component {
// Start with initialSelectedId if provided, otherwise current leaf
const targetId = initialSelectedId ?? currentLeafId;
const targetIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === targetId);
if (targetIndex !== -1) {
this.selectedIndex = targetIndex;
} else {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
this.selectedIndex = this.findNearestVisibleIndex(targetId);
this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null;
}
/**
* Find the index of the nearest visible entry, walking up the parent chain if needed.
* Returns the index in filteredNodes, or the last index as fallback.
*/
private findNearestVisibleIndex(entryId: string | null): number {
if (this.filteredNodes.length === 0) return 0;
// Build a map for parent lookup
const entryMap = new Map<string, FlatNode>();
for (const flatNode of this.flatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Build a map of visible entry IDs to their indices in filteredNodes
const visibleIdToIndex = new Map<string, number>(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));
// Walk from entryId up to root, looking for a visible entry
let currentId = entryId;
while (currentId !== null) {
const index = visibleIdToIndex.get(currentId);
if (index !== undefined) return index;
const node = entryMap.get(currentId);
if (!node) break;
currentId = node.node.entry.parentId ?? null;
}
// Fallback: last visible entry
return this.filteredNodes.length - 1;
}
/** Build the set of entry IDs on the path from root to current leaf */
@ -239,8 +266,11 @@ class TreeList implements Component {
}
private applyFilter(): void {
// Remember currently selected node to preserve cursor position
const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
// Update lastSelectedId only when we have a valid selection (non-empty list)
// This preserves the selection when switching through empty filter results
if (this.filteredNodes.length > 0) {
this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;
}
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
@ -306,18 +336,17 @@ class TreeList implements Component {
// Recalculate visual structure (indent, connectors, gutters) based on visible tree
this.recalculateVisualStructure();
// Try to preserve cursor on the same node after filtering
if (previouslySelectedId) {
const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId);
if (newIndex !== -1) {
this.selectedIndex = newIndex;
return;
}
// Try to preserve cursor on the same node, or find nearest visible ancestor
if (this.lastSelectedId) {
this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId);
} else if (this.selectedIndex >= this.filteredNodes.length) {
// Clamp index if out of bounds
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
// Fall back: clamp index if out of bounds
if (this.selectedIndex >= this.filteredNodes.length) {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
// Update lastSelectedId to the actual selection (may have changed due to parent walk)
if (this.filteredNodes.length > 0) {
this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;
}
}

View file

@ -3352,15 +3352,6 @@ export class InteractiveMode {
const tree = this.sessionManager.getTree();
const realLeafId = this.sessionManager.getLeafId();
// Find the visible leaf for display (skip metadata entries like labels)
let visibleLeafId = realLeafId;
while (visibleLeafId) {
const entry = this.sessionManager.getEntry(visibleLeafId);
if (!entry) break;
if (entry.type !== "label" && entry.type !== "custom") break;
visibleLeafId = entry.parentId ?? null;
}
if (tree.length === 0) {
this.showStatus("No entries in session");
return;
@ -3369,11 +3360,11 @@ export class InteractiveMode {
this.showSelector((done) => {
const selector = new TreeSelectorComponent(
tree,
visibleLeafId,
realLeafId,
this.ui.terminal.rows,
async (entryId) => {
// Selecting the visible leaf is a no-op (already there)
if (entryId === visibleLeafId) {
// Selecting the current leaf is a no-op (already there)
if (entryId === realLeafId) {
done();
this.showStatus("Already at this point");
return;