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.
This commit is contained in:
kosmos authored and Kosmos committed 2026-11-11 16:11:07 +00:00
1 parent 8db1e2aa11
commit 994df09c8f
5 files changed
+160 -22

No files matched your search

+65 -22
View File
@@ -2,9 +2,11 @@
// 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 is intent only: the name flows up, the
// shell answers, and the form closes when the new key lands in the
// inventory.
// 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'
@@ -16,7 +18,12 @@ const props = defineProps<{
avatarUrl?: string | null
}>()
const emit = defineEmits<{ select: [key: string]; refresh: []; create: [name: string] }>()
const emit = defineEmits<{
select: [key: string]
refresh: []
create: [name: string]
delete: [key: string]
}>()
interface Group {
label: string
@@ -148,20 +155,29 @@ watch(
<div v-for="group in groups" :key="group.label" class="group">
<span class="label">{{ group.label }}</span>
<button
<div
v-for="row in group.rows"
:key="row.key"
class="entry"
class="row"
:class="{ active: row.key === activeKey }"
:title="row.key"
@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="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>
@@ -270,28 +286,55 @@ watch(
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;
width: 100%;
flex: 1;
min-width: 0;
text-align: left;
background: none;
border: none;
border-radius: 7px;
padding: 7px 10px;
color: var(--text2);
color: inherit;
font-weight: inherit;
}
.entry:hover:not(:disabled) {
background: var(--elevated);
color: var(--text);
.row .remove {
flex: none;
padding: 0 5px;
font-size: 13px;
opacity: 0;
}
.entry.active {
background: color-mix(in srgb, var(--accent) 17%, transparent);
color: var(--accent-text);
font-weight: 600;
.row:hover .remove,
.row:focus-within .remove,
.row.active .remove {
opacity: 1;
}
.name {
@@ -13,6 +13,7 @@ const routes = [
{ method: 'GET', path: '/healthz', note: 'liveness, open without a token' },
{ method: 'GET', path: '/v1/sessions', note: 'the dispatch-key inventory' },
{ method: 'POST', path: '/v1/sessions', note: 'create an interactive session from a name' },
{ method: 'DELETE', path: '/v1/sessions/{key}', note: 'delete a session and its turn history' },
{ method: 'POST', path: '/v1/sessions/{key}/messages', note: 'message ingest, 202 + stream' },
{ method: 'GET', path: '/v1/sessions/{key}/events', note: 'the turn-event stream (SSE)' },
{ method: 'POST', path: '/v1/consents/{id}', note: 'answer a consent (a secret or a grant)' },