Files
rhema-claw/web/claw/app/components/SessionsSidebar.vue
T
kosmos 994df09c8f feat(web): delete a session from the sidebar
A session could be created from the sidebar but never removed, so the
chat surface accumulated every experiment anyone ever named. Each row
now carries a small delete affordance that only names the key: the
confirmation is the shell's, and it says the turn history goes with
the session before calling the new route. On confirm the chat moves to
the first remaining session — the stream's watcher follows the key —
so nothing keeps streaming a session that no longer exists.

The row had to stop being a single button, since a nested button is
invalid HTML: the select button and the delete button now sit in a
container that owns the hover and active surface.
2026-11-11 16:11:07 +00:00

360 lines
8.4 KiB
Vue
Raw 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 } 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)
// `phone:<device>:<topic>` keys carry the phone client's status
// reporting (battery, network, dnd), not conversation; the inventory
// hides them rather than the API narrowing.
const conversable = props.sessions.filter((session) => !session.key.startsWith('phone:'))
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>