feat: add session management (rename, archive, delete)

Implements comprehensive session lifecycle management with user-friendly interactions:

Rename: Update session metadata.name via PATCH endpoint with conflict detection
Archive: Abort active sessions via DELETE endpoint with validation
Delete: Permanently remove inactive sessions with cascade cleanup

Backend:
- Store.deleteSession() removes session and cascade-deletes messages
- SyncEngine.renameSession() with concurrency error handling
- SyncEngine.deleteSession() with active session validation
- PATCH /sessions/:id for rename, DELETE /sessions/:id for delete

Frontend Components:
- RenameSessionDialog: Text input with auto-focus and error display
- SessionActionMenu: Modal with rename, archive, delete buttons
- ConfirmDialog: Reusable confirmation with error feedback
- SessionHeader: Menu button (⋮) triggering action menu
- SessionList: Long-press detection triggering item actions

Interactions:
- Long-press on session list items (500ms threshold) opens action menu
- Menu button in session header (non-Telegram environments only)
- Confirmation dialogs with descriptive warnings for destructive actions
- Real-time error display in dialogs on operation failure
- Haptic feedback on long-press via usePlatform hook

Accessibility:
- Keyboard support (Enter/Space) for long-press handler
- Focus management in RenameSessionDialog
- Proper ARIA labels and semantic HTML
This commit is contained in:
weishu
2026-01-02 19:15:01 +08:00
parent c2d1f8c507
commit 41fd1abd53
13 changed files with 881 additions and 98 deletions
+12
View File
@@ -743,4 +743,16 @@ export class Store {
).run(platform, platformUserId)
return result.changes > 0
}
/**
* Delete a session and all associated data.
* Messages are automatically cascade-deleted via foreign key constraint.
* Todos are stored in the sessions.todos column and deleted with the row.
*/
deleteSession(id: string, namespace: string): boolean {
const result = this.db.prepare(
'DELETE FROM sessions WHERE id = ? AND namespace = ?'
).run(id, namespace)
return result.changes > 0
}
}
+50
View File
@@ -751,6 +751,56 @@ export class SyncEngine {
}
}
async renameSession(sessionId: string, name: string): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) {
throw new Error('Session not found')
}
const currentMetadata = session.metadata ?? { path: '', host: '' }
const newMetadata = { ...currentMetadata, name }
const result = this.store.updateSessionMetadata(
sessionId,
newMetadata,
session.metadataVersion,
session.namespace
)
if (result.result === 'error') {
throw new Error('Failed to update session metadata')
}
if (result.result === 'version-mismatch') {
throw new Error('Session was modified concurrently. Please try again.')
}
this.refreshSession(sessionId)
}
async deleteSession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) {
throw new Error('Session not found')
}
if (session.active) {
throw new Error('Cannot delete active session')
}
const deleted = this.store.deleteSession(sessionId, session.namespace)
if (!deleted) {
throw new Error('Failed to delete session')
}
this.sessions.delete(sessionId)
this.sessionMessages.delete(sessionId)
this.lastBroadcastAtBySessionId.delete(sessionId)
this.todoBackfillAttemptedSessionIds.delete(sessionId)
this.emit({ type: 'session-removed', sessionId, namespace: session.namespace })
}
async applySessionConfig(
sessionId: string,
config: {
+62
View File
@@ -67,6 +67,10 @@ const modelModeSchema = z.object({
model: z.enum(['default', 'sonnet', 'opus'])
})
const renameSessionSchema = z.object({
name: z.string().min(1).max(255)
})
export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
@@ -213,6 +217,64 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
}
})
app.patch('/sessions/:id', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
const body = await c.req.json().catch(() => null)
const parsed = renameSessionSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid body: name is required' }, 400)
}
try {
await engine.renameSession(sessionResult.sessionId, parsed.data.name)
return c.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to rename session'
// Map concurrency/version errors to 409 conflict
if (message.includes('concurrently') || message.includes('version')) {
return c.json({ error: message }, 409)
}
return c.json({ error: message }, 500)
}
})
app.delete('/sessions/:id', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const sessionResult = requireSessionFromParam(c, engine)
if (sessionResult instanceof Response) {
return sessionResult
}
if (sessionResult.session.active) {
return c.json({ error: 'Cannot delete active session. Archive it first.' }, 409)
}
try {
await engine.deleteSession(sessionResult.sessionId)
return c.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete session'
// Map "active session" error to 409 conflict (race condition: session became active)
if (message.includes('active')) {
return c.json({ error: message }, 409)
}
return c.json({ error: message }, 500)
}
})
app.get('/sessions/:id/slash-commands', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {