A reload of a weeks-old session replayed the whole record before the first pixel, and every reconnect reset the mirror and replayed it again. The stream now names each frame's seq and the client keeps it, so a retry sends since=<seq> and receives only the gap, and the mirror is left alone: its items are still true. A first open with no cursor gets the tail, cut to a turn boundary, which paints the latest conversation at once. The turns above it come from the history route, one whole turn per page, fetched when the reader nears the top or when the transcript is too short to fill the screen; a page folds through the same reducer as the live stream and joins above, and the scroll position moves down by the page's height so the reader keeps their place. A reset comment (the record no longer holds the cursor) drops the mirror; the tail that follows rebuilds it. The dev proxy rebuilt the upstream URL from the pathname alone, so since and before never reached the daemon under nuxt dev: every resume was a full replay and every history page was the tail. It forwards the query string now.
994 lines
27 KiB
Vue
994 lines
27 KiB
Vue
<script setup lang="ts">
|
||
// The transcript: sent lines and streamed turns, the turn in flight at
|
||
// the bottom. The claw owns the conversation — this renders what the
|
||
// event stream delivered, nothing more. The composer never closes for
|
||
// a running turn: sending while one streams steers it — the claw cuts
|
||
// the turn and answers the message instead (rhema-code's shape). The
|
||
// header names the session and hides the dispatch internals behind an
|
||
// info modal; the composer chips mirror live claw state; the backend
|
||
// chip jumps to settings.
|
||
import {
|
||
PASTE_INLINE_LINES,
|
||
chipLabel,
|
||
embedPastes,
|
||
lineCount,
|
||
normalizePaste,
|
||
pasteParts,
|
||
} from '~/composables/useTranscript'
|
||
import type { ClawSession, ConsentAnswer, StreamStatus } from '~/composables/useClaw'
|
||
import type { PastePart, SentImage, StagedImage, TranscriptItem, TurnBlock } from '~/composables/useTranscript'
|
||
|
||
const props = defineProps<{
|
||
items: TranscriptItem[]
|
||
live: TurnBlock | null
|
||
activeKey: string
|
||
/** Only `interactive:*` keys accept messages (the claw 400s the rest). */
|
||
canSend: boolean
|
||
busy: boolean
|
||
session: ClawSession | null
|
||
activeBackend: { provider: string; model: string } | null
|
||
streamStatus: StreamStatus
|
||
/** An older page is in flight: the stream area shows it, so a slow
|
||
* claw reads as loading rather than as the top of the transcript. */
|
||
loadingOlder?: boolean
|
||
/** The record still holds turns above what the mirror shows. */
|
||
olderAvailable?: boolean
|
||
/** Bumped whenever a page was folded in above the mirror. */
|
||
prependCount?: number
|
||
identity?: { name: string | null; mark: string | null; initials: string | null } | null
|
||
avatarUrl?: string | null
|
||
}>()
|
||
|
||
const agentName = computed(() => props.identity?.name?.trim() || 'rhema')
|
||
|
||
/** One send as the panel hands it up: the text (pastes embedded) and
|
||
* the staged images, previews included for the optimistic row. */
|
||
interface SendPayload {
|
||
text: string
|
||
images: StagedImage[]
|
||
}
|
||
|
||
const emit = defineEmits<{
|
||
send: [message: SendPayload]
|
||
openProviders: []
|
||
consentAnswer: [id: string, answer: ConsentAnswer]
|
||
consentDeny: [id: string]
|
||
downloadAttachment: [id: string, name: string]
|
||
older: []
|
||
}>()
|
||
|
||
const draft = ref('')
|
||
/** The draft each session typed before the view moved on: the composer
|
||
* belongs to the session it types into, so a draft never sends into a
|
||
* session it was not written for. Staged attachments and pastes stay
|
||
* with the composer itself — they sit visible in the chips, so the
|
||
* user still sees them carry. */
|
||
const drafts = new Map<string, string>()
|
||
watch(
|
||
() => props.activeKey,
|
||
(key, previous) => {
|
||
if (previous !== undefined) drafts.set(previous, draft.value)
|
||
draft.value = drafts.get(key) ?? ''
|
||
void nextTick(resizePrompt)
|
||
},
|
||
)
|
||
const scroller = ref<HTMLElement | null>(null)
|
||
/** Pin to the bottom only while the reader is already there. */
|
||
const pinned = ref(true)
|
||
|
||
/** How near the top counts as asking for the turn above. */
|
||
const OLDER_THRESHOLD = 160
|
||
|
||
/** The scroll state captured when a page was asked for, so the reader
|
||
* keeps looking at the same message once it lands above them: the
|
||
* content grew over the viewport, and the position must move down by
|
||
* that growth. */
|
||
let olderAnchor: { top: number; height: number } | null = null
|
||
|
||
/** Asks for the page above when the reader nears the top, or when the
|
||
* transcript is too short to fill the screen — an auto-walk that stops
|
||
* the moment the record runs out (`olderAvailable`) or a page lands. */
|
||
function requestOlder(): void {
|
||
if (props.loadingOlder || props.olderAvailable === false) return
|
||
const el = scroller.value
|
||
if (!el) return
|
||
const atTop = el.scrollTop < OLDER_THRESHOLD
|
||
const unfilled = el.scrollHeight - el.clientHeight < 8
|
||
if (!atTop && !unfilled) return
|
||
olderAnchor = { top: el.scrollTop, height: el.scrollHeight }
|
||
emit('older')
|
||
}
|
||
|
||
function onScroll(): void {
|
||
const el = scroller.value
|
||
if (!el) return
|
||
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||
// Scrolling back into the history asks for the next page up; the
|
||
// anchor captured here is what keeps the reader's place when it
|
||
// lands.
|
||
if (el.scrollTop < OLDER_THRESHOLD) requestOlder()
|
||
}
|
||
|
||
/** A page landed above the viewport: hold the reader's place. */
|
||
watch(
|
||
() => props.prependCount,
|
||
async () => {
|
||
const el = scroller.value
|
||
const anchor = olderAnchor
|
||
olderAnchor = null
|
||
if (!el || !anchor) return
|
||
await nextTick()
|
||
el.scrollTop = anchor.top + (el.scrollHeight - anchor.height)
|
||
},
|
||
)
|
||
|
||
/** A transcript shorter than the screen has nothing to scroll, so no
|
||
* scroll event will ever ask for the page above: ask once the first
|
||
* turn has rendered, and keep asking as pages land until the screen
|
||
* fills or the record ends. */
|
||
watch(
|
||
() => [props.items.length, props.loadingOlder] as const,
|
||
async () => {
|
||
await nextTick()
|
||
const el = scroller.value
|
||
if (!el || props.items.length === 0) return
|
||
if (el.scrollHeight - el.clientHeight < 8) requestOlder()
|
||
},
|
||
)
|
||
|
||
// Thinking grows inside the trailing entry without changing the
|
||
// entries length, so its total length joins the watch to keep the
|
||
// stream pinned at the bottom.
|
||
function liveThinkingLength(): number {
|
||
return (
|
||
props.live?.entries.reduce(
|
||
(count, entry) => (entry.kind === 'thinking' ? count + entry.text.length : count),
|
||
0,
|
||
) ?? 0
|
||
)
|
||
}
|
||
|
||
watch(
|
||
() => [props.items.length, props.live?.reply.length, liveThinkingLength(), props.live?.entries.length],
|
||
async () => {
|
||
if (!pinned.value) return
|
||
await nextTick()
|
||
const el = scroller.value
|
||
if (el) el.scrollTop = el.scrollHeight
|
||
},
|
||
)
|
||
|
||
function submit(): void {
|
||
const text = draft.value.trim()
|
||
// The draft stays the gate: the claw 422s an empty text even with
|
||
// images aboard. `busy` is only this send's POST in flight (the claw
|
||
// answers 202 at once) — a running turn never blocks the composer,
|
||
// because a send into one steers it.
|
||
if (!text || !props.canSend || props.busy) return
|
||
emit('send', { text: embedPastes(text, pastes.value), images: stagedImages.value })
|
||
stageGeneration++
|
||
draft.value = ''
|
||
pastes.value = []
|
||
stagedImages.value = []
|
||
imageError.value = null
|
||
pinned.value = true
|
||
void nextTick(resizePrompt)
|
||
}
|
||
|
||
// --- the composer -----------------------------------------------------
|
||
const prompt = ref<HTMLTextAreaElement | null>(null)
|
||
|
||
/** The composer grows to this many rows, then scrolls instead. */
|
||
const MAX_PROMPT_ROWS = 10
|
||
|
||
/** Height follows the content; the CSS max-height clamps the growth. */
|
||
function resizePrompt(): void {
|
||
const el = prompt.value
|
||
if (!el) return
|
||
el.style.height = 'auto'
|
||
el.style.height = `${el.scrollHeight}px`
|
||
}
|
||
|
||
/** Enter submits, shift+enter is a newline, ctrl/cmd+enter submits. */
|
||
function onPromptKeydown(event: KeyboardEvent): void {
|
||
if (event.key === 'Enter' && !event.shiftKey) {
|
||
event.preventDefault()
|
||
submit()
|
||
}
|
||
}
|
||
|
||
// --- pasted attachments ----------------------------------------------
|
||
// A paste over PASTE_INLINE_LINES lines rides as a chip: the payload
|
||
// never lands in the box, it embeds into the sent text on submit —
|
||
// the same record the TUI keeps.
|
||
|
||
const pastes = ref<string[]>([])
|
||
|
||
function onPaste(event: ClipboardEvent): void {
|
||
const transfer = event.clipboardData
|
||
if (!transfer) return
|
||
const text = transfer.getData('text/plain')
|
||
if (text !== '') {
|
||
if (absorbPaste(text)) event.preventDefault()
|
||
return
|
||
}
|
||
// A file paste: text files read inline or chip, images stage for the
|
||
// next send — the same door the (+) button opens; anything else the
|
||
// composer still ignores.
|
||
let handled = false
|
||
for (const file of Array.from(transfer.files)) {
|
||
if (file.type.startsWith('text/')) {
|
||
handled = true
|
||
void file.text().then((pasted) => {
|
||
if (!absorbPaste(pasted)) insertInline(pasted)
|
||
})
|
||
} else if (mediaTypeOf(file) !== null) {
|
||
stageImage(file)
|
||
handled = true
|
||
}
|
||
}
|
||
if (handled) event.preventDefault()
|
||
}
|
||
|
||
/** A long paste becomes one chip; whether it did is the answer. */
|
||
function absorbPaste(text: string): boolean {
|
||
const normalized = normalizePaste(text)
|
||
if (lineCount(normalized) <= PASTE_INLINE_LINES) return false
|
||
pastes.value.push(normalized)
|
||
return true
|
||
}
|
||
|
||
/** A short file paste joins the draft where the caret sits. */
|
||
function insertInline(text: string): void {
|
||
const el = prompt.value
|
||
if (el && !el.disabled) {
|
||
el.setRangeText(text, el.selectionStart ?? el.value.length, el.selectionEnd ?? el.value.length, 'end')
|
||
draft.value = el.value
|
||
} else {
|
||
draft.value += text
|
||
}
|
||
resizePrompt()
|
||
}
|
||
|
||
// --- staged images ----------------------------------------------------
|
||
// The (+) button and the clipboard both land here: files held for the
|
||
// next send, previewed from object URLs, read to base64 at stage time
|
||
// so a bad file surfaces before submit, not inside it. The budget is
|
||
// the claw's own (http.rs): eight images, 5 MiB decoded each.
|
||
|
||
const MAX_IMAGES = 8
|
||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||
const IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
||
const IMAGE_ACCEPT = IMAGE_TYPES.join(',')
|
||
|
||
const stagedImages = ref<StagedImage[]>([])
|
||
const imageError = ref<string | null>(null)
|
||
const fileInput = ref<HTMLInputElement | null>(null)
|
||
|
||
/** Bumped on submit: a read still in flight when the draft cleared
|
||
* must not stage into a message that already left. */
|
||
let stageGeneration = 0
|
||
|
||
/** The wire media type a file stages as, or null when it is not one
|
||
* the claw accepts. The type usually rides the file; browsers that
|
||
* cannot name one leave it empty and the extension decides. */
|
||
function mediaTypeOf(file: File): string | null {
|
||
if (IMAGE_TYPES.includes(file.type)) return file.type
|
||
const byExt: Record<string, string> = {
|
||
png: 'image/png',
|
||
jpg: 'image/jpeg',
|
||
jpeg: 'image/jpeg',
|
||
gif: 'image/gif',
|
||
webp: 'image/webp',
|
||
}
|
||
return byExt[file.name.split('.').pop()?.toLowerCase() ?? ''] ?? null
|
||
}
|
||
|
||
/** B / KB / MB, one decimal past 1 KB — a chip label, not precision. */
|
||
function formatBytes(size: number): string {
|
||
if (size < 1024) return `${size} B`
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||
}
|
||
|
||
function pickImages(): void {
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
function onPick(event: Event): void {
|
||
const input = event.target as HTMLInputElement
|
||
for (const file of Array.from(input.files ?? [])) stageImage(file)
|
||
// Cleared so picking the same file again still fires `change`.
|
||
input.value = ''
|
||
}
|
||
|
||
/** Stages one file, or surfaces why not: never a silent drop. */
|
||
function stageImage(file: File): void {
|
||
const label = file.name || 'clipboard image'
|
||
imageError.value = null
|
||
if (stagedImages.value.length >= MAX_IMAGES) {
|
||
imageError.value = `${label}: at most ${MAX_IMAGES} images a message`
|
||
return
|
||
}
|
||
const media_type = mediaTypeOf(file)
|
||
if (!media_type) {
|
||
imageError.value = `${label}: not a png, jpeg, gif, or webp image`
|
||
return
|
||
}
|
||
const generation = stageGeneration
|
||
const reader = new FileReader()
|
||
reader.onload = () => {
|
||
if (generation !== stageGeneration) return
|
||
// The data URL's head carries the mime back; the payload after the
|
||
// first comma is the bare base64 the body wants.
|
||
const dataUrl = typeof reader.result === 'string' ? reader.result : ''
|
||
const comma = dataUrl.indexOf(',')
|
||
const data = comma === -1 ? '' : dataUrl.slice(comma + 1)
|
||
if (data === '') {
|
||
imageError.value = `${label}: could not be read`
|
||
return
|
||
}
|
||
// Four base64 characters decode to three bytes — the same budget
|
||
// check the claw runs, no decode needed.
|
||
if (data.length * 3 / 4 > MAX_IMAGE_BYTES) {
|
||
imageError.value = `${label}: over the 5 MiB decoded budget`
|
||
return
|
||
}
|
||
stagedImages.value.push({ media_type, data, url: URL.createObjectURL(file), name: label })
|
||
}
|
||
reader.onerror = () => {
|
||
if (generation === stageGeneration) imageError.value = `${label}: could not be read`
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
function removeImage(index: number): void {
|
||
const [gone] = stagedImages.value.splice(index, 1)
|
||
if (gone) URL.revokeObjectURL(gone.url)
|
||
imageError.value = null
|
||
}
|
||
|
||
/** A stored image opens the same download path an attachment card
|
||
* uses; a local preview has nothing behind it to download. */
|
||
function downloadImage(image: SentImage): void {
|
||
if (image.id === null) return
|
||
emit('downloadAttachment', image.id, storedImageName(image))
|
||
}
|
||
|
||
/** A stored image's download name: the staged file's when the row is
|
||
* local, derived from the id when only the claw knows the bytes. */
|
||
function storedImageName(image: SentImage): string {
|
||
if (image.name) return image.name
|
||
const ext = image.media_type.replace('image/', '')
|
||
return `image-${image.id}.${ext === 'jpeg' ? 'jpg' : ext}`
|
||
}
|
||
|
||
/** One render piece of a user message: literal text or a paste chip. */
|
||
type MessageSegment = PastePart | { kind: 'chip'; label: string }
|
||
|
||
/** The message's render pieces: literal text and resolved chips, the
|
||
* chips' one-based ids counted across the message. */
|
||
function messageSegments(text: string): MessageSegment[] {
|
||
const segments: MessageSegment[] = []
|
||
let chip = 0
|
||
for (const part of pasteParts(text)) {
|
||
if (part.kind === 'text') {
|
||
if (part.text !== '') segments.push(part)
|
||
} else {
|
||
segments.push({ kind: 'chip', label: chipLabel(part.text, chip++) })
|
||
}
|
||
}
|
||
return segments
|
||
}
|
||
|
||
const displayName = computed(() => props.activeKey.replace(/^interactive:/, ''))
|
||
|
||
// The dispatch internals live behind the info modal, not in the header.
|
||
const infoOpen = ref(false)
|
||
|
||
const infoRows = computed(() => [
|
||
{ label: 'state', value: props.session?.state ?? 'unknown' },
|
||
{ label: 'revision', value: props.session ? String(props.session.revision) : '—' },
|
||
{ label: 'inbox depth', value: props.session ? String(props.session.inbox_depth) : '—' },
|
||
{ label: 'last outcome', value: props.session?.last_outcome ?? '—' },
|
||
{ label: 'key', value: props.activeKey },
|
||
])
|
||
|
||
const streamLabel = computed(() =>
|
||
props.streamStatus === 'open' ? 'live' : props.streamStatus,
|
||
)
|
||
|
||
function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
||
if (status === 'open') return 'ok'
|
||
if (status === 'reconnecting') return 'error'
|
||
return 'warn'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="chat">
|
||
<header class="head">
|
||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="34" />
|
||
<div class="text">
|
||
<h2>{{ displayName }}</h2>
|
||
</div>
|
||
<span class="stream-status" :title="`event stream: ${streamStatus}`">
|
||
<StatusDot :tone="streamTone(streamStatus)" />
|
||
{{ streamLabel }}
|
||
</span>
|
||
<button class="ghost info" title="session info" @click="infoOpen = true">ⓘ</button>
|
||
</header>
|
||
|
||
<AppModal :open="infoOpen" title="Session info" @close="infoOpen = false">
|
||
<dl class="info-rows">
|
||
<div v-for="row in infoRows" :key="row.label" class="info-row">
|
||
<dt class="label">{{ row.label }}</dt>
|
||
<dd class="mono">{{ row.value }}</dd>
|
||
</div>
|
||
</dl>
|
||
</AppModal>
|
||
|
||
<div ref="scroller" class="stream-area" @scroll.passive="onScroll">
|
||
<p v-if="items.length === 0 && !live" class="empty">
|
||
nothing streamed yet; messages post to the claw and replies arrive here live
|
||
</p>
|
||
|
||
<p v-if="loadingOlder" class="older-hint">loading earlier turns</p>
|
||
|
||
<template v-for="item in items" :key="item.id">
|
||
<div v-if="item.kind === 'sent'" class="msg">
|
||
<div class="avatar user" aria-hidden="true">·</div>
|
||
<div class="msg-body">
|
||
<div class="who">you</div>
|
||
<div v-if="item.images.length > 0" class="sent-images">
|
||
<button
|
||
v-for="(image, index) in item.images"
|
||
:key="index"
|
||
class="sent-image"
|
||
:disabled="image.url === null || image.id === null"
|
||
:title="image.url !== null && image.id !== null ? `download ${storedImageName(image)}` : (image.name ?? 'attached image')"
|
||
@click="downloadImage(image)"
|
||
>
|
||
<img v-if="image.url" :src="image.url" :alt="image.name ?? 'attached image'" />
|
||
</button>
|
||
</div>
|
||
<div class="reply plain">
|
||
<template v-for="(segment, index) in messageSegments(item.text)" :key="index">
|
||
<span v-if="segment.kind === 'chip'" class="badge paste-chip">{{ segment.label }}</span>
|
||
<template v-else>{{ segment.text }}</template>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="item.kind === 'turn'" class="msg">
|
||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
||
<div class="msg-body">
|
||
<div class="who">{{ agentName }}</div>
|
||
<TurnEntries
|
||
:entries="item.turn.entries"
|
||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||
@consent-deny="(id) => emit('consentDeny', id)"
|
||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||
/>
|
||
<AppMarkdown v-if="item.turn.reply" :text="item.turn.reply" class="reply" />
|
||
<div
|
||
v-if="item.turn.outcome"
|
||
class="outcome mono"
|
||
:class="{ failed: item.turn.outcome === 'failed', steered: item.turn.outcome === 'steered' }"
|
||
>
|
||
{{ item.turn.outcome }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="msg" :class="item.kind">
|
||
<div class="msg-body">
|
||
<TurnEntries
|
||
:entries="[item.kind === 'consent' ? { kind: 'consent', consent: item.consent } : { kind: 'attachment', attachment: item.attachment }]"
|
||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||
@consent-deny="(id) => emit('consentDeny', id)"
|
||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="live" class="msg live">
|
||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
||
<div class="msg-body">
|
||
<div class="who">{{ agentName }}</div>
|
||
<TurnEntries
|
||
:entries="live.entries"
|
||
:open-thinking="true"
|
||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||
@consent-deny="(id) => emit('consentDeny', id)"
|
||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||
/>
|
||
<AppMarkdown v-if="live.reply" :text="live.reply" streaming class="reply" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="composer">
|
||
<div class="chips">
|
||
<span class="badge">{{ activeKey }}</span>
|
||
<button
|
||
class="badge chip-btn"
|
||
title="switch the model backend in settings"
|
||
:disabled="!activeBackend"
|
||
@click="emit('openProviders')"
|
||
>
|
||
{{ activeBackend ? `${activeBackend.provider} · ${activeBackend.model}` : 'no backend' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="pastes.length > 0" class="paste-chips">
|
||
<span v-for="(paste, index) in pastes" :key="index" class="badge paste-chip">
|
||
{{ chipLabel(paste, index) }}
|
||
<button class="chip-x" :title="`drop paste ${index + 1}`" @click="pastes.splice(index, 1)">
|
||
×
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div v-if="stagedImages.length > 0" class="paste-chips">
|
||
<span v-for="(image, index) in stagedImages" :key="image.url" class="badge paste-chip">
|
||
▣{{ index + 1 }} {{ image.name }} {{ formatBytes(Math.round((image.data.length * 3) / 4)) }}
|
||
<button class="chip-x" :title="`drop ${image.name}`" @click="removeImage(index)">
|
||
×
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<p v-if="imageError" class="image-error" role="alert">
|
||
{{ imageError }}
|
||
<button class="chip-x" title="dismiss" @click="imageError = null">×</button>
|
||
</p>
|
||
<div class="input-row">
|
||
<button class="ghost attach" title="attach images" :disabled="!canSend" @click="pickImages">
|
||
+
|
||
</button>
|
||
<textarea
|
||
ref="prompt"
|
||
v-model="draft"
|
||
rows="1"
|
||
:placeholder="
|
||
canSend
|
||
? live
|
||
? `Message ${agentName}… sending steers the running turn`
|
||
: `Message ${agentName}…`
|
||
: 'only interactive:* sessions accept messages'
|
||
"
|
||
:disabled="!canSend"
|
||
@keydown="onPromptKeydown"
|
||
@paste="onPaste"
|
||
@input="resizePrompt"
|
||
/>
|
||
<button
|
||
class="primary send"
|
||
:disabled="!canSend || busy || draft.trim() === ''"
|
||
:title="live ? 'send — interrupts the running turn' : 'send'"
|
||
@click="submit"
|
||
>
|
||
↑
|
||
</button>
|
||
<input
|
||
ref="fileInput"
|
||
class="file-input"
|
||
type="file"
|
||
:accept="IMAGE_ACCEPT"
|
||
multiple
|
||
@change="onPick"
|
||
/>
|
||
</div>
|
||
</footer>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.chat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
flex: 1;
|
||
background: var(--ground);
|
||
}
|
||
|
||
.head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--surface);
|
||
}
|
||
|
||
.avatar {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: none;
|
||
border-radius: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.avatar.user {
|
||
background: var(--elevated);
|
||
border: 1px solid var(--border-strong);
|
||
color: var(--text2);
|
||
}
|
||
|
||
.text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.text h2 {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.head .info {
|
||
flex: none;
|
||
padding: 0 6px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.info-rows {
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.info-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 7px 0;
|
||
}
|
||
|
||
.info-row + .info-row {
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.info-row dd {
|
||
margin: 0;
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
text-align: right;
|
||
}
|
||
|
||
.stream-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--text3);
|
||
font-size: 12px;
|
||
flex: none;
|
||
}
|
||
|
||
.stream-area {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 20px 24px;
|
||
}
|
||
|
||
.empty {
|
||
color: var(--text3);
|
||
padding: 24px;
|
||
}
|
||
|
||
.older-hint {
|
||
color: var(--text3);
|
||
font-family: var(--mono, monospace);
|
||
font-size: 12px;
|
||
margin: 0 0 16px;
|
||
text-align: center;
|
||
}
|
||
|
||
.msg {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.msg-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.who {
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.live .who {
|
||
color: var(--accent-text);
|
||
}
|
||
|
||
.reply {
|
||
color: var(--text);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* User lines are verbatim input; agent replies render markdown (see
|
||
main.css .md) and wrap by its rules instead. */
|
||
.plain {
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.outcome {
|
||
margin-top: 6px;
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.outcome.failed {
|
||
color: var(--error);
|
||
}
|
||
|
||
/* A steered turn: cut short by the user's next message — not a
|
||
failure, the reply that replaced it streams right below. */
|
||
.outcome.steered {
|
||
color: var(--accent-text);
|
||
}
|
||
|
||
/* A consent card: the agent asking its human something — distinct
|
||
from a tool row, toned by its resolution. */
|
||
.consent-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-left: 3px solid var(--warn);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.consent-card.ok {
|
||
border-left-color: var(--ok);
|
||
}
|
||
|
||
.consent-card.denied {
|
||
border-left-color: var(--error);
|
||
}
|
||
|
||
.consent-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.consent-summary {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.denied .consent-summary {
|
||
color: var(--text3);
|
||
}
|
||
|
||
.consent-state {
|
||
flex: none;
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--ok);
|
||
}
|
||
|
||
.denied .consent-state {
|
||
color: var(--error);
|
||
}
|
||
|
||
.consent-detail {
|
||
margin: 0;
|
||
color: var(--text2);
|
||
font-size: 12.5px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.consent-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.consent-actions input {
|
||
flex: 1;
|
||
min-width: 160px;
|
||
}
|
||
|
||
/* An attachment card: the agent handing a file over — head row names
|
||
it, the image (when there is one) rides under it, capped so a
|
||
screenshot cannot take the stream. */
|
||
.attachment-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-width: 480px;
|
||
padding: 10px 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.attachment-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.attachment-glyph {
|
||
flex: none;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.attachment-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: var(--text);
|
||
}
|
||
|
||
.attachment-size {
|
||
flex: none;
|
||
color: var(--text3);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.attachment-image {
|
||
max-width: 100%;
|
||
max-height: 240px;
|
||
align-self: flex-start;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.attachment-caption {
|
||
margin: 0;
|
||
color: var(--text3);
|
||
font-size: 12.5px;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.composer {
|
||
border-top: 1px solid var(--border);
|
||
background: var(--surface);
|
||
padding: 10px 16px 14px;
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.chip-btn {
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.chip-btn:hover:not(:disabled) {
|
||
border-color: var(--accent);
|
||
color: var(--accent-text);
|
||
}
|
||
|
||
.paste-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* One embedded paste, standing in for its payload — composer staging
|
||
and transcript rendering share the shape. */
|
||
.paste-chip {
|
||
gap: 2px;
|
||
padding-right: 4px;
|
||
background: var(--elevated);
|
||
}
|
||
|
||
.chip-x {
|
||
border: none;
|
||
background: none;
|
||
padding: 0 2px;
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
color: var(--text3);
|
||
}
|
||
|
||
.chip-x:hover:not(:disabled) {
|
||
color: var(--error);
|
||
}
|
||
|
||
.input-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.attach {
|
||
width: 38px;
|
||
padding: 0;
|
||
font-size: 16px;
|
||
border-radius: 9px;
|
||
flex: none;
|
||
}
|
||
|
||
.file-input {
|
||
display: none;
|
||
}
|
||
|
||
.image-error {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin: 0 0 8px;
|
||
color: var(--error);
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
/* Images a user message carried: the attachment-card visual, sized to
|
||
the sent row. A stored image downloads on click; a local preview has
|
||
nothing behind it. */
|
||
.sent-images {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.sent-image {
|
||
display: block;
|
||
padding: 0;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.sent-image:disabled {
|
||
cursor: default;
|
||
}
|
||
|
||
.sent-image img {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 240px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.input-row textarea {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 9px 12px;
|
||
resize: none;
|
||
overflow-y: auto;
|
||
/* MAX_PROMPT_ROWS rows, then the box scrolls instead of growing. */
|
||
max-height: calc(10 * 1.55em + 18px);
|
||
}
|
||
|
||
.send {
|
||
width: 38px;
|
||
padding: 0;
|
||
font-size: 16px;
|
||
border-radius: 9px;
|
||
flex: none;
|
||
}
|
||
</style>
|