From 4e7be79926792da9483947a832a875f970f7bac2 Mon Sep 17 00:00:00 2001 From: Hubdev Date: Wed, 14 Oct 2026 03:31:05 +0000 Subject: [PATCH] feat(chat): collapse log messages --- frontend/src/components/Message.vue | 190 +++++++++++++++++++++++++++- frontend/src/views/Chat.vue | 30 ++++- 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue index 683ce3f..44ee041 100644 --- a/frontend/src/components/Message.vue +++ b/frontend/src/components/Message.vue @@ -2,6 +2,7 @@ import { ref, computed, watch } from 'vue'; import { marked } from "marked"; import * as backend from '../backend'; +import * as iconoir from '@iconoir/vue'; marked.use({ renderer: { code ({lang, text}) { @@ -33,6 +34,12 @@ const props = defineProps({ logExpanded: { type: Boolean, required: false, + }, + // Toggle set of expanded message ids, shared with the parent view. + // Used by log-groups to expand/collapse the previewed log. + expandedLogs: { + type: Object, + required: false, } }); @@ -45,6 +52,8 @@ function chatEntryClass(msg) { return ['resource', 'resource-' + (msg.subkind || 'generic')]; } else if (msg.type == 'log') { return ['log']; + } else if (msg.type == 'log-group') { + return ['log-group']; } else if (msg.type == 'thread-header') { let cls = ['thread']; if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed') @@ -108,10 +117,106 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte } }, {immediate: true}) + +// === Log groups === // +// A log-group collects consecutive log messages. Each one is shown as a square +// icon (which selects it as the preview); the selected log is previewed below. + +const logs = computed(() => + props.message.type == 'log-group' ? (props.message.logs || []) : [] +); + +// Icon used for a log's square button, chosen by log subkind +function logIcon(subkind) { + if (subkind == 'reasoning') return iconoir.Brain; + if (subkind == 'tool-call') return iconoir.Wrench; + return iconoir.Terminal; +} + +// Null means "default to the last log in the group" +const selectedLogId = ref(null); + +const selectedLog = computed(() => { + const arr = logs.value; + if (!arr.length) return null; + + if (selectedLogId.value != null) { + const found = arr.find(l => l.id == selectedLogId.value); + if (found) return found; + } + + return arr[arr.length - 1]; +}); + +// Streamed logs resolve their content through a shared per-stream cache, so +// switching the preview between logs still shows up-to-date content. +const boundStreams = new Set(); +const streamContents = ref({}); + +function bindLogStreams() { + for (const log of logs.value) { + if (log.format != 'stream') continue; + + const streamId = parseInt(log.content); + if (boundStreams.has(streamId)) continue; + boundStreams.add(streamId); + + const stream = backend.readStream(streamId); + const update = () => { + streamContents.value = { ...streamContents.value, [streamId]: stream.partialContent }; + }; + update(); + stream.on('data', update); + } +} + +watch(logs, bindLogStreams, { immediate: true }); + +const selectedLogText = computed(() => { + const log = selectedLog.value; + if (!log) return ''; + if (log.format == 'stream') return streamContents.value[parseInt(log.content)] ?? ''; + return log.content ?? ''; +}); + +const groupLogLong = computed(() => { + if (!selectedLog.value) return false; + const lines = String(selectedLogText.value ?? '').split('\n'); + return lines.length > LOG_MAX_LINES || lines.some(line => line.length > LOG_MAX_LENGTH); +}); + +const groupExpanded = computed(() => + selectedLog.value != null && !!props.expandedLogs?.has(selectedLog.value.id) +); + +// Collapsed preview keeps the *last* N characters (the tail), not the head; +// CSS then clips the top so only the bottom lines show. +const groupLogContent = computed(() => { + const text = String(selectedLogText.value ?? ''); + if (groupLogLong.value && !groupExpanded.value) { + return text.slice(-(LOG_MAX_LENGTH * LOG_MAX_LINES)); + } + return text; +}); + +const groupLogMeta = computed(() => groupLogLong.value ? {expanded: groupExpanded.value} : null); + +function toggleGroupLog() { + if (selectedLog.value) props.expandedLogs?.toggle(selectedLog.value.id); +} + +function onContextMenu() { + if (props.message.type == 'log-group') { + if (selectedLog.value) emit('open-menu', selectedLog.value, groupLogMeta.value); + } else { + emit('open-menu', props.message, logMeta.value); + } +} +