Skip to content

Commit c6e9cb1

Browse files
staticoclaude
andcommitted
Fix chat selection and node navigation bugs
- Chat: Keep viewport stable when entering selection mode (no more viewport jumping on first up-arrow press). Viewport stays anchored at bottom until selection scrolls above visible area. - Chat: Clamp selectedMessageIndex to prevent selecting past the last message when message count changes between renders. - Nodes: Add tiebreaker (by node number) to sort for deterministic ordering, fixing 'n' key sometimes navigating to wrong node. - Nodes: Set selectedNodeNumRef directly in navigateToNode so the selection preservation effect always targets the correct node. - Nodes: Update ref when clamping after a node disappears from the filtered list. - Remove unused selectedNode variable (was indexing unsorted array). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be03ca0 commit c6e9cb1

2 files changed

Lines changed: 63 additions & 50 deletions

File tree

src/ui/App.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,7 +1843,8 @@ export function App({ address, packetStore, nodeStore, skipConfig = false, skipN
18431843
cmp = (b.isFavorite ? 1 : 0) - (a.isFavorite ? 1 : 0);
18441844
break;
18451845
}
1846-
return sortAscending ? cmp : -cmp;
1846+
const primary = sortAscending ? cmp : -cmp;
1847+
return primary !== 0 ? primary : a.num - b.num;
18471848
});
18481849
}, []);
18491850

@@ -1860,6 +1861,8 @@ export function App({ address, packetStore, nodeStore, skipConfig = false, skipN
18601861
if (nodeIndex >= 0) {
18611862
setMode("nodes");
18621863
setSelectedNodeIndex(nodeIndex);
1864+
// Set ref directly so the selection preservation effect targets the right node
1865+
selectedNodeNumRef.current = nodeNum;
18631866
} else {
18641867
showNotification("Node not found in list", theme.status.offline);
18651868
}
@@ -1889,8 +1892,14 @@ export function App({ address, packetStore, nodeStore, skipConfig = false, skipN
18891892
if (newIndex >= 0) {
18901893
setSelectedNodeIndex(newIndex);
18911894
} else {
1892-
// Node disappeared (filtered out) — clamp to valid range
1893-
setSelectedNodeIndex(i => Math.min(i, filteredNodes.length - 1));
1895+
// Node disappeared (filtered out) — clamp to valid range and update ref
1896+
setSelectedNodeIndex(i => {
1897+
const clamped = Math.min(i, filteredNodes.length - 1);
1898+
if (filteredNodes[clamped]) {
1899+
selectedNodeNumRef.current = filteredNodes[clamped].num;
1900+
}
1901+
return clamped;
1902+
});
18941903
}
18951904
}
18961905
}, [filteredNodes]);
@@ -3043,7 +3052,6 @@ export function App({ address, packetStore, nodeStore, skipConfig = false, skipN
30433052
});
30443053

30453054
const selectedPacket = packets[selectedPacketIndex];
3046-
const selectedNode = nodes[selectedNodeIndex];
30473055

30483056
const getModeLabel = () => {
30493057
const p = mode === "packets";

src/ui/components/ChatPanel.tsx

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -153,59 +153,64 @@ function ChatPanelComponent({
153153
return lineCount || 1; // Ensure at least 1 line
154154
};
155155

156+
// Clamp selection to valid range
157+
const clampedSelection = selectedMessageIndex >= 0
158+
? Math.min(selectedMessageIndex, filteredMessages.length - 1)
159+
: -1;
160+
156161
// Calculate visible messages based on actual line heights
157162
const visibleMessages: DbMessage[] = [];
158163
let scrollOffset = 0;
159-
let totalLines = 0;
160-
161-
if (selectedMessageIndex < 0) {
162-
// No selection - show most recent messages that fit
163-
let linesUsed = 0;
164-
for (let i = filteredMessages.length - 1; i >= 0; i--) {
165-
const msgHeight = getMessageHeight(filteredMessages[i]);
166-
if (linesUsed + msgHeight <= messageAreaHeight) {
167-
visibleMessages.unshift(filteredMessages[i]);
168-
linesUsed += msgHeight;
169-
scrollOffset = i;
164+
165+
// Compute bottom-anchored view (what's visible when showing most recent messages)
166+
let bottomLinesUsed = 0;
167+
let bottomViewStart = filteredMessages.length;
168+
for (let i = filteredMessages.length - 1; i >= 0; i--) {
169+
const h = getMessageHeight(filteredMessages[i]);
170+
if (bottomLinesUsed + h <= messageAreaHeight) {
171+
bottomLinesUsed += h;
172+
bottomViewStart = i;
173+
} else {
174+
break;
175+
}
176+
}
177+
178+
if (clampedSelection < 0 || clampedSelection >= bottomViewStart) {
179+
// No selection, or selection is within the bottom view — show bottom-anchored view
180+
for (let i = bottomViewStart; i < filteredMessages.length; i++) {
181+
visibleMessages.push(filteredMessages[i]);
182+
}
183+
scrollOffset = bottomViewStart;
184+
} else {
185+
// Selection is above the bottom view — scroll up to keep it visible
186+
// Show selected message near the bottom third with context above
187+
visibleMessages.push(filteredMessages[clampedSelection]);
188+
let linesUsed = getMessageHeight(filteredMessages[clampedSelection]);
189+
scrollOffset = clampedSelection;
190+
191+
// Fill below (newer messages for context, up to ~1/3 of viewport)
192+
const maxBelowLines = Math.floor(messageAreaHeight / 3);
193+
let belowLines = 0;
194+
for (let i = clampedSelection + 1; i < filteredMessages.length; i++) {
195+
const h = getMessageHeight(filteredMessages[i]);
196+
if (belowLines + h <= maxBelowLines && linesUsed + h <= messageAreaHeight) {
197+
visibleMessages.push(filteredMessages[i]);
198+
belowLines += h;
199+
linesUsed += h;
170200
} else {
171201
break;
172202
}
173203
}
174-
} else {
175-
// Try to center the selected message
176-
scrollOffset = Math.max(0, selectedMessageIndex);
177-
let linesUsed = 0;
178-
179-
// Add selected message first
180-
if (scrollOffset < filteredMessages.length) {
181-
visibleMessages.push(filteredMessages[scrollOffset]);
182-
linesUsed += getMessageHeight(filteredMessages[scrollOffset]);
183-
}
184204

185-
// Add messages before and after alternately to center
186-
let before = scrollOffset - 1;
187-
let after = scrollOffset + 1;
188-
while ((before >= 0 || after < filteredMessages.length) && linesUsed < messageAreaHeight) {
189-
if (after < filteredMessages.length) {
190-
const msgHeight = getMessageHeight(filteredMessages[after]);
191-
if (linesUsed + msgHeight <= messageAreaHeight) {
192-
visibleMessages.push(filteredMessages[after]);
193-
linesUsed += msgHeight;
194-
after++;
195-
} else {
196-
break;
197-
}
198-
}
199-
if (before >= 0) {
200-
const msgHeight = getMessageHeight(filteredMessages[before]);
201-
if (linesUsed + msgHeight <= messageAreaHeight) {
202-
visibleMessages.unshift(filteredMessages[before]);
203-
linesUsed += msgHeight;
204-
scrollOffset = before;
205-
before--;
206-
} else if (after >= filteredMessages.length) {
207-
break;
208-
}
205+
// Fill above with remaining space
206+
for (let i = clampedSelection - 1; i >= 0; i--) {
207+
const h = getMessageHeight(filteredMessages[i]);
208+
if (linesUsed + h <= messageAreaHeight) {
209+
visibleMessages.unshift(filteredMessages[i]);
210+
linesUsed += h;
211+
scrollOffset = i;
212+
} else {
213+
break;
209214
}
210215
}
211216
}
@@ -304,7 +309,7 @@ function ChatPanelComponent({
304309
message={msg}
305310
nodeStore={nodeStore}
306311
isOwn={msg.fromNode === myNodeNum}
307-
isSelected={actualIndex === selectedMessageIndex && !inputFocused}
312+
isSelected={actualIndex === clampedSelection && !inputFocused}
308313
width={width}
309314
meshViewConfirmedIds={meshViewConfirmedIds}
310315
allMessages={messages}

0 commit comments

Comments
 (0)