From 994df09c8f2b6e376ec1d7bab0a72880bc873257 Mon Sep 17 00:00:00 2001 From: kosmos Date: Wed, 11 Nov 2026 16:04:23 +0000 Subject: [PATCH] feat(web): delete a session from the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/claw/README.md | 8 ++ web/claw/app/app.vue | 73 ++++++++++++++++ web/claw/app/components/SessionsSidebar.vue | 87 ++++++++++++++----- .../app/components/settings/AboutPage.vue | 1 + web/claw/app/composables/useClaw.ts | 13 +++ 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/web/claw/README.md b/web/claw/README.md index 5afacfd..7037157 100644 --- a/web/claw/README.md +++ b/web/claw/README.md @@ -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 keys (`phone:…`) stay out of the inventory; the full list stays on 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 diff --git a/web/claw/app/app.vue b/web/claw/app/app.vue index cef0b93..f56fe40 100644 --- a/web/claw/app/app.vue +++ b/web/claw/app/app.vue @@ -319,6 +319,54 @@ async function createSession(name: string): Promise { void refreshSessions() } +// --- deleting a session ---------------------------------------------- +/** The key the confirmation names; null while no dialog is open. */ +const deletingKey = ref(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 { + 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 { try { providersState.value = await claw.providers() @@ -540,11 +588,15 @@ let stream: { close: () => void } | null = null function resubscribe(): void { stream?.close() + stream = null transcript.reset() olderAvailable.value = true attemptedAttachments.clear() notices.value = [] 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, { onEvent: (event, seq) => transcript.applyEvent(event, seq), // The record lost what this mirror holds (a wiped or reset @@ -672,6 +724,7 @@ onUnmounted(() => { @select="activeKey = $event" @refresh="refreshSessions" @create="createSession" + @delete="askDeleteSession" /> { + + +

+ Delete {{ deletingKey }}? Its dispatch record and its + turn history both go: the transcript cannot be recovered, and a session + created under the same name starts empty. +

+ +
diff --git a/web/claw/app/components/SessionsSidebar.vue b/web/claw/app/components/SessionsSidebar.vue index ba89365..ec23550 100644 --- a/web/claw/app/components/SessionsSidebar.vue +++ b/web/claw/app/components/SessionsSidebar.vue @@ -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(
{{ group.label }} - + + +
@@ -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 { diff --git a/web/claw/app/components/settings/AboutPage.vue b/web/claw/app/components/settings/AboutPage.vue index 8dbf840..75d311f 100644 --- a/web/claw/app/components/settings/AboutPage.vue +++ b/web/claw/app/components/settings/AboutPage.vue @@ -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)' }, diff --git a/web/claw/app/composables/useClaw.ts b/web/claw/app/composables/useClaw.ts index ae8ab42..6aebaab 100644 --- a/web/claw/app/composables/useClaw.ts +++ b/web/claw/app/composables/useClaw.ts @@ -339,6 +339,11 @@ export interface ClawApi { /** `POST /v1/sessions` — create an interactive session from a name; * 400 for a name off `SESSION_NAME_PATTERN`, 409 when it exists. */ createSession(name: string): Promise + /** `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 /** `GET /v1/consents` — the pending consents, oldest first. */ listConsents(): Promise /** `POST /v1/consents/{id}` — answer one consent; 409 when already @@ -454,6 +459,13 @@ export function useClaw(token: Ref): ClawApi { })) as NewSession } + async function deleteSession(key: string): Promise { + await send(`/v1/sessions/${encodeURIComponent(key)}`, { + method: 'DELETE', + headers: authHeaders(token.value), + }) + } + async function listConsents(): Promise { const body = (await get('/v1/consents', token.value)) as { consents: ConsentRequest[] } return body.consents @@ -722,6 +734,7 @@ export function useClaw(token: Ref): ClawApi { return { sessions, createSession, + deleteSession, listConsents, resolveConsent, denyConsent,