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

+8
View File
@@ -122,6 +122,14 @@ Two consequences worth knowing before changing this code:
lets 400/409 land in the error banner. The phone client's status lets 400/409 land in the error banner. The phone client's status
keys (`phone:…`) stay out of the inventory; the full list stays on keys (`phone:…`) stay out of the inventory; the full list stays on
the wire. the wire.
- **Deleting a session is a decision, not a click.** The row's delete
button only names the key; the shell's dialog spells out that the
turn history goes with it, and `DELETE /v1/sessions/{key}` runs on
confirm — so a key created again under the same name starts empty.
A 409 (a turn holds the key) and a 404 (already gone) land in the
error banner like every other refusal, and the chat moves to the
first remaining session, the stream's watcher following the key so
nothing keeps streaming a session that no longer exists.
## Layout ## Layout
+73
View File
@@ -319,6 +319,54 @@ async function createSession(name: string): Promise<void> {
void refreshSessions() 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) => isInteractive(session.key)) ?? remaining[0]
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> { async function refreshProviders(): Promise<void> {
try { try {
providersState.value = await claw.providers() providersState.value = await claw.providers()
@@ -540,11 +588,15 @@ let stream: { close: () => void } | null = null
function resubscribe(): void { function resubscribe(): void {
stream?.close() stream?.close()
stream = null
transcript.reset() transcript.reset()
olderAvailable.value = true olderAvailable.value = true
attemptedAttachments.clear() attemptedAttachments.clear()
notices.value = [] notices.value = []
streamStatus.value = 'connecting' 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, { stream = claw.subscribeEvents(activeKey.value, {
onEvent: (event, seq) => transcript.applyEvent(event, seq), onEvent: (event, seq) => transcript.applyEvent(event, seq),
// The record lost what this mirror holds (a wiped or reset // The record lost what this mirror holds (a wiped or reset
@@ -672,6 +724,7 @@ onUnmounted(() => {
@select="activeKey = $event" @select="activeKey = $event"
@refresh="refreshSessions" @refresh="refreshSessions"
@create="createSession" @create="createSession"
@delete="askDeleteSession"
/> />
<ChatPanel <ChatPanel
:items="transcriptItems" :items="transcriptItems"
@@ -740,6 +793,26 @@ onUnmounted(() => {
</template> </template>
</main> </main>
</div> </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> </div>
</template> </template>
+65 -22
View File
@@ -2,9 +2,11 @@
// The session inventory: the dispatch keys a human converses with, // The session inventory: the dispatch keys a human converses with,
// interactive first. Clicking selects the session the chat mirrors. // interactive first. Clicking selects the session the chat mirrors.
// Displayed names drop the `interactive:` prefix — the full key stays // Displayed names drop the `interactive:` prefix — the full key stays
// in the tooltip. Creating is intent only: the name flows up, the // in the tooltip. Creating and deleting are intent only: the name (or
// shell answers, and the form closes when the new key lands in the // the key) flows up, the shell answers, and the form closes when the
// inventory. // 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 { SESSION_NAME_PATTERN } from '~/composables/useClaw'
import type { ClawSession } from '~/composables/useClaw' import type { ClawSession } from '~/composables/useClaw'
@@ -16,7 +18,12 @@ const props = defineProps<{
avatarUrl?: string | null 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 { interface Group {
label: string label: string
@@ -148,20 +155,29 @@ watch(
<div v-for="group in groups" :key="group.label" class="group"> <div v-for="group in groups" :key="group.label" class="group">
<span class="label">{{ group.label }}</span> <span class="label">{{ group.label }}</span>
<button <div
v-for="row in group.rows" v-for="row in group.rows"
:key="row.key" :key="row.key"
class="entry" class="row"
:class="{ active: row.key === activeKey }" :class="{ active: row.key === activeKey }"
:title="row.key" :title="row.key"
@click="$emit('select', row.key)"
> >
<StatusDot :tone="stateTone(row.session.state)" /> <button class="entry" @click="$emit('select', row.key)">
<span class="name">{{ row.name }}</span> <StatusDot :tone="stateTone(row.session.state)" />
<span v-if="row.session.inbox_depth > 0" class="depth" title="inbox depth"> <span class="name">{{ row.name }}</span>
{{ row.session.inbox_depth }} <span v-if="row.session.inbox_depth > 0" class="depth" title="inbox depth">
</span> {{ row.session.inbox_depth }}
</button> </span>
</button>
<button
class="ghost remove"
aria-label="delete session"
:title="`delete ${row.key}`"
@click.stop="$emit('delete', row.key)"
>
×
</button>
</div>
</div> </div>
</aside> </aside>
</template> </template>
@@ -270,28 +286,55 @@ watch(
padding: 0 8px 4px; 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 { .entry {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; flex: 1;
min-width: 0;
text-align: left; text-align: left;
background: none; background: none;
border: none; border: none;
border-radius: 7px; border-radius: 7px;
padding: 7px 10px; padding: 7px 10px;
color: var(--text2); color: inherit;
font-weight: inherit;
} }
.entry:hover:not(:disabled) { .row .remove {
background: var(--elevated); flex: none;
color: var(--text); padding: 0 5px;
font-size: 13px;
opacity: 0;
} }
.entry.active { .row:hover .remove,
background: color-mix(in srgb, var(--accent) 17%, transparent); .row:focus-within .remove,
color: var(--accent-text); .row.active .remove {
font-weight: 600; opacity: 1;
} }
.name { .name {
@@ -13,6 +13,7 @@ const routes = [
{ method: 'GET', path: '/healthz', note: 'liveness, open without a token' }, { method: 'GET', path: '/healthz', note: 'liveness, open without a token' },
{ method: 'GET', path: '/v1/sessions', note: 'the dispatch-key inventory' }, { method: 'GET', path: '/v1/sessions', note: 'the dispatch-key inventory' },
{ method: 'POST', path: '/v1/sessions', note: 'create an interactive session from a name' }, { 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: '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: '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)' }, { method: 'POST', path: '/v1/consents/{id}', note: 'answer a consent (a secret or a grant)' },
+13
View File
@@ -339,6 +339,11 @@ export interface ClawApi {
/** `POST /v1/sessions` — create an interactive session from a name; /** `POST /v1/sessions` — create an interactive session from a name;
* 400 for a name off `SESSION_NAME_PATTERN`, 409 when it exists. */ * 400 for a name off `SESSION_NAME_PATTERN`, 409 when it exists. */
createSession(name: string): Promise<NewSession> createSession(name: string): Promise<NewSession>
/** `DELETE /v1/sessions/{key}` — delete a session, its dispatch record
* and the turn history recorded under it, so a key re-created under the
* same name starts empty. 409 while a turn holds the key, 404 when it is
* already gone. */
deleteSession(key: string): Promise<void>
/** `GET /v1/consents` — the pending consents, oldest first. */ /** `GET /v1/consents` — the pending consents, oldest first. */
listConsents(): Promise<ConsentRequest[]> listConsents(): Promise<ConsentRequest[]>
/** `POST /v1/consents/{id}` — answer one consent; 409 when already /** `POST /v1/consents/{id}` — answer one consent; 409 when already
@@ -454,6 +459,13 @@ export function useClaw(token: Ref<string | null>): ClawApi {
})) as NewSession })) as NewSession
} }
async function deleteSession(key: string): Promise<void> {
await send(`/v1/sessions/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: authHeaders(token.value),
})
}
async function listConsents(): Promise<ConsentRequest[]> { async function listConsents(): Promise<ConsentRequest[]> {
const body = (await get('/v1/consents', token.value)) as { consents: ConsentRequest[] } const body = (await get('/v1/consents', token.value)) as { consents: ConsentRequest[] }
return body.consents return body.consents
@@ -722,6 +734,7 @@ export function useClaw(token: Ref<string | null>): ClawApi {
return { return {
sessions, sessions,
createSession, createSession,
deleteSession,
listConsents, listConsents,
resolveConsent, resolveConsent,
denyConsent, denyConsent,