discovery-by-llm/frontend/src/components/DevPanel.vue
Justin Visser a3af8f45cc
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
fix(frontend): harden the streaming ui and extract copy and constants
2026-08-10 15:42:18 +02:00

299 lines
6.8 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { messages } from '../lib/messages'
import type { AssistantTurn, EventLogEntry } from '../lib/models'
const props = defineProps<{
latest: AssistantTurn | undefined
log: EventLogEntry[]
opener: HTMLElement | null
}>()
const emit = defineEmits<{ close: []; reset: [] }>()
const panel = ref<HTMLElement | null>(null)
const closeButton = ref<HTMLButtonElement | null>(null)
function latestValue(value: string | null | undefined): string {
return value ?? messages.devPanelWaiting
}
const rows = computed(() => [
{ label: messages.devPanelRequestId, value: latestValue(props.latest?.requestId) },
{
label: messages.devPanelCandidateCount,
value: latestValue(props.latest?.candidateCount?.toString()),
},
{
label: messages.devPanelTrackCount,
value: latestValue(
props.latest?.completion?.track_count.toString() ?? props.latest?.tracks.length.toString(),
),
},
{
label: messages.devPanelTotalMilliseconds,
value: latestValue(props.latest?.completion?.total_ms.toString()),
},
])
const entries = computed(() => {
if (props.log.length) return [...props.log].reverse()
return [
{
timestamp: '--:--:--',
type: messages.devPanelIdle,
detail: messages.devPanelWaitingForRequest,
},
]
})
function focusableElements(): HTMLElement[] {
if (!panel.value) return []
return Array.from(
panel.value.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
)
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
if (event.key !== 'Tab') return
const focusable = focusableElements()
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last?.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first?.focus()
}
}
onMounted(async () => {
await nextTick()
closeButton.value?.focus()
})
onUnmounted(() => {
const fallback = document.querySelector<HTMLElement>('[data-dev-panel-opener]')
const target = props.opener?.isConnected ? props.opener : fallback
target?.focus()
})
</script>
<template>
<div class="backdrop" @click.self="$emit('close')">
<aside
ref="panel"
class="panel"
role="dialog"
aria-modal="true"
aria-labelledby="dev-panel-title"
@keydown="onKeydown"
>
<div class="bar">
<h2 id="dev-panel-title" class="caps">{{ messages.devPanelTitle }}</h2>
<button
ref="closeButton"
class="close"
type="button"
:aria-label="messages.devPanelCloseLabel"
@click="$emit('close')"
>
<span aria-hidden="true">{{ messages.devPanelCloseSymbol }}</span>
</button>
</div>
<div class="body">
<section>
<h3 class="caps">{{ messages.devPanelLatestRequest }}</h3>
<p class="caption">{{ messages.devPanelContractCaption }}</p>
<div class="table">
<div v-for="row in rows" :key="row.label" class="row">
<span class="key">{{ row.label }}</span
><span class="value">{{ row.value }}</span>
</div>
</div>
</section>
<section>
<h3 class="caps section-heading">{{ messages.devPanelConversation }}</h3>
<button class="reset" type="button" @click="$emit('reset')">
{{ messages.devPanelClearConversation }}
</button>
</section>
<section>
<h3 class="caps section-heading">{{ messages.devPanelEventStream }}</h3>
<div class="log">
<div v-for="(entry, index) in entries" :key="index" class="entry">
<span class="time">{{ entry.timestamp }}</span>
<span class="type">{{ entry.type }}</span>
<span class="detail" :title="entry.detail">{{ entry.detail }}</span>
</div>
</div>
</section>
</div>
</aside>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.5);
}
.panel {
position: absolute;
inset: 0 0 0 auto;
display: flex;
flex-direction: column;
width: var(--dev-width);
max-width: 100vw;
overflow-y: auto;
background: var(--c-dev-bg);
border-left: 1px solid var(--c-line);
box-shadow: var(--shadow-drawer);
}
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--s-6) var(--s-7);
border-bottom: 1px solid var(--c-line);
}
.caps {
margin: 0;
color: var(--c-text-muted);
font-family: var(--f-mono);
font-size: var(--t-micro);
font-weight: 400;
letter-spacing: var(--ls-caps);
text-transform: uppercase;
}
.section-heading {
margin-bottom: var(--s-4);
}
.close {
min-width: var(--tap-min);
min-height: var(--tap-min);
color: var(--c-text-faint);
font-family: var(--f-mono);
font-size: var(--t-body);
line-height: 1;
cursor: pointer;
background: none;
border: 0;
}
.body {
display: flex;
flex-direction: column;
gap: var(--s-8);
padding: var(--s-7);
}
.caption {
margin: var(--s-1) 0 var(--s-5);
color: var(--c-text-dim);
font-size: 12.5px;
}
.table {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
background: var(--c-line);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.row {
display: flex;
gap: var(--s-5);
justify-content: space-between;
padding: 9px var(--s-5);
font-family: var(--f-mono);
font-size: var(--t-meta);
background: var(--c-surface);
}
.key {
color: var(--c-text-dim);
}
.value {
max-width: 55%;
overflow: hidden;
color: var(--c-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.reset {
width: 100%;
min-height: var(--tap-min);
padding: 11px var(--s-5);
color: var(--c-text-chip);
font-size: 13px;
text-align: left;
cursor: pointer;
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-md);
}
.reset:hover {
color: var(--c-text);
border-color: var(--c-accent);
}
.log {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 260px;
overflow-y: auto;
font-family: var(--f-mono);
font-size: var(--t-micro);
}
.entry {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: var(--s-4);
}
.time {
color: var(--c-text-ghost);
}
.type {
color: var(--c-accent);
}
.detail {
min-width: 0;
overflow: hidden;
color: var(--c-text-dim);
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 560px) {
.panel {
width: 100vw;
}
}
</style>