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

365 lines
8.8 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// The session inventory: the dispatch keys a human converses with,
// interactive first. Clicking selects the session the chat mirrors.
// Displayed names drop the `interactive:` prefix — the full key stays
// in the tooltip. Creating and deleting are intent only: the name (or
// the key) flows up, the shell answers, and the form closes when the
// new key lands in the inventory. The delete button asks nothing itself
// — the confirmation, which names the transcript that goes with the
// session, is the shell's.
import { SESSION_NAME_PATTERN, isResidentKey } from '~/composables/useClaw'
import type { ClawSession } from '~/composables/useClaw'
const props = defineProps<{
sessions: ClawSession[]
activeKey: string
loading: boolean
identity?: { name: string | null; tagline: string | null; mark: string | null; initials: string | null } | null
avatarUrl?: string | null
}>()
const emit = defineEmits<{
select: [key: string]
refresh: []
create: [name: string]
delete: [key: string]
}>()
interface Group {
label: string
rows: { key: string; name: string; session: ClawSession }[]
}
const groups = computed<Group[]>(() => {
const byKey = (a: ClawSession, b: ClawSession) => a.key.localeCompare(b.key)
// Only the resident prefixes are chat sessions: `interactive:` is the
// live chat surface, `task:` a scheduled task's own turn, `calendar:` a
// reminder's. The rest of the inventory has no transcript to open —
// `phone:<device>:<topic>` is status reporting, `job:<instance>#<id>`
// is the coding jobs' bookkeeping, a forge key is a thread the handler
// reports on — and the daemon's message, event and history routes
// refuse those prefixes, so a row for one is a dead end. The inventory
// hides them rather than the API narrowing.
const conversable = props.sessions.filter((session) => isResidentKey(session.key))
const interactive = conversable
.filter((session) => session.key.startsWith('interactive:'))
.sort(byKey)
const others = conversable
.filter((session) => !session.key.startsWith('interactive:'))
.sort(byKey)
const row = (session: ClawSession) => ({
key: session.key,
name: session.key.replace(/^interactive:/, ''),
session,
})
const groups: Group[] = []
if (interactive.length > 0)
groups.push({ label: 'interactive', rows: interactive.map(row) })
if (others.length > 0) groups.push({ label: 'other keys', rows: others.map(row) })
return groups
})
const shownCount = computed(() =>
groups.value.reduce((total, group) => total + group.rows.length, 0),
)
function stateTone(state: string): 'ok' | 'warn' | 'idle' {
if (state === 'leased') return 'ok'
if (state === 'reacquire') return 'warn'
return 'idle'
}
// --- new sessions -----------------------------------------------------
const creating = ref(false)
const nameDraft = ref('')
const nameInput = ref<HTMLInputElement | null>(null)
/** The name this form is waiting on; the create is in flight. */
const pendingCreate = ref<string | null>(null)
const nameValid = computed(() => SESSION_NAME_PATTERN.test(nameDraft.value))
function openCreate(): void {
creating.value = true
nameDraft.value = ''
void nextTick(() => nameInput.value?.focus())
}
function closeCreate(): void {
creating.value = false
nameDraft.value = ''
pendingCreate.value = null
}
function submitCreate(): void {
if (!nameValid.value) return
pendingCreate.value = nameDraft.value
emit('create', nameDraft.value)
}
// The form closes when the claw answers with the session in the
// inventory; a failed create answers nothing, and the form stays.
watch(
() => props.sessions,
(sessions) => {
if (
pendingCreate.value !== null &&
sessions.some((session) => session.key === `interactive:${pendingCreate.value}`)
) {
closeCreate()
}
},
)
</script>
<template>
<aside class="sidebar">
<header class="agent">
<AgentAvatar
:avatar-url="avatarUrl"
:initials="identity?.initials ?? identity?.name?.slice(0, 1)"
:size="34"
/>
<div class="agent-text">
<strong>{{ identity?.name?.trim() || 'rhema' }}</strong>
<span>{{ identity?.tagline?.trim() || 'the spoken word' }}</span>
</div>
</header>
<header class="head">
<span class="label">Sessions</span>
<span class="count">{{ shownCount }}</span>
<button class="ghost" title="new session" :disabled="creating" @click="creating ? closeCreate() : openCreate()">
+
</button>
<button class="ghost" title="refresh sessions" :disabled="loading" @click="$emit('refresh')">
↻
</button>
</header>
<form v-if="creating" class="new-session" @submit.prevent="submitCreate">
<input
ref="nameInput"
v-model="nameDraft"
type="text"
class="mono"
placeholder="session name"
autocomplete="off"
maxlength="64"
/>
<div class="new-actions">
<button type="button" class="ghost" @click="closeCreate">cancel</button>
<button type="submit" class="primary" :disabled="!nameValid">create</button>
</div>
<p v-if="nameDraft !== '' && !nameValid" class="new-note">
letters, digits, dots, dashes, underscores — up to 64.
</p>
</form>
<p v-if="shownCount === 0" class="empty">
{{ loading ? 'loading…' : 'no sessions yet; send the first message' }}
</p>
<div v-for="group in groups" :key="group.label" class="group">
<span class="label">{{ group.label }}</span>
<div
v-for="row in group.rows"
:key="row.key"
class="row"
:class="{ active: row.key === activeKey }"
:title="row.key"
>
<button class="entry" @click="$emit('select', row.key)">
<StatusDot :tone="stateTone(row.session.state)" />
<span class="name">{{ row.name }}</span>
<span v-if="row.session.inbox_depth > 0" class="depth" title="inbox depth">
{{ row.session.inbox_depth }}
</span>
</button>
<button
class="ghost remove"
aria-label="delete session"
:title="`delete ${row.key}`"
@click.stop="$emit('delete', row.key)"
>
×
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
width: 240px;
flex: none;
padding: 14px 10px;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
}
.agent {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
.agent-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.agent-text strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-text span {
color: var(--text3);
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 10px;
}
.count {
flex: 1;
color: var(--text3);
font-size: 12px;
}
.head .ghost {
padding: 0 6px;
}
.new-session {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 8px 10px;
}
.new-session input {
width: 100%;
font-size: 12.5px;
}
.new-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.new-actions button {
padding: 3px 10px;
font-size: 12px;
}
.new-note {
margin: 0;
color: var(--text3);
font-size: 11.5px;
}
.empty {
margin: 4px 8px;
color: var(--text3);
}
.group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 14px;
}
.group .label {
padding: 0 8px 4px;
}
/* The row owns the hover and active surface: the select button fills it
and the delete button sits beside it, so neither has to nest in the
other. */
.row {
display: flex;
align-items: center;
gap: 2px;
border-radius: 7px;
padding-right: 4px;
color: var(--text2);
}
.row:hover {
background: var(--elevated);
color: var(--text);
}
.row.active {
background: color-mix(in srgb, var(--accent) 17%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.entry {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
text-align: left;
background: none;
border: none;
border-radius: 7px;
padding: 7px 10px;
color: inherit;
font-weight: inherit;
}
.row .remove {
flex: none;
padding: 0 5px;
font-size: 13px;
opacity: 0;
}
.row:hover .remove,
.row:focus-within .remove,
.row.active .remove {
opacity: 1;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.depth {
flex: none;
min-width: 18px;
text-align: center;
font-size: 11px;
font-family: var(--mono);
color: var(--info);
border: 1px solid var(--border-strong);
border-radius: 999px;
padding: 0 5px;
}
</style>