299 lines
6.8 KiB
Vue
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>
|