Files
kosmos b3bb026fab fix(web): the session inventory lists only the keys the chat can open
The sidebar grouped everything that was not `interactive:` under
"other keys", minus the phone's status keys. That included the coding
jobs' `job:<instance>#<id>` keys and forge keys — and those are dead
ends: the message, event and history routes answer only for the
resident prefixes (`is_resident_key`), so opening one 400s and the row
sits there looking busy with nothing inside. A finished job's key was
worse than useless while its dispatch spun: `reacquire`, forever.

`isResidentKey` names the rule once, beside `isInteractive` —
`interactive:`, `task:`, `calendar:` — and the inventory filters on it.
That leaves the phone's keys out as before, keeps scheduled tasks
(their turns have real transcripts), and stops offering keys the chat
surface cannot address. The shell's `nextActiveKey` follows the same
rule: after a delete the chat moves to the next key it can open, or to
none. The full list stays on the wire — the API's inventory is the
dispatcher's, not the client's, and the delete route still answers for
any key with a record.

Consequence worth naming: a `job:` or forge key has no row now, so it
cannot be deleted from the sidebar either.
2026-11-11 23:52:54 +00:00

865 lines
26 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, isResidentKey, 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,
loadingOlder,
prependCount,
} = transcript
// Whether the record still holds turns above what the mirror shows.
// The stream opens on the newest turn and the reader walks back page
// by page (ChatPanel asks when the scroller nears its top, or when the
// transcript is too short to fill the screen), so a session that ran
// for weeks costs nothing up front and still reaches its first message.
const olderAvailable = ref(true)
// 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()
}
// --- deleting a session ----------------------------------------------
/** The key the confirmation names; null while no dialog is open. */
const deletingKey = ref<string | null>(null)
const deleting = ref(false)
function askDeleteSession(key: string): void {
deletingKey.value = key
}
function closeDeleteSession(): void {
// A delete in flight is not cancellable: the claw is already writing.
if (deleting.value) return
deletingKey.value = null
}
/** The session to show once `gone` is deleted: the first interactive one
* the inventory still has, else the first of any kind, else none — an
* empty key opens no stream (see `resubscribe`). */
function nextActiveKey(gone: string): string {
const remaining = sessions.value.filter((session) => session.key !== gone)
const next = remaining.find((session) => isResidentKey(session.key))
return next?.key ?? ''
}
async function confirmDeleteSession(): Promise<void> {
const key = deletingKey.value
if (key === null || deleting.value) return
deleting.value = true
try {
await claw.deleteSession(key)
apiError.value = null
pushNotice(`session ${key} deleted`)
// The inventory first: the replacement key is whatever is left.
await refreshSessions()
// Moving activeKey resubscribes the stream (the watcher below), so
// the chat follows the delete instead of streaming a key that is
// gone.
if (activeKey.value === key) activeKey.value = nextActiveKey(key)
} catch (err) {
noteApiError(err)
} finally {
deleting.value = false
// The dialog closes either way: a refusal lands in the banner, where
// the rest of the claw's errors do.
deletingKey.value = null
}
}
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()
stream = null
transcript.reset()
olderAvailable.value = true
attemptedAttachments.clear()
notices.value = []
streamStatus.value = 'connecting'
// Deleting the last session clears the key, and an empty key is not a
// stream: subscribing to one would retry a 404 forever.
if (activeKey.value === '') return
stream = claw.subscribeEvents(activeKey.value, {
onEvent: (event, seq) => transcript.applyEvent(event, seq),
// The record lost what this mirror holds (a wiped or reset
// database): drop it, and the tail the claw sends next rebuilds it.
onReset: () => {
transcript.reset()
olderAvailable.value = true
attemptedAttachments.clear()
scannedItems = 0
},
onStatus: (status) => {
streamStatus.value = status
// A retry resumes from the last seq this mirror holds, so the
// claw sends the gap and nothing else: the mirror stays as it
// is. Only the attachment set is cleared — a fetch that failed
// during the outage should retry against the recovered stream.
if (status === 'reconnecting') {
attemptedAttachments.clear()
scannedItems = 0
}
if (status === 'open') void refreshSessions()
},
onNotice: pushNotice,
})
}
/** Fetches one page of turns older than the mirror's first and stacks
* it above. Whole turns only, so the page never duplicates or skips:
* the daemon ends it exactly where the mirror begins. */
async function loadOlder(): Promise<void> {
const before = transcript.firstSeq.value
if (before === null || !olderAvailable.value || transcript.loadingOlder.value) return
transcript.loadingOlder.value = true
const key = activeKey.value
try {
const page = await claw.history(key, before)
// The reader may have switched sessions while the page was in
// flight; a page belongs to the mirror it was asked for.
if (key !== activeKey.value) return
transcript.prependOlder(page.events)
olderAvailable.value = page.more
} catch (err) {
noteApiError(err)
} finally {
transcript.loadingOlder.value = false
}
}
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"
@delete="askDeleteSession"
/>
<ChatPanel
:items="transcriptItems"
:live="liveTurn"
:active-key="activeKey"
:can-send="isInteractive(activeKey)"
:busy="busy"
:session="activeSession"
:active-backend="providersState?.active ?? null"
:stream-status="streamStatus"
:loading-older="loadingOlder"
:older-available="olderAvailable"
:prepend-count="prependCount"
:identity="identity"
:avatar-url="identityAvatarUrl"
@older="loadOlder"
@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>
<AppModal
:open="deletingKey !== null"
title="Delete session"
@close="closeDeleteSession"
>
<p>
Delete <span class="mono">{{ deletingKey }}</span>? Its dispatch record and its
turn history both go: the transcript cannot be recovered, and a session
created under the same name starts empty.
</p>
<template #actions>
<button class="ghost" :disabled="deleting" @click="closeDeleteSession">
cancel
</button>
<button :disabled="deleting" @click="confirmDeleteSession">
{{ deleting ? 'deleting…' : 'delete' }}
</button>
</template>
</AppModal>
</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>