@@ -3,7 +3,10 @@ import SwiftUI
3struct LiveTailView: View {
3struct LiveTailView: View {
4 @Environment(AppState.self) private var appState
4 @Environment(AppState.self) private var appState
5 @State private var viewModel = LiveTailViewModel()
5 @State private var viewModel = LiveTailViewModel()
6+ /// Selection is tracked by entry id, not by record value: live-tail rows
7+ /// frequently share identical content (repeated log lines), so comparing by
8+ /// record value would select/toggle every matching row at once.
6− @State private var selectedRecord: LogRecord?
9+ @State private var selectedEntryID: UUID?
7 @State private var autoScroll = true
10 @State private var autoScroll = true
8 @State private var pulseOpacity: Double = 1.0
11 @State private var pulseOpacity: Double = 1.0
9 @State private var showColumnPopover = false
12 @State private var showColumnPopover = false
@@ -112,7 +115,7 @@ struct LiveTailView: View {
114 viewModel.clear()
117 viewModel.clear()
115− selectedRecord = nil
118+ selectedEntryID = nil
116 sortColumn = nil
119 sortColumn = nil
117 columnWidths = [:]
120 columnWidths = [:]
118 cachedSorted = []
121 cachedSorted = []
@@ -250,8 +253,9 @@ struct LiveTailView: View {
250 .frame(minWidth: 400)
253 .frame(minWidth: 400)
252 // Detail pane
255 // Detail pane
253− if let record = selectedRecord {
256+ if let id = selectedEntryID,
257+ let entry = cachedSorted.first(where: { $0.id == id }) {
254− LogDetailView(record: record)
258+ LogDetailView(record: entry.record)
255 .frame(minWidth: 280, idealWidth: 320)
259 .frame(minWidth: 280, idealWidth: 320)
@@ -263,7 +267,7 @@ struct LiveTailView: View {
263 viewModel.stop()
267 viewModel.stop()
265 viewModel.clear()
269 viewModel.clear()
266− selectedRecord = nil
270+ selectedEntryID = nil
267 sortColumn = nil
271 sortColumn = nil
268 columnWidths = [:]
272 columnWidths = [:]
269 cachedSorted = []
273 cachedSorted = []
@@ -337,7 +341,7 @@ struct LiveTailView: View {
337 record: entry.record,
341 record: entry.record,
338 columns: viewModel.visibleColumns,
342 columns: viewModel.visibleColumns,
339 columnWidths: columnWidths,
343 columnWidths: columnWidths,
340− isSelected: selectedRecord == entry.record,
344+ isSelected: selectedEntryID == entry.id,
341 isAlternate: index % 2 == 1,
345 isAlternate: index % 2 == 1,
342 severity: severityByID[entry.id] ?? .unknown,
346 severity: severityByID[entry.id] ?? .unknown,
343 severityColumns: severityColumnSet,
347 severityColumns: severityColumnSet,
@@ -347,10 +351,10 @@ struct LiveTailView: View {
349 .onTapGesture {
353 .onTapGesture {
350− if selectedRecord == entry.record {
354+ if selectedEntryID == entry.id {
351− selectedRecord = nil
355+ selectedEntryID = nil
353− selectedRecord = entry.record
357+ selectedEntryID = entry.id
@@ -381,9 +385,11 @@ struct LiveTailView: View {
381 rebuildSortedEntries()
385 rebuildSortedEntries()
383 // Scroll when the rendered list (`cachedSorted`) changes, targeting
387 // Scroll when the rendered list (`cachedSorted`) changes, targeting
384− // its last element. Keying off `entries.count` could fire before the
388+ // its last element. Key off the last element's id rather than the
385− // rebuild, scrolling to an id not yet present (a silent no-op).
389+ // count: once the buffer is pinned at `maxEntries`, the count stops
390+ // changing while new rows keep arriving, so a count-based trigger
391+ // would silently stall auto-scroll.
386− .onChange(of: cachedSorted.count) { _, _ in
392+ .onChange(of: cachedSorted.last?.id) { _, _ in
387 guard autoScroll, sortColumn == nil else { return }
393 guard autoScroll, sortColumn == nil else { return }
388 if let target = cachedSorted.last {
394 if let target = cachedSorted.last {
389 proxy.scrollTo(target.id, anchor: .bottom)
395 proxy.scrollTo(target.id, anchor: .bottom)