@@ -59,7 +59,9 @@ data class SavedFiltersState(
5959data class LogViewerState(
6060 val streamName: String = "",
6161 val logs: List<JsonObject> = emptyList(),
62+ val logKeys: List<String> = emptyList(),
6263 val columns: List<String> = emptyList(),
64+ val searchableColumns: List<String> = emptyList(),
6365 val isLoading: Boolean = false,
6466 val error: String? = null,
6567 val selectedTimeRange: TimeRange = TimeRange.LAST_1H,
@@ -108,6 +110,23 @@ class LogViewerViewModel @Inject constructor(
108110 private const val LOAD_MORE_INCREMENT = 500
109111 private const val MAX_STREAMING_ERRORS = 5
110112 private val ALLOWED_OPERATORS = setOf("=", "!=", "LIKE", "ILIKE", ">", "<", ">=", "<=", "IS NULL", "IS NOT NULL")
113+
114+ /**
115+ * Produces deterministic keys for a list of log entries so that expanded state
116+ * survives list mutations (e.g., new logs prepended during streaming).
117+ */
118+ internal fun computeLogKeys(logs: List<JsonObject>): List<String> {
119+ val seen = mutableMapOf<String, Int>()
120+ return logs.map { log ->
121+ val ts = log["p_timestamp"]?.toString()
122+ val meta = log["p_metadata"]?.toString()
123+ val tag = log["p_tags"]?.toString()
124+ val base = if (ts != null) "$ts|${meta.orEmpty()}|${tag.orEmpty()}" else log.hashCode().toString()
125+ val count = seen.getOrDefault(base, 0)
126+ seen[base] = count + 1
127+ if (count == 0) base else "$base|$count"
128+ }
129+ }
111130 }
112131
113132 fun initialize(streamName: String) {
@@ -124,7 +143,8 @@ class LogViewerViewModel @Inject constructor(
124143 when (val result = repository.getStreamSchema(streamName)) {
125144 is ApiResult.Success -> {
126145 val columns = result.data.fields.map { it.name }.sorted()
127− _state.update { it.copy(columns = columns) }
146+ val searchable = columns.filter { !it.startsWith("p_") }
147+ _state.update { it.copy(columns = columns, searchableColumns = searchable) }
128148 }
129149 is ApiResult.Error -> { /* Schema loading failure is non-fatal */ }
130150 }
@@ -236,9 +256,11 @@ class LogViewerViewModel @Inject constructor(
236256 val (startTime, endTime) = getTimeRange()
237257 when (val result = repository.queryLogsRaw(safeSql, startTime, endTime)) {
238258 is ApiResult.Success -> {
259+ val keys = computeLogKeys(result.data)
239260 _state.update {
240261 it.copy(
241262 logs = result.data,
263+ logKeys = keys,
242264 isLoading = false,
243265 hasMore = result.data.size >= it.currentLimit,
244266 )
@@ -253,13 +275,11 @@ class LogViewerViewModel @Inject constructor(
253275 }
254276 }
255277
256− private fun buildSearchClause(columns: List<String>, searchQuery: String): String? {
278+ private fun buildSearchClause(searchableColumns: List<String>, searchQuery: String): String? {
257279 if (searchQuery.isBlank()) return null
280+ if (searchableColumns.isEmpty()) return null
258281 val safeSearch = escapeSql(searchQuery)
259− // Search across ALL non-internal columns (not just 5)
260− val searchFields = columns.filter { !it.startsWith("p_") }
261− if (searchFields.isEmpty()) return null
262− return searchFields.joinToString(" OR ") {
282+ return searchableColumns.joinToString(" OR ") {
263283 "\"${escapeIdentifier(it)}\" ILIKE '%$safeSearch%'"
264284 }
265285 }
@@ -283,7 +303,7 @@ class LogViewerViewModel @Inject constructor(
283303 // Build WHERE clause from filters + search
284304 val clauses = current.filters.filterClauses.toMutableList()
285305 if (current.filters.searchQuery.isNotBlank()) {
286− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
306+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
287307 if (searchClause != null) {
288308 clauses.add("($searchClause)")
289309 } else {
@@ -304,9 +324,11 @@ class LogViewerViewModel @Inject constructor(
304324 limit = current.currentLimit,
305325 )) {
306326 is ApiResult.Success -> {
327+ val keys = computeLogKeys(result.data)
307328 _state.update {
308329 it.copy(
309330 logs = result.data,
331+ logKeys = keys,
310332 isLoading = false,
311333 hasMore = result.data.size >= it.currentLimit,
312334 )
@@ -415,7 +437,7 @@ class LogViewerViewModel @Inject constructor(
415437 // Build WHERE clause from active filters + search
416438 val clauses = current.filters.filterClauses.toMutableList()
417439 if (current.filters.searchQuery.isNotBlank()) {
418− val searchClause = buildSearchClause(current.columns, current.filters.searchQuery)
440+ val searchClause = buildSearchClause(current.searchableColumns, current.filters.searchQuery)
419441 if (searchClause != null) {
420442 clauses.add(searchClause)
421443 }
@@ -441,15 +463,16 @@ class LogViewerViewModel @Inject constructor(
441463 }
442464
443465 _state.update { state ->
444− // Prepend new logs, cap total
445− val combined = newLogs + state.logs
446− val capped = if (combined.size > STREAMING_MAX_LOGS) {
447− combined.take(STREAMING_MAX_LOGS)
448− } else {
449− combined
466+ // Prepend new logs, cap total — avoid full intermediate list allocation
467+ val remaining = (STREAMING_MAX_LOGS - newLogs.size).coerceAtLeast(0)
468+ val capped = ArrayList<JsonObject>(newLogs.size + remaining).apply {
469+ addAll(newLogs)
470+ val oldLogs = state.logs
471+ addAll(oldLogs.subList(0, remaining.coerceAtMost(oldLogs.size)))
450472 }
451473 state.copy(
452474 logs = capped,
475+ logKeys = computeLogKeys(capped),
453476 streaming = state.streaming.copy(
454477 streamingNewCount = state.streaming.streamingNewCount + newLogs.size,
455478 streamingError = null,