diff --git a/server/src/store/index.ts b/server/src/store/index.ts index a18f0737..e3f59899 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -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 + } } diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index b4f00d4b..4e057249 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -751,6 +751,56 @@ export class SyncEngine { } } + async renameSession(sessionId: string, name: string): Promise { + 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 { + 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: { diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index 24ae1c25..f162f430 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -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 { const app = new Hono() @@ -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) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e89feb74..c115cd47 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -307,4 +307,17 @@ export class ApiClient { `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` ) } + + async renameSession(sessionId: string, name: string): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ name }) + }) + } + + async deleteSession(sessionId: string): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE' + }) + } } diff --git a/web/src/components/RenameSessionDialog.tsx b/web/src/components/RenameSessionDialog.tsx new file mode 100644 index 00000000..7d2a217f --- /dev/null +++ b/web/src/components/RenameSessionDialog.tsx @@ -0,0 +1,103 @@ +import { useState, useEffect, useRef } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +type RenameSessionDialogProps = { + isOpen: boolean + onClose: () => void + currentName: string + onRename: (newName: string) => Promise + isPending: boolean +} + +export function RenameSessionDialog(props: RenameSessionDialogProps) { + const { isOpen, onClose, currentName, onRename, isPending } = props + const [name, setName] = useState(currentName) + const [error, setError] = useState(null) + const inputRef = useRef(null) + + useEffect(() => { + if (isOpen) { + setName(currentName) + setError(null) + setTimeout(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, 100) + } + }, [isOpen, currentName]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const trimmed = name.trim() + if (!trimmed || trimmed === currentName) { + onClose() + return + } + setError(null) + try { + await onRename(trimmed) + onClose() + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to rename. Please try again.' + setError(message) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + return ( + !open && onClose()}> + + + Rename Session + +
+ setName(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Session name" + className="w-full px-3 py-2.5 rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] placeholder:text-[var(--app-hint)] focus:outline-none focus:ring-2 focus:ring-[var(--app-button)] focus:border-transparent" + disabled={isPending} + maxLength={255} + /> + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+
+
+
+ ) +} diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx new file mode 100644 index 00000000..191a72af --- /dev/null +++ b/web/src/components/SessionActionMenu.tsx @@ -0,0 +1,139 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +type SessionActionMenuProps = { + isOpen: boolean + onClose: () => void + sessionActive: boolean + onRename: () => void + onArchive: () => void + onDelete: () => void +} + +function EditIcon(props: { className?: string }) { + return ( + + + + + ) +} + +function ArchiveIcon(props: { className?: string }) { + return ( + + + + + + ) +} + +function TrashIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + +export function SessionActionMenu(props: SessionActionMenuProps) { + const { isOpen, onClose, sessionActive, onRename, onArchive, onDelete } = props + + const handleRename = () => { + onClose() + onRename() + } + + const handleArchive = () => { + onClose() + onArchive() + } + + const handleDelete = () => { + onClose() + onDelete() + } + + return ( + !open && onClose()}> + + + Session Actions + +
+ + + {sessionActive ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index f53de378..24523949 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -143,6 +143,8 @@ export function SessionChat(props: { session={props.session} onBack={props.onBack} onViewFiles={props.session.metadata?.path ? handleViewFiles : undefined} + api={props.api} + onSessionDeleted={props.onBack} /> {controlsDisabled ? ( diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 9e790fda..53e6ea14 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -1,6 +1,11 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import type { Session } from '@/types/api' +import type { ApiClient } from '@/api/client' import { isTelegramApp } from '@/hooks/useTelegram' +import { useSessionActions } from '@/hooks/mutations/useSessionActions' +import { SessionActionMenu } from '@/components/SessionActionMenu' +import { RenameSessionDialog } from '@/components/RenameSessionDialog' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' function getSessionTitle(session: Session): string { if (session.metadata?.name) { @@ -36,13 +41,45 @@ function FilesIcon(props: { className?: string }) { ) } +function MoreVerticalIcon(props: { className?: string }) { + return ( + + + + + + ) +} + export function SessionHeader(props: { session: Session onBack: () => void onViewFiles?: () => void + api: ApiClient | null + onSessionDeleted?: () => void }) { - const title = useMemo(() => getSessionTitle(props.session), [props.session]) - const worktreeBranch = props.session.metadata?.worktree?.branch + const { session, api, onSessionDeleted } = props + const title = useMemo(() => getSessionTitle(session), [session]) + const worktreeBranch = session.metadata?.worktree?.branch + + const [menuOpen, setMenuOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [archiveOpen, setArchiveOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + const { abortSession, renameSession, deleteSession, isPending } = useSessionActions(api, session.id) + + const handleDelete = async () => { + await deleteSession() + onSessionDeleted?.() + } // In Telegram, don't render header (Telegram provides its own) if (isTelegramApp()) { @@ -50,51 +87,103 @@ export function SessionHeader(props: { } return ( -
-
- {/* Back button */} - - - {/* Session info - two lines: title and path */} -
-
- {title} -
-
- {props.session.metadata?.path ?? props.session.id} - {worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''} -
-
- - {props.onViewFiles ? ( + <> +
+
+ {/* Back button */} - ) : null} + + {/* Session info - two lines: title and path */} +
+
+ {title} +
+
+ {session.metadata?.path ?? session.id} + {worktreeBranch ? ` • worktree: ${worktreeBranch}` : ''} +
+
+ + {props.onViewFiles ? ( + + ) : null} + + +
-
+ + setMenuOpen(false)} + sessionActive={session.active} + onRename={() => setRenameOpen(true)} + onArchive={() => setArchiveOpen(true)} + onDelete={() => setDeleteOpen(true)} + /> + + setRenameOpen(false)} + currentName={title} + onRename={renameSession} + isPending={isPending} + /> + + setArchiveOpen(false)} + title="Archive Session" + description={`Are you sure you want to archive "${title}"? This will disconnect the active session.`} + confirmLabel="Archive" + confirmingLabel="Archiving..." + onConfirm={abortSession} + isPending={isPending} + destructive + /> + + setDeleteOpen(false)} + title="Delete Session" + description={`Are you sure you want to delete "${title}"? This action cannot be undone.`} + confirmLabel="Delete" + confirmingLabel="Deleting..." + onConfirm={handleDelete} + isPending={isPending} + destructive + /> + ) } diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 4849ea1e..794c8253 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -1,5 +1,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { SessionSummary } from '@/types/api' +import type { ApiClient } from '@/api/client' +import { useLongPress } from '@/hooks/useLongPress' +import { usePlatform } from '@/hooks/usePlatform' +import { useSessionActions } from '@/hooks/mutations/useSessionActions' +import { SessionActionMenu } from '@/components/SessionActionMenu' +import { RenameSessionDialog } from '@/components/RenameSessionDialog' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' type SessionGroup = { directory: string @@ -161,64 +168,128 @@ function SessionItem(props: { session: SessionSummary onSelect: (sessionId: string) => void showPath?: boolean + api: ApiClient | null }) { - const { session: s, onSelect, showPath = true } = props + const { session: s, onSelect, showPath = true, api } = props + const { haptic } = usePlatform() + const [menuOpen, setMenuOpen] = useState(false) + const [renameOpen, setRenameOpen] = useState(false) + const [archiveOpen, setArchiveOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + const { abortSession, renameSession, deleteSession, isPending } = useSessionActions(api, s.id) + + const longPressHandlers = useLongPress({ + onLongPress: () => { + haptic.impact('medium') + setMenuOpen(true) + }, + onClick: () => onSelect(s.id), + threshold: 500 + }) + + const sessionName = getSessionTitle(s) + return ( -
- {showPath ? ( -
- {s.metadata?.path ?? s.id} -
- ) : null} -
- - - {getAgentLabel(s)} - - model: {getModelLabel(s)} - {s.metadata?.worktree?.branch ? ( - worktree: {s.metadata.worktree.branch} + {showPath ? ( +
+ {s.metadata?.path ?? s.id} +
) : null} -
- +
+ + + {getAgentLabel(s)} + + model: {getModelLabel(s)} + {s.metadata?.worktree?.branch ? ( + worktree: {s.metadata.worktree.branch} + ) : null} +
+ + + setMenuOpen(false)} + sessionActive={s.active} + onRename={() => setRenameOpen(true)} + onArchive={() => setArchiveOpen(true)} + onDelete={() => setDeleteOpen(true)} + /> + + setRenameOpen(false)} + currentName={sessionName} + onRename={renameSession} + isPending={isPending} + /> + + setArchiveOpen(false)} + title="Archive Session" + description={`Are you sure you want to archive "${sessionName}"? This will disconnect the active session.`} + confirmLabel="Archive" + confirmingLabel="Archiving..." + onConfirm={abortSession} + isPending={isPending} + destructive + /> + + setDeleteOpen(false)} + title="Delete Session" + description={`Are you sure you want to delete "${sessionName}"? This action cannot be undone.`} + confirmLabel="Delete" + confirmingLabel="Deleting..." + onConfirm={deleteSession} + isPending={isPending} + destructive + /> + ) } @@ -229,8 +300,9 @@ export function SessionList(props: { onRefresh: () => void isLoading: boolean renderHeader?: boolean + api: ApiClient | null }) { - const { renderHeader = true } = props + const { renderHeader = true, api } = props const groups = useMemo( () => groupSessionsByDirectory(props.sessions), [props.sessions] @@ -317,6 +389,7 @@ export function SessionList(props: { session={s} onSelect={props.onSelect} showPath={false} + api={api} /> ))} diff --git a/web/src/components/ui/ConfirmDialog.tsx b/web/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000..8b16b72b --- /dev/null +++ b/web/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +type ConfirmDialogProps = { + isOpen: boolean + onClose: () => void + title: string + description: string + confirmLabel: string + confirmingLabel: string + onConfirm: () => Promise + isPending: boolean + destructive?: boolean +} + +export function ConfirmDialog(props: ConfirmDialogProps) { + const { + isOpen, + onClose, + title, + description, + confirmLabel, + confirmingLabel, + onConfirm, + isPending, + destructive = false + } = props + + const [error, setError] = useState(null) + + // Clear error when dialog opens/closes + useEffect(() => { + if (isOpen) { + setError(null) + } + }, [isOpen]) + + const handleConfirm = async () => { + setError(null) + try { + await onConfirm() + onClose() + } catch (err) { + const message = err instanceof Error ? err.message : 'Operation failed. Please try again.' + setError(message) + } + } + + return ( + !open && onClose()}> + + + {title} + + {description} + + + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+
+
+ ) +} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 28c10b3e..bc54476d 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -25,6 +25,8 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setModelMode: (mode: ModelMode) => Promise + renameSession: (name: string) => Promise + deleteSession: () => Promise isPending: boolean } { const queryClient = useQueryClient() @@ -75,11 +77,38 @@ export function useSessionActions(api: ApiClient | null, sessionId: string | nul onSuccess: () => void invalidateSession(), }) + const renameMutation = useMutation({ + mutationFn: async (name: string) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.renameSession(sessionId, name) + }, + onSuccess: () => void invalidateSession(), + }) + + const deleteMutation = useMutation({ + mutationFn: async () => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + await api.deleteSession(sessionId) + }, + onSuccess: async () => { + if (!sessionId) return + queryClient.removeQueries({ queryKey: queryKeys.session(sessionId) }) + queryClient.removeQueries({ queryKey: queryKeys.messages(sessionId) }) + await queryClient.invalidateQueries({ queryKey: queryKeys.sessions }) + }, + }) + return { abortSession: abortMutation.mutateAsync, switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, setModelMode: modelMutation.mutateAsync, - isPending: abortMutation.isPending || switchMutation.isPending || permissionMutation.isPending || modelMutation.isPending, + renameSession: renameMutation.mutateAsync, + deleteSession: deleteMutation.mutateAsync, + isPending: abortMutation.isPending || switchMutation.isPending || permissionMutation.isPending || modelMutation.isPending || renameMutation.isPending || deleteMutation.isPending, } } diff --git a/web/src/hooks/useLongPress.ts b/web/src/hooks/useLongPress.ts new file mode 100644 index 00000000..92a6532e --- /dev/null +++ b/web/src/hooks/useLongPress.ts @@ -0,0 +1,116 @@ +import type React from 'react' +import { useCallback, useRef } from 'react' + +type UseLongPressOptions = { + onLongPress: () => void + onClick?: () => void + threshold?: number + disabled?: boolean +} + +type UseLongPressHandlers = { + onMouseDown: React.MouseEventHandler + onMouseUp: React.MouseEventHandler + onMouseLeave: React.MouseEventHandler + onTouchStart: React.TouchEventHandler + onTouchEnd: React.TouchEventHandler + onTouchMove: React.TouchEventHandler + onContextMenu: React.MouseEventHandler + onKeyDown: React.KeyboardEventHandler +} + +export function useLongPress(options: UseLongPressOptions): UseLongPressHandlers { + const { onLongPress, onClick, threshold = 500, disabled = false } = options + + const timerRef = useRef | null>(null) + const isLongPressRef = useRef(false) + const touchMoved = useRef(false) + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + const startTimer = useCallback(() => { + if (disabled) return + + clearTimer() + isLongPressRef.current = false + touchMoved.current = false + + timerRef.current = setTimeout(() => { + isLongPressRef.current = true + onLongPress() + }, threshold) + }, [disabled, clearTimer, onLongPress, threshold]) + + const handleEnd = useCallback((shouldTriggerClick: boolean) => { + clearTimer() + + if (shouldTriggerClick && !isLongPressRef.current && !touchMoved.current && onClick) { + onClick() + } + + isLongPressRef.current = false + touchMoved.current = false + }, [clearTimer, onClick]) + + const onMouseDown = useCallback((e) => { + if (e.button !== 0) return + startTimer() + }, [startTimer]) + + const onMouseUp = useCallback(() => { + handleEnd(true) + }, [handleEnd]) + + const onMouseLeave = useCallback(() => { + handleEnd(false) + }, [handleEnd]) + + const onTouchStart = useCallback(() => { + startTimer() + }, [startTimer]) + + const onTouchEnd = useCallback((e) => { + if (isLongPressRef.current) { + e.preventDefault() + } + handleEnd(true) + }, [handleEnd]) + + const onTouchMove = useCallback(() => { + touchMoved.current = true + clearTimer() + }, [clearTimer]) + + const onContextMenu = useCallback((e) => { + if (!disabled) { + e.preventDefault() + clearTimer() + isLongPressRef.current = true + onLongPress() + } + }, [disabled, clearTimer, onLongPress]) + + const onKeyDown = useCallback((e) => { + if (disabled) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick?.() + } + }, [disabled, onClick]) + + return { + onMouseDown, + onMouseUp, + onMouseLeave, + onTouchStart, + onTouchEnd, + onTouchMove, + onContextMenu, + onKeyDown + } +} diff --git a/web/src/router.tsx b/web/src/router.tsx index f34e957f..38ac17f4 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -110,6 +110,7 @@ function SessionsPage() { onRefresh={handleRefresh} isLoading={isLoading} renderHeader={false} + api={api} />