ui and tiltfile tweaks, update docs

This commit is contained in:
2026-06-03 14:57:25 -03:00
parent be09fcde2c
commit 05fe7000c5
6 changed files with 531 additions and 387 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Panel } from 'soleprint-ui'
import { nextTick, ref, watch } from 'vue'
import { Panel, SplitPane } from 'soleprint-ui'
import { useRunStream, type GraphNode, type ToolCall } from '../composables/useRunStream'
const question = ref('How many loans are currently in debt (status D)?')
@@ -27,11 +27,6 @@ function toggleNode(id: string) {
expanded.value = s
}
// Trace pane grows to 2/3 when the run gets visually dense.
const traceDense = computed(() =>
graphNodes.value.length > 4 || toolCalls.value.length > 6,
)
watch(() => log.value.length, async () => {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
@@ -53,26 +48,30 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
return 'idle'
}
function focusNode(id: string | null) {
if (!id) return
if (!expanded.value.has(id)) toggleNode(id)
// Soft-link: scroll an element into view and flash it. The DOM access lives in
// script (not the template) — Vue templates don't expose `document` as a global.
function flashInto(elementId: string) {
nextTick(() => {
const el = document.getElementById(`node-${id}`)
const el = document.getElementById(elementId)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
}
function focusNode(id: string | null) {
if (!id) return
if (!expanded.value.has(id)) toggleNode(id)
flashInto(`node-${id}`)
}
function focusTool(index: number) {
if (index < 0) return
flashInto(`tc-${index}`)
}
function focusFirstTool(id: string) {
const first = toolCalls.value.findIndex(c => c.analysisId === id)
if (first < 0) return
nextTick(() => {
const el = document.getElementById(`tc-${first}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
focusTool(toolCalls.value.findIndex(c => c.analysisId === id))
}
function toolIndex(call: ToolCall): number {
@@ -108,148 +107,162 @@ function nodeClasses(n: GraphNode) {
</script>
<template>
<div :class="['ask-layout', { dense: traceDense }]">
<!-- Left / top: ask + answer (the user's surface) -->
<div class="ask-pane">
<Panel title="Ask" :status="statusOf(status)">
<div class="ask-form">
<textarea v-model="question" rows="3" :disabled="isRunning"
placeholder="Ask a question about the financial warehouse…" />
<div class="ask-actions">
<span class="hint">{{ isRunning ? `running… ${runId}` : 'Examples:' }}</span>
<button class="run-btn" @click="submit" :disabled="isRunning || !question.trim()">
{{ isRunning ? 'Running' : 'Ask' }}
</button>
</div>
<div v-if="!isRunning" class="examples">
<button v-for="e in examples" :key="e" class="example" @click="pick(e)">
{{ e }}
</button>
</div>
</div>
</Panel>
<Panel title="Answer" :status="answer ? 'live' : (error ? 'error' : 'idle')">
<div v-if="error" class="error-block">{{ error }}</div>
<div v-else-if="answer" class="answer-block">{{ answer }}</div>
<div v-else-if="isRunning" class="muted">Composing…</div>
<div v-else class="muted">Ask a question to see the answer here.</div>
</Panel>
</div>
<!-- Right / bottom: live trace (the demo's surface) -->
<div class="trace-pane">
<Panel title="Plan" :status="statusOf(status)">
<div v-if="rationale" class="rationale">{{ rationale }}</div>
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span v-if="idx > 0 && n.kind !== 'fallback'" class="connector"></span>
<span v-else-if="n.kind === 'fallback'" class="connector fallback-arrow"> fallback</span>
<div
:id="`node-${n.id}`"
:class="nodeClasses(n)"
@click="isExpandable(n) && toggleNode(n.id)"
:role="isExpandable(n) ? 'button' : undefined"
:tabindex="isExpandable(n) ? 0 : undefined"
>
<div class="node-head">
<span v-if="isExpandable(n)" class="chev">
{{ expanded.has(n.id) ? '' : '' }}
</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.kind === 'analysis' && subEvents(n.id).length" class="step-count">
{{ subEvents(n.id).length }} call(s)
</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
<button v-if="n.kind === 'analysis' && subEvents(n.id).length"
class="goto" @click.stop="focusFirstTool(n.id)"
title="Jump to tool call list"></button>
</div>
<div v-if="expanded.has(n.id)" class="node-body">
<div v-if="n.detail" class="detail-full">{{ n.detail }}</div>
<div v-if="subEvents(n.id).length" class="subgraph">
<div class="subgraph-label">tool calls</div>
<div v-for="c in subEvents(n.id)" :key="toolIndex(c)"
:class="['sub-node', c.error ? 'error' : (c.t_ended ? 'done' : 'running')]"
@click.stop="document.getElementById(`tc-${toolIndex(c)}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })">
<span class="sub-dot" />
<span class="sub-tool">{{ c.tool }}</span>
<span class="sub-time">{{ c.t_ended ? `${c.t_ended - c.t_started}ms` : '…' }}</span>
<div class="ask-layout">
<!-- Main split: ask/answer (left) | live trace (right). Trace wider by default. -->
<SplitPane direction="horizontal" size-mode="ratio" :initial-size="1.4" anchor="second" :min="0.3" :max="3">
<!-- Left: ask + answer (the user's surface) -->
<template #first>
<SplitPane direction="vertical" size-mode="px" :initial-size="340" :min="120" :max="600">
<template #first>
<Panel title="Ask" :status="statusOf(status)">
<div class="pane-scroll">
<div class="ask-form">
<textarea v-model="question" rows="3" :disabled="isRunning"
placeholder="Ask a question about the financial warehouse…" />
<div class="ask-actions">
<span class="hint">{{ isRunning ? `running… ${runId}` : 'Examples:' }}</span>
<button class="run-btn" @click="submit" :disabled="isRunning || !question.trim()">
{{ isRunning ? 'Running' : 'Ask' }}
</button>
</div>
<div v-if="!isRunning" class="examples">
<button v-for="e in examples" :key="e" class="example" @click="pick(e)">
{{ e }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Panel>
</Panel>
</template>
<template #second>
<Panel title="Answer" :status="answer ? 'live' : (error ? 'error' : 'idle')">
<div class="pane-scroll">
<div v-if="error" class="error-block">{{ error }}</div>
<div v-else-if="answer" class="answer-block">{{ answer }}</div>
<div v-else-if="isRunning" class="muted">Composing…</div>
<div v-else class="muted">Ask a question to see the answer here.</div>
</div>
</Panel>
</template>
</SplitPane>
</template>
<Panel title="Tool calls" class="tools-panel">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<div v-for="(t, i) in toolCalls" :key="i"
:id="`tc-${i}`"
:class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div class="tool-head">
<span v-if="t.analysisId" class="tool-chip"
@click="focusNode(t.analysisId)"
:title="`Jump to ${t.analysisId} node`">
{{ t.analysisId }}
</span>
<span class="tool-name">· {{ t.tool }}</span>
<span class="tool-time">{{ t.t_ended ? `${t.t_ended - t.t_started}ms` : '…' }}</span>
</div>
<pre v-if="shortPreview(t)" class="tool-body sql">{{ shortPreview(t) }}</pre>
<pre v-if="t.output?.preview && t.output.preview.length" class="tool-body rows">{{ JSON.stringify(t.output.preview, null, 2) }}</pre>
<div v-if="t.error" class="tool-error">{{ t.error }}</div>
</div>
</Panel>
<!-- Right: live trace (the demo's surface) -->
<template #second>
<SplitPane direction="vertical" size-mode="ratio" :initial-size="0.8" :min="0.25" :max="3">
<template #first>
<Panel title="Plan" :status="statusOf(status)">
<div class="pane-scroll">
<div v-if="rationale" class="rationale">{{ rationale }}</div>
<Panel title="Event log" class="log-panel">
<template #actions>
<label class="wrap-toggle">
<input type="checkbox" v-model="wrapLog" />
wrap
</label>
</template>
<div ref="logEl" :class="['log', { wrap: wrapLog }]">
<div v-for="(e, i) in log" :key="i" :class="['log-row', e.level]">
<span class="log-ts">{{ e.ts }}</span>
<span class="log-level">{{ e.level }}</span>
<span class="log-stage">{{ e.stage }}</span>
<span class="log-msg">{{ e.msg }}</span>
</div>
<div v-if="log.length === 0" class="muted small">No events yet.</div>
</div>
</Panel>
</div>
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span v-if="idx > 0 && n.kind !== 'fallback'" class="connector"></span>
<span v-else-if="n.kind === 'fallback'" class="connector fallback-arrow"> fallback</span>
<div
:id="`node-${n.id}`"
:class="nodeClasses(n)"
@click="isExpandable(n) && toggleNode(n.id)"
:role="isExpandable(n) ? 'button' : undefined"
:tabindex="isExpandable(n) ? 0 : undefined"
>
<div class="node-head">
<span v-if="isExpandable(n)" class="chev">
{{ expanded.has(n.id) ? '' : '' }}
</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.kind === 'analysis' && subEvents(n.id).length" class="step-count">
{{ subEvents(n.id).length }} call(s)
</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
<button v-if="n.kind === 'analysis' && subEvents(n.id).length"
class="goto" @click.stop="focusFirstTool(n.id)"
title="Jump to tool call list"></button>
</div>
<div v-if="expanded.has(n.id)" class="node-body">
<div v-if="n.detail" class="detail-full">{{ n.detail }}</div>
<div v-if="subEvents(n.id).length" class="subgraph">
<div class="subgraph-label">tool calls</div>
<div v-for="c in subEvents(n.id)" :key="toolIndex(c)"
:class="['sub-node', c.error ? 'error' : (c.t_ended ? 'done' : 'running')]"
@click.stop="focusTool(toolIndex(c))">
<span class="sub-dot" />
<span class="sub-tool">{{ c.tool }}</span>
<span class="sub-time">{{ c.t_ended ? `${c.t_ended - c.t_started}ms` : '…' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Panel>
</template>
<template #second>
<SplitPane direction="vertical" size-mode="ratio" :initial-size="1" :min="0.25" :max="3">
<template #first>
<Panel title="Tool calls" class="tools-panel">
<div class="pane-scroll">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<div v-for="(t, i) in toolCalls" :key="i"
:id="`tc-${i}`"
:class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div class="tool-head">
<span v-if="t.analysisId" class="tool-chip"
@click="focusNode(t.analysisId)"
:title="`Jump to ${t.analysisId} node`">
{{ t.analysisId }}
</span>
<span class="tool-name">· {{ t.tool }}</span>
<span class="tool-time">{{ t.t_ended ? `${t.t_ended - t.t_started}ms` : '…' }}</span>
</div>
<pre v-if="shortPreview(t)" class="tool-body sql">{{ shortPreview(t) }}</pre>
<pre v-if="t.output?.preview && t.output.preview.length" class="tool-body rows">{{ JSON.stringify(t.output.preview, null, 2) }}</pre>
<div v-if="t.error" class="tool-error">{{ t.error }}</div>
</div>
</div>
</Panel>
</template>
<template #second>
<Panel title="Event log" class="log-panel">
<template #actions>
<label class="wrap-toggle">
<input type="checkbox" v-model="wrapLog" />
wrap
</label>
</template>
<div ref="logEl" :class="['log', { wrap: wrapLog }]">
<div v-for="(e, i) in log" :key="i" :class="['log-row', e.level]">
<span class="log-ts">{{ e.ts }}</span>
<span class="log-level">{{ e.level }}</span>
<span class="log-stage">{{ e.stage }}</span>
<span class="log-msg">{{ e.msg }}</span>
</div>
<div v-if="log.length === 0" class="muted small">No events yet.</div>
</div>
</Panel>
</template>
</SplitPane>
</template>
</SplitPane>
</template>
</SplitPane>
</div>
</template>
<style scoped>
.ask-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: calc(100vh - 80px);
min-height: 0;
transition: grid-template-columns 0.25s ease;
}
.ask-layout.dense { grid-template-columns: 1fr 2fr; }
.ask-pane, .trace-pane {
display: flex; flex-direction: column; gap: 12px;
overflow: auto; min-height: 0;
}
.trace-pane {
border-left: var(--panel-border);
padding-left: 16px;
}
.trace-pane > .tools-panel { flex: 1; min-height: 0; }
.trace-pane > .log-panel { flex: 1; min-height: 200px; }
/* Each Panel sits in a fixed-height SplitPane pane whose body is overflow:hidden;
this wrapper gives the panel content its own scroll region. */
.pane-scroll { height: 100%; overflow-y: auto; overflow-x: hidden; }
/* ── Ask form ── */
.ask-form { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
@@ -431,16 +444,4 @@ textarea {
.log-row.warn .log-level { color: var(--status-processing, #ffc107); }
.log-stage { color: #7ab0ff; }
.log-msg { color: var(--text-primary); }
/* ── Mobile: stack vertically ── */
@media (max-width: 900px) {
.ask-layout, .ask-layout.dense {
grid-template-columns: 1fr;
height: auto;
}
.trace-pane {
border-left: none; border-top: var(--panel-border);
padding-left: 0; padding-top: 16px;
}
}
</style>