Files
rhema-claw/web/claw/app/app.vue
T
vhaudiquet 94b82e2636 fix(web): the bugs a chat that never closes for a turn exposes
An investigation pass over the client, driven by steering's new shape —
a composer that is always live makes adjacent latent bugs visible.

The event stream now watches for silence: the claw keeps the
connection alive with a comment every fifteen seconds, and three
missed ones abort the fetch and reconnect, so a half-open TCP after a
laptop sleep no longer reads as 'live' forever. A reconnect clears the
attempted-attachment set with the transcript, or an image whose fetch
failed during the outage never retries against the recovered stream.
Saving a token reloads the identity and accounts the 401 froze: the
banner cleared, but the pages stayed empty until a browser reload.

A failed POST retracts its optimistic sent row (and its pending echo,
so a message the claw did take still renders from the stream's echo);
before, a send into an unreachable claw left a phantom row waiting for
a reply that could never come. Drafts are per-session now — the
composer's text belonged to no session, so a draft typed into one
session sent into whichever was selected at Enter. The providers
login form distinguishes a landed login (the roster carries its id)
from a failed one and no longer closes on an unrelated state refresh.

Two cost fixes that read as jank on long turns: the streamed reply's
markdown re-parsed the whole text per delta (quadratic) and now
re-renders on a short trailing debounce; the attachment-image watcher
walked the whole transcript on every streamed chunk and now scans only
the items it has not seen. The accounts page drops a stray Rust-ism
comment, and the avatar row's meta line admits webp like its accept
attribute always did.
2026-11-08 18:00:56 +01:00

747 lines
22 KiB
Vue

<script setup lang="ts">
// The one page: the icon rail, then either the chat view (session
// inventory + live transcript) or the settings view (nav + pages). A
// tiny hash router keeps views deep-linkable without vue-router. All
// state is in-memory; only the token and the active session persist.
// The claw owns the conversation and the config, so this page is a
// mirror, not a store.
import { ClawError, clawHealth, isInteractive, useClaw } from '~/composables/useClaw'
import type {
AgentIdentity,
AgentImageKind,
ClawAccount,
ClawSession,
ConsentAnswer,
IdentityTexts,
ModelCard,
NewAccount,
ProviderCredentials,
ProvidersState,
StreamStatus,
} from '~/composables/useClaw'
import { useTranscript } from '~/composables/useTranscript'
import type { StagedImage } from '~/composables/useTranscript'
const TOKEN_STORE_KEY = 'claw.token'
const ACTIVE_STORE_KEY = 'claw.activeKey'
const DEFAULT_KEY = 'interactive:web'
function stored(key: string): string | null {
if (!import.meta.client) return null
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function persist(key: string, value: string | null): void {
if (!import.meta.client) return
try {
if (value === null) localStorage.removeItem(key)
else localStorage.setItem(key, value)
} catch {
// Storage unavailable (private mode): the session still works, it
// just forgets.
}
}
// --- the two views ---------------------------------------------------
type Route = { view: 'chat' } | { view: 'settings'; section: string }
const SETTINGS_SECTIONS: { label: string; entries: { id: string; label: string }[] }[] = [
{
label: 'Agent',
entries: [
{ id: 'agent/general', label: 'General' },
{ id: 'agent/accounts', label: 'Accounts' },
],
},
{ label: 'Providers', entries: [{ id: 'providers', label: 'Model providers' }] },
{ label: 'System', entries: [{ id: 'about', label: 'About' }] },
]
function parseHash(): Route {
if (!import.meta.client) return { view: 'chat' }
const hash = window.location.hash.replace(/^#\/?/, '')
if (hash.startsWith('settings')) {
// Section ids may carry their category ('agent/general'), so the
// section is everything after 'settings/', not the second segment.
const section = hash.replace(/^settings\/?/, '') || 'agent/general'
return {
view: 'settings',
section: SETTINGS_SECTIONS.some((s) => s.entries.some((e) => e.id === section))
? section
: 'providers',
}
}
return { view: 'chat' }
}
const route = ref<Route>(parseHash())
function go(next: Route): void {
window.location.hash = next.view === 'chat' ? '#/chat' : `#/settings/${next.section}`
}
function onHashChange(): void {
route.value = parseHash()
}
// --- claw state ------------------------------------------------------
const config = useRuntimeConfig()
const token = ref<string | null>(
stored(TOKEN_STORE_KEY) ?? (config.public.clawToken || null),
)
const claw = useClaw(token)
const activeKey = ref(stored(ACTIVE_STORE_KEY) ?? DEFAULT_KEY)
watch(activeKey, (key) => persist(ACTIVE_STORE_KEY, key))
const transcript = useTranscript()
const { items: transcriptItems, live: liveTurn } = transcript
// Attachment ids this page already tried to fetch; a failed fetch is
// not retried until the stream resets and clears the set.
const attemptedAttachments = new Set<string>()
// Image cards need their bytes before they can render: fetched as the
// card appears, not on any timer. code/file fetch on download instead.
// A replayed question's images fetch the same way — the stream carries
// their attachment ids, never the bytes; locally sent rows hold their
// own object URLs.
//
// The watch is deep (rows mutate in place as their bytes land), and it
// fires on every streamed delta, so the scan covers only the items the
// last pass did not see — the transcript only ever appends.
let scannedItems = 0
watch(
transcriptItems,
(items) => {
for (const item of items.slice(scannedItems)) {
if (item.kind === 'attachment') {
const { id, media, url } = item.attachment
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
attemptedAttachments.add(id)
claw
.attachmentUrl(id)
.then((fetched) => transcript.setAttachmentUrl(id, fetched))
.catch((err) => noteApiError(err))
} else if (item.kind === 'sent') {
for (const image of item.images) {
const { id, url } = image
if (id === null || url !== null || attemptedAttachments.has(id)) continue
attemptedAttachments.add(id)
claw
.attachmentUrl(id)
.then((fetched) => transcript.setSentImageUrl(id, fetched))
.catch((err) => noteApiError(err))
}
}
}
scannedItems = items.length
},
{ deep: true },
)
// --- the agent's identity --------------------------------------------
const identity = ref<AgentIdentity | null>(null)
const identityAvatarUrl = ref<string | null>(null)
const identityLogoUrl = ref<string | null>(null)
async function loadImageUrls(): Promise<void> {
// A new object URL per fetch; the old one is revoked, not leaked.
for (const [kind, url] of [
['avatar', identityAvatarUrl],
['logo', identityLogoUrl],
] as const) {
if (!identity.value?.[kind]) {
if (url.value) URL.revokeObjectURL(url.value)
url.value = null
continue
}
if (url.value) URL.revokeObjectURL(url.value)
try {
url.value = await claw.agentImage(kind)
} catch {
url.value = null
}
}
}
async function refreshIdentity(): Promise<void> {
try {
identity.value = await claw.agent()
await loadImageUrls()
} catch (err) {
noteApiError(err)
}
}
async function saveIdentity(texts: IdentityTexts): Promise<void> {
savingIdentity.value = true
try {
identity.value = await claw.setAgent(texts)
pushNotice('identity saved')
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
savingIdentity.value = false
}
}
async function uploadIdentityImage(kind: AgentImageKind, file: File): Promise<void> {
mutatingIdentity.value = true
try {
identity.value = await claw.setAgentImage(kind, file)
await loadImageUrls()
pushNotice(`${kind} updated`)
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
mutatingIdentity.value = false
}
}
async function clearIdentityImage(kind: AgentImageKind): Promise<void> {
mutatingIdentity.value = true
try {
identity.value = await claw.clearAgentImage(kind)
await loadImageUrls()
pushNotice(`${kind} cleared`)
} catch (err) {
noteApiError(err)
} finally {
mutatingIdentity.value = false
}
}
// The window title answers to the identity and the view, as the design
// shows it: "Kosmos — Settings · Model providers".
const viewTitle = computed(() => {
const current = route.value
if (current.view === 'chat') return 'Chat'
const label = SETTINGS_SECTIONS.flatMap((section) => section.entries).find(
(entry) => entry.id === current.section,
)?.label
return `Settings · ${label ?? current.section}`
})
watch(
[identity, viewTitle],
() => {
const name = identity.value?.name?.trim()
document.title = name ? `${name} — ${viewTitle.value}` : `rhema claw — ${viewTitle.value}`
},
{ immediate: true },
)
const clawUp = ref<boolean | null>(null)
const sessions = ref<ClawSession[]>([])
const sessionsLoading = ref(false)
const providersState = ref<ProvidersState | null>(null)
const providerModels = ref<ModelCard[]>([])
const providerModelsLoading = ref(false)
const providerModelsError = ref<string | null>(null)
const selectedProvider = ref<string | null>(null)
const savingProviders = ref(false)
const mutatingProviders = ref(false)
const savingIdentity = ref(false)
const mutatingIdentity = ref(false)
const accounts = ref<ClawAccount[] | null>(null)
const accountsLoading = ref(false)
const mutatingAccounts = ref(false)
const streamStatus = ref<StreamStatus>('connecting')
const busy = ref(false)
const authRequired = ref(false)
const tokenDraft = ref('')
const apiError = ref<string | null>(null)
const notices = ref<{ id: number; text: string }[]>([])
let noticeId = 1
const activeSession = computed(
() => sessions.value.find((session) => session.key === activeKey.value) ?? null,
)
function noteApiError(err: unknown): void {
if (err instanceof ClawError && err.status === 401) {
authRequired.value = true
return
}
apiError.value = err instanceof Error ? err.message : String(err)
}
function pushNotice(text: string): void {
notices.value.push({ id: noticeId++, text })
if (notices.value.length > 4) notices.value.shift()
}
// --- claw calls ------------------------------------------------------
async function checkHealth(): Promise<void> {
clawUp.value = await clawHealth()
}
async function refreshSessions(): Promise<void> {
sessionsLoading.value = true
try {
sessions.value = await claw.sessions()
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
sessionsLoading.value = false
}
}
async function createSession(name: string): Promise<void> {
try {
// The SSE resubscribe watcher follows the switch; the inventory
// refresh lands the new key in the sidebar and closes its form.
activeKey.value = (await claw.createSession(name)).key
apiError.value = null
} catch (err) {
noteApiError(err)
}
void refreshSessions()
}
async function refreshProviders(): Promise<void> {
try {
providersState.value = await claw.providers()
if (providersState.value && !selectedProvider.value) {
selectedProvider.value = providersState.value.active.provider
await loadProviderModels(selectedProvider.value)
}
apiError.value = null
} catch (err) {
noteApiError(err)
}
}
async function loadProviderModels(id: string): Promise<void> {
selectedProvider.value = id
providerModelsLoading.value = true
providerModelsError.value = null
try {
providerModels.value = await claw.models(id)
} catch (err) {
// A backend without a live listing is normal (404): the operator
// still sees the switch go through on the default model.
providerModels.value = []
providerModelsError.value = err instanceof Error ? err.message : String(err)
} finally {
providerModelsLoading.value = false
}
}
async function refreshAccounts(): Promise<void> {
accountsLoading.value = true
try {
accounts.value = await claw.accounts()
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
accountsLoading.value = false
}
}
async function addAccount(account: NewAccount): Promise<void> {
mutatingAccounts.value = true
try {
const saved = await claw.addAccount(account)
pushNotice(
saved.watchable_forge
? saved.watched
? `account ${saved.label} saved — following ${saved.service} notifications`
: `account ${saved.label} saved (already watched)`
: `account ${saved.label} saved — ${saved.service} is not a watchable forge`,
)
await refreshAccounts()
} catch (err) {
noteApiError(err)
} finally {
mutatingAccounts.value = false
}
}
async function removeAccount(account: ClawAccount): Promise<void> {
mutatingAccounts.value = true
try {
const forgot = await claw.removeAccount(account.id)
pushNotice(
forgot.forgot
? `account ${account.label} forgotten${forgot.watches_removed ? ` — ${forgot.watches_removed} watch(es) removed` : ''}`
: `account ${account.label} was already gone`,
)
await refreshAccounts()
} catch (err) {
noteApiError(err)
} finally {
mutatingAccounts.value = false
}
}
async function saveProviders(provider: string, model?: string): Promise<void> {
savingProviders.value = true
try {
// The route answers the new pair only; carry the provider list
// forward so the state stays whole.
const { active } = await claw.setActive(provider, model)
providersState.value = providersState.value
? { ...providersState.value, active }
: { active, providers: [] }
pushNotice(`active backend: ${provider} / ${active.model}`)
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
savingProviders.value = false
}
}
async function addProvider(id: string, credentials: ProviderCredentials): Promise<void> {
mutatingProviders.value = true
const existed = providersState.value?.providers.some((p) => p.id === id) ?? false
try {
// The login answers the whole state: the roster with the new (or
// replaced) backend instance on it.
providersState.value = await claw.setCredentials(id, credentials)
selectedProvider.value = id
await loadProviderModels(id)
pushNotice(existed ? `provider ${id} replaced` : `provider ${id} added`)
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
mutatingProviders.value = false
}
}
async function removeProvider(id: string): Promise<void> {
mutatingProviders.value = true
try {
providersState.value = await claw.removeProvider(id)
// The removal may have moved the active pair: both re-ground on
// what remains.
if (selectedProvider.value === id) {
selectedProvider.value = providersState.value.active.provider
await loadProviderModels(selectedProvider.value)
}
pushNotice(`provider ${id} removed`)
apiError.value = null
} catch (err) {
noteApiError(err)
} finally {
mutatingProviders.value = false
}
}
async function send(message: { text: string; images: StagedImage[] }): Promise<void> {
// The optimistic row renders the staged previews; the stream's
// user_message echo carries the stored ids and dedupes against it.
const rowId = transcript.addSent(
message.text,
message.images.map(({ media_type, url, name }) => ({ id: null, media_type, url, name })),
)
busy.value = true
try {
// Fire-and-forget by design: the claw answers 202 and the reply
// arrives on the event stream. The body carries the images only
// when there are some.
await claw.sendMessage(
activeKey.value,
message.text,
message.images.map(({ media_type, data }) => ({ media_type, data })),
)
apiError.value = null
} catch (err) {
// The claw never took the message: retract the optimistic row, or
// it would sit there forever waiting for a reply that cannot come.
transcript.removeSent(rowId)
noteApiError(err)
} finally {
busy.value = false
}
void refreshSessions()
}
// A consent answers optimistically: the card flips as soon as the POST
// lands, and the stream's consent_resolved echo confirms the same
// value.
async function answerConsent(id: string, answer: ConsentAnswer): Promise<void> {
try {
await claw.resolveConsent(id, answer)
transcript.resolveConsent(id, 'secret' in answer ? true : answer.grant)
apiError.value = null
} catch (err) {
noteApiError(err)
}
}
async function denyConsent(id: string): Promise<void> {
try {
await claw.denyConsent(id)
transcript.resolveConsent(id, false)
apiError.value = null
} catch (err) {
noteApiError(err)
}
}
async function downloadAttachment(id: string, name: string): Promise<void> {
try {
// The URL is cached per id, so a second download of the same file
// replays the blob, not the request.
const url = await claw.attachmentUrl(id)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = name
anchor.click()
} catch (err) {
noteApiError(err)
}
}
function saveToken(): void {
const next = tokenDraft.value.trim()
token.value = next || null
persist(TOKEN_STORE_KEY, next || null)
tokenDraft.value = ''
authRequired.value = false
apiError.value = null
// The stream's retry loop reads the token fresh each attempt, so it
// recovers on its own; everything the 401 froze reloads now — the
// polls confirm the token works, and the identity and accounts pages
// were left empty by the failed fetches that raised the banner.
void checkHealth()
void refreshProviders()
void refreshSessions()
void refreshIdentity()
void refreshAccounts()
}
// --- the one live subscription ---------------------------------------
let stream: { close: () => void } | null = null
function resubscribe(): void {
stream?.close()
transcript.reset()
attemptedAttachments.clear()
notices.value = []
streamStatus.value = 'connecting'
stream = claw.subscribeEvents(activeKey.value, {
onEvent: (event) => transcript.applyEvent(event),
onStatus: (status) => {
streamStatus.value = status
// A retry reopens the stream with the claw's replay of the
// recorded turns: drop the mirror and let it rebuild, or the
// replay would double everything already on screen. The
// attempted-attachment set goes too — a fetch that failed during
// the outage must retry against the recovered stream.
if (status === 'reconnecting') {
transcript.reset()
attemptedAttachments.clear()
scannedItems = 0
}
if (status === 'open') void refreshSessions()
},
onNotice: pushNotice,
})
}
watch(activeKey, resubscribe)
let healthTimer: ReturnType<typeof setInterval> | undefined
let sessionsTimer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
window.addEventListener('hashchange', onHashChange)
resubscribe()
void checkHealth()
void refreshIdentity()
void refreshProviders()
void refreshAccounts()
void refreshSessions()
healthTimer = setInterval(checkHealth, 30000)
sessionsTimer = setInterval(() => void refreshSessions(), 20000)
})
onUnmounted(() => {
window.removeEventListener('hashchange', onHashChange)
stream?.close()
clearInterval(healthTimer)
clearInterval(sessionsTimer)
})
</script>
<template>
<div class="shell">
<AppRail
:view="route.view"
:claw-up="clawUp"
:identity="identity"
:avatar-url="identityAvatarUrl"
@navigate="go($event === 'chat' ? { view: 'chat' } : { view: 'settings', section: 'providers' })"
/>
<div class="body">
<div class="banners">
<ClawBanner v-if="authRequired" variant="error">
the claw requires a bearer token on /v1.
<template #actions>
<input
v-model="tokenDraft"
type="password"
placeholder="token"
autocomplete="off"
@keydown.enter="saveToken"
/>
<button @click="saveToken">save</button>
</template>
</ClawBanner>
<ClawBanner v-if="clawUp === false" variant="error">
the claw is unreachable at {{ config.public.clawBase || 'same origin (dev proxy → 127.0.0.1:7799)' }};
start it with <code>rhema-claw</code>
</ClawBanner>
<ClawBanner v-if="apiError" variant="error" dismissible @dismiss="apiError = null">
{{ apiError }}
</ClawBanner>
<ClawBanner
v-for="notice in notices"
:key="notice.id"
variant="warn"
dismissible
@dismiss="notices = notices.filter((n) => n.id !== notice.id)"
>
{{ notice.text }}
</ClawBanner>
</div>
<main class="view">
<template v-if="route.view === 'chat'">
<SessionsSidebar
:sessions="sessions"
:active-key="activeKey"
:loading="sessionsLoading"
:identity="identity"
:avatar-url="identityAvatarUrl"
@select="activeKey = $event"
@refresh="refreshSessions"
@create="createSession"
/>
<ChatPanel
:items="transcriptItems"
:live="liveTurn"
:active-key="activeKey"
:can-send="isInteractive(activeKey)"
:busy="busy"
:session="activeSession"
:active-backend="providersState?.active ?? null"
:stream-status="streamStatus"
:identity="identity"
:avatar-url="identityAvatarUrl"
@send="send"
@open-providers="go({ view: 'settings', section: 'providers' })"
@consent-answer="answerConsent"
@consent-deny="denyConsent"
@download-attachment="downloadAttachment"
/>
</template>
<template v-else>
<SettingsNav
:sections="SETTINGS_SECTIONS"
:active="route.section"
:claw-up="clawUp"
@select="go({ view: 'settings', section: $event })"
/>
<SettingsAgentGeneralPage
v-show="route.section === 'agent/general'"
:identity="identity"
:avatar-url="identityAvatarUrl"
:logo-url="identityLogoUrl"
:saving="savingIdentity"
:mutating="mutatingIdentity"
@save="saveIdentity"
@upload-image="uploadIdentityImage"
@clear-image="clearIdentityImage"
/>
<SettingsAgentAccountsPage
v-show="route.section === 'agent/accounts'"
:accounts="accounts"
:loading="accountsLoading"
:mutating="mutatingAccounts"
@add="addAccount"
@remove="removeAccount"
/>
<SettingsProvidersPage
v-show="route.section === 'providers'"
:state="providersState"
:models="providerModels"
:models-loading="providerModelsLoading"
:models-error="providerModelsError"
:selected-provider="selectedProvider"
:saving="savingProviders"
:mutating="mutatingProviders"
@inspect="loadProviderModels"
@save="saveProviders"
@add="addProvider"
@remove="removeProvider"
/>
<SettingsAboutPage v-show="route.section === 'about'" :claw-up="clawUp" />
</template>
</main>
</div>
</div>
</template>
<style scoped>
.shell {
display: flex;
height: 100vh;
}
.body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.banners {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 16px 0;
}
.view {
flex: 1;
display: flex;
min-height: 0;
}
.view > * {
min-height: 0;
}
@media (max-width: 980px) {
.shell {
height: auto;
min-height: 100vh;
}
.view {
flex-direction: column;
}
.view > * {
height: 60vh;
border-top: 1px solid var(--border);
}
}
</style>