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:
1 parent
8db1e2aa11
commit
994df09c8f
5 files changed
+160
-22
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)' },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user