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.
365 lines
8.8 KiB
Vue
365 lines
8.8 KiB
Vue
<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>
|