diff --git a/server/src/conversations.ts b/server/src/conversations.ts index 788ffb3..b98cbae 100644 --- a/server/src/conversations.ts +++ b/server/src/conversations.ts @@ -121,9 +121,17 @@ function load(): StoredConversation[] { return cache! } +/** Written to a temporary file and renamed into place, so the file on disk + * is always a whole snapshot. Written in place, a crash or a reader in the + * middle of the write saw an empty file, and load() treats an unreadable + * file as no conversations; the next save would have kept it that way. */ function persist(): void { const snapshot = JSON.stringify(load(), null, 1) - writing = writing.then(() => fsp.writeFile(FILE, snapshot)).catch(() => {}) + const tmp = `${FILE}.${process.pid}.tmp` + writing = writing.then(async () => { + await fsp.writeFile(tmp, snapshot) + await fsp.rename(tmp, FILE) + }).catch(() => {}) } const summary = (c: StoredConversation) => ({ @@ -242,6 +250,28 @@ export async function conversationRoutes(app: FastifyInstance): Promise { return summary(c) }) + // Bulk delete for the chat rail's selection mode: one write and one + // reindex for any number of conversations. Only the caller's own (or + // unowned) conversations go; others it can see under shared history are + // reported as skipped, not deleted. + app.post('/api/chat/conversations/delete', async req => { + const raw = (req.body as { ids?: unknown } | null)?.ids + if (!Array.isArray(raw) || !raw.length) throw new KbError(400, 'ids must be a non-empty list') + const ids = new Set(raw.map(String).slice(0, 1000)) + const me = req.user?.username ?? null + const mine = (c: StoredConversation) => visibleTo(c, me) && (!authEnabled() || !c.owner || c.owner === me) + const gone = load().filter(c => ids.has(c.id) && mine(c)) + const goneIds = new Set(gone.map(c => c.id)) + if (gone.length) { + cache = load().filter(c => !goneIds.has(c.id)) + persist() + await Promise.all(gone.map(c => fsp.rm(exportPath(c), { force: true }).catch(() => {}))) + if (indexTimer) clearTimeout(indexTimer) + indexTimer = setTimeout(() => { void reindexForDir(EXPORT_DIR).catch(() => {}) }, 2_000) + } + return { deleted: [...goneIds], skipped: [...ids].filter(id => !goneIds.has(id)) } + }) + app.delete('/api/chat/conversations/:id', async req => { const id = (req.params as { id: string }).id const gone = load().find(c => c.id === id) diff --git a/server/test/bulkDelete.test.mjs b/server/test/bulkDelete.test.mjs new file mode 100644 index 0000000..0eaa90a --- /dev/null +++ b/server/test/bulkDelete.test.mjs @@ -0,0 +1,51 @@ +// Bulk deletion of chat conversations: one request removes many, and only +// the caller's own (or unowned) conversations are removed. +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +const IX = fs.mkdtempSync(path.join(os.tmpdir(), 'bulk-ix-')) +process.env.KB_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'bulk-kb-')) +process.env.INDEX_BASE = IX +// Auth on, so ownership applies; nothing is fetched from this URL. +process.env.AUTH_JWKS_URL = 'https://keys.example.invalid/jwks' +const conv = (id, owner) => ({ id, title: id, owner, createdAt: '2026-10-01T00:00:00Z', updatedAt: '2026-10-01T00:00:00Z', activeBranchId: null, messages: [] }) +fs.writeFileSync(path.join(IX, 'conversations.json'), JSON.stringify([ + conv('a', 'me'), conv('b', 'me'), conv('c', 'me'), conv('theirs', 'someone-else'), conv('legacy', null), +])) + +const { default: Fastify } = await import('fastify') +const { sanitizedErrorHandler } = await import('../dist/routes.js') +const { conversationRoutes } = await import('../dist/conversations.js') +const app = Fastify() +app.setErrorHandler(sanitizedErrorHandler(app)) +app.addHook('onRequest', async req => { req.user = { id: 'u-me', username: 'me' } }) +await app.register(conversationRoutes) +await app.ready() + +test('deletes the listed own and unowned conversations in one request, skipping the rest', async () => { + const res = await app.inject({ method: 'POST', url: '/api/chat/conversations/delete', payload: { ids: ['a', 'b', 'theirs', 'legacy', 'nope'] } }) + assert.equal(res.statusCode, 200) + const body = res.json() + assert.deepEqual(body.deleted.sort(), ['a', 'b', 'legacy']) + assert.deepEqual(body.skipped.sort(), ['nope', 'theirs']) + // Shared history is on by default, so the other person's conversation was + // visible to this caller, and still not deleted. + const left = (await app.inject({ method: 'GET', url: '/api/chat/conversations' })).json().map(c => c.id).sort() + assert.deepEqual(left, ['c', 'theirs']) + // The save is asynchronous; the file always holds a whole snapshot, old or new. + let stored = [] + for (let i = 0; i < 50; i++) { + stored = JSON.parse(fs.readFileSync(path.join(IX, 'conversations.json'), 'utf8')).map(c => c.id).sort() + if (stored.length === 2) break + await new Promise(r => setTimeout(r, 20)) + } + assert.deepEqual(stored, ['c', 'theirs']) +}) + +test('an empty or missing list is refused', async () => { + assert.equal((await app.inject({ method: 'POST', url: '/api/chat/conversations/delete', payload: { ids: [] } })).statusCode, 400) + assert.equal((await app.inject({ method: 'POST', url: '/api/chat/conversations/delete', payload: {} })).statusCode, 400) +}) diff --git a/web/src/adapter.ts b/web/src/adapter.ts index c829753..7be7b3b 100644 --- a/web/src/adapter.ts +++ b/web/src/adapter.ts @@ -148,6 +148,22 @@ function decorateConversations(rows: T[]): T[] { : c) } +/** Whether the viewer may delete a conversation: their own, or one with + * no owner. The server applies the same rule. */ +export function isOwnConversation(c: { owner?: string | null }): boolean { + return !c.owner || !viewerUsername || c.owner === viewerUsername +} + +/** Deletes many conversations in one request; ids the viewer may not + * delete come back as skipped. */ +export async function deleteConversations(ids: string[]): Promise<{ deleted: string[]; skipped: string[] }> { + return fetch('/api/chat/conversations/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }).then(r => json<{ deleted: string[]; skipped: string[] }>(r)) +} + export function createStudioAdapter(): ChatAdapter { return { // Conversations live server-side beside the index; browser storage is diff --git a/web/src/components/ChatsManager.tsx b/web/src/components/ChatsManager.tsx new file mode 100644 index 0000000..5b31c7c --- /dev/null +++ b/web/src/components/ChatsManager.tsx @@ -0,0 +1,138 @@ +import { useMemo, useRef, useState } from 'react' +import { useChat } from '@parallelworks/ai-chat' +import { deleteConversations, isOwnConversation } from '../adapter' + +/** + * Selecting and deleting past conversations in bulk. The chat package's + * rail deletes one at a time; until it can select several, this panel + * stands in, in the main pane like the package's own Attachments page. + */ + +type Row = { id: string; title?: string; preview?: string; updatedAt?: string; createdAt: string; messageCount: number; owner?: string | null } + +const when = (iso?: string) => { + if (!iso) return '' + const d = new Date(iso) + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }) +} + +export function ChatsManager({ activeId, onOpen, onActiveDeleted, onClose }: { + activeId: string | null + onOpen: (id: string) => void + onActiveDeleted: () => void + onClose: () => void +}) { + const { conversations, loadConversations } = useChat() + const rows = conversations as unknown as Row[] + const [filter, setFilter] = useState('') + const [picked, setPicked] = useState>(new Set()) + const [confirming, setConfirming] = useState(false) + const [busy, setBusy] = useState(false) + const [note, setNote] = useState('') + const lastClicked = useRef(null) + + const shown = useMemo(() => { + const q = filter.trim().toLowerCase() + return rows.filter(c => !q || (c.title ?? '').toLowerCase().includes(q) || (c.preview ?? '').toLowerCase().includes(q)) + }, [rows, filter]) + const deletable = shown.filter(isOwnConversation) + const pickedShown = deletable.filter(c => picked.has(c.id)) + const allShownPicked = deletable.length > 0 && pickedShown.length === deletable.length + + const toggle = (index: number, shift: boolean) => { + const c = shown[index] + if (!isOwnConversation(c)) return + setConfirming(false) + // Read before the update: React runs the updater later, after the ref moves. + const anchor = shift ? lastClicked.current : null + setPicked(prev => { + const next = new Set(prev) + const on = !prev.has(c.id) + // Shift-click sets every deletable row between the last click and this one. + const from = anchor !== null ? Math.min(anchor, index) : index + const to = anchor !== null ? Math.max(anchor, index) : index + for (let i = from; i <= to; i++) { + const r = shown[i] + if (!isOwnConversation(r)) continue + if (on) next.add(r.id); else next.delete(r.id) + } + return next + }) + lastClicked.current = index + } + const toggleAll = () => { + setConfirming(false) + setPicked(prev => { + const next = new Set(prev) + if (allShownPicked) deletable.forEach(c => next.delete(c.id)) + else deletable.forEach(c => next.add(c.id)) + return next + }) + } + + const remove = async () => { + const ids = [...picked] + setBusy(true); setNote('') + try { + const r = await deleteConversations(ids) + if (activeId && r.deleted.includes(activeId)) onActiveDeleted() + setPicked(new Set()) + setConfirming(false) + await loadConversations() + setNote(`Deleted ${r.deleted.length} conversation${r.deleted.length === 1 ? '' : 's'}.${r.skipped.length ? ` ${r.skipped.length} could not be deleted.` : ''}`) + } catch (e) { + setNote(`Delete failed: ${(e as Error).message}`) + } finally { setBusy(false) } + } + + return ( +
+
+

Conversations

+ {rows.length} + +
+
+ + setFilter(e.target.value)} /> + {!confirming + ? + : + Delete {picked.size} conversation{picked.size === 1 ? '' : 's'} and their transcripts? This cannot be undone. + + + } +
+ {note &&

{note}

} +
    + {shown.map((c, i) => { + const own = isOwnConversation(c) + return ( +
  • + { /* handled on click, which carries the shift key */ }} + onClick={e => toggle(i, e.shiftKey)} + /> + +
  • + ) + })} + {!shown.length &&
  • {rows.length ? 'No conversation matches.' : 'No conversations yet.'}
  • } +
+
+ ) +} diff --git a/web/src/components/ManageChatsRailItem.tsx b/web/src/components/ManageChatsRailItem.tsx new file mode 100644 index 0000000..b9f6f94 --- /dev/null +++ b/web/src/components/ManageChatsRailItem.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' + +/** + * A "Select chats" row in the chat package's rail, under New chat and + * Attachments. The package has no slot for extra rows, so this finds that + * group in the rendered rail and portals a row styled like its siblings + * into it, re-attaching if the package re-renders the group. It copies the + * label class from the New chat row so it collapses with the rail. + */ + +const NEW_CHAT = 'button[title="New chat"]' + +export function ManageChatsRailItem({ active, onSelect }: { active: boolean; onSelect: () => void }) { + const [host, setHost] = useState(null) + const [labelClass, setLabelClass] = useState('text-sm') + + useEffect(() => { + const canvas = document.querySelector('.chat-canvas') + if (!canvas) return + const el = document.createElement('div') + el.className = 'ade-rail-extra' + const place = () => { + const anchor = canvas.querySelector(NEW_CHAT) + const group = anchor?.parentElement + if (!group) return + if (el.parentElement !== group) group.appendChild(el) + const span = anchor.querySelector('span') + if (span && span.className !== labelClass) setLabelClass(span.className) + setHost(el) + } + place() + const watch = new MutationObserver(place) + watch.observe(canvas, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }) + return () => { watch.disconnect(); el.remove() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (!host) return null + return createPortal( + , + host, + ) +} diff --git a/web/src/styles.css b/web/src/styles.css index 21a5e1b..d226b46 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1885,3 +1885,31 @@ code, pre, kbd, .text-body, .viewer-path, .sql-box, [data-streamdown='code-block /* The Agents tabs no longer sit under a title. */ .agents-view .ov-head { padding: 0 24px; } .hist-view > .empty-state { grid-column: 1 / -1; } + +/* Chat rail: Select chats, and the bulk selection panel it opens. */ +.ade-rail-extra { display: contents; } +.ade-rail-select { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; text-align: left; } +.ade-rail-select.active { background: color-mix(in srgb, var(--pw-text) 9%, var(--pw-panel)); } +.chats-manager { height: 100%; overflow-y: auto; padding: 18px 24px 32px; box-sizing: border-box; } +.chats-manager-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; } +.chats-manager-head h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--pw-text); } +.chats-manager-close { margin-left: auto; } +.chats-manager-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding-bottom: 10px; border-bottom: 1px solid var(--pw-border); position: sticky; top: -18px; background: var(--pw-panel); z-index: 1; padding-top: 6px; } +.chats-check-all { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--pw-text); white-space: nowrap; } +.chats-filter { flex: 1; min-width: 160px; max-width: 360px; } +.chats-confirm { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--pw-text); } +.chats-note { margin: 8px 0 0; font-size: 12.5px; } +.chats-list { list-style: none; margin: 0; padding: 0; } +.chats-row { display: flex; align-items: center; gap: 10px; padding: 2px 4px; border-bottom: 1px solid var(--pw-border); } +.chats-row.picked { background: color-mix(in srgb, var(--pw-text) 6%, var(--pw-panel)); } +.chats-row.locked { opacity: 0.7; } +.chats-row input[type=checkbox] { flex-shrink: 0; width: 15px; height: 15px; } +.chats-row-open { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 12px; padding: 8px 4px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.chats-row-open:hover .chats-row-title { text-decoration: underline; } +.chats-row-title { flex: 1; min-width: 0; font-size: 13.5px; color: var(--pw-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.chats-row-meta { flex-shrink: 0; font-size: 12px; color: var(--pw-muted); } +.chats-empty { padding: 16px 4px; font-size: 13px; } +@media (max-width: 720px) { + .chats-manager { padding: 12px 14px 24px; } + .chats-row-meta { display: none; } +} diff --git a/web/src/views/ChatView.tsx b/web/src/views/ChatView.tsx index 3fa907d..e28cce8 100644 --- a/web/src/views/ChatView.tsx +++ b/web/src/views/ChatView.tsx @@ -7,6 +7,8 @@ import { rememberHash } from '../lastLocation' import { ChatProvider, ChatLayout, ChatThread, ChatEmptyState, AttachmentManager, useChat, } from '@parallelworks/ai-chat' +import { ChatsManager } from '../components/ChatsManager' +import { ManageChatsRailItem } from '../components/ManageChatsRailItem' import { createStudioAdapter, getChatListFilter, setChatListFilter, setViewerUsername } from '../adapter' import { useAppConfig } from '../config' import { api, fetchModels, forgetModels, type ModelsResponse } from '../api' @@ -61,6 +63,7 @@ export function ChatView() { rememberHash(next) }, []) const [showAttachments, setShowAttachments] = useState(false) + const [showManage, setShowManage] = useState(false) // Width of the Activity (thinking) drawer on the right; the package // renders it fixed at 400px, and our CSS reads this variable over it. const [thinkRail, setThinkRail] = useState(() => Number(localStorage.getItem('ade-think-rail')) || 400) @@ -293,8 +296,8 @@ export function ChatView() { onClick={e => { e.preventDefault() onClick?.(e) - if (target.kind === 'conversation') { setActiveId(target.id); setShowAttachments(false) } - else if (target.kind === 'attachments') setShowAttachments(true) + if (target.kind === 'conversation') { setActiveId(target.id); setShowAttachments(false); setShowManage(false) } + else if (target.kind === 'attachments') { setShowAttachments(true); setShowManage(false) } else if (target.kind === 'external') window.open(target.href, '_blank', 'noopener') }} >{children} @@ -351,9 +354,9 @@ export function ChatView() { adapter={adapter} currentUser={cfg.user} navigation={{ - toConversation: id => { setActiveId(id); setShowAttachments(false) }, + toConversation: id => { setActiveId(id); setShowAttachments(false); setShowManage(false) }, toNewChat: () => { - setActiveId(null); setShowAttachments(false) + setActiveId(null); setShowAttachments(false); setShowManage(false) // Land the cursor in the composer so typing starts immediately. const focus = (tries: number) => { const box = document.querySelector('.chat-canvas textarea') @@ -362,7 +365,7 @@ export function ChatView() { } setTimeout(() => focus(5), 50) }, - toAttachments: () => setShowAttachments(true), + toAttachments: () => { setShowAttachments(true); setShowManage(false) }, }} notify={{ success: () => {}, @@ -409,7 +412,14 @@ export function ChatView() { )} - {showAttachments ? : activeId ? : (() => { + {showManage ? ( + { setActiveId(id); setShowManage(false) }} + onActiveDeleted={() => setActiveId(null)} + onClose={() => setShowManage(false)} + /> + ) : showAttachments ? : activeId ? : (() => { // The same dark-aware pick the sidebar makes, so the two never // disagree about which mark to show, and read live so a theme // toggle swaps it. The icon rides as a pseudo-element on the @@ -427,9 +437,10 @@ export function ChatView() { ) })()} - {activeId && !showAttachments && } - {!showAttachments && } - {!showAttachments && } + { setShowManage(true); setShowAttachments(false) }} /> + {activeId && !showAttachments && !showManage && } + {!showAttachments && !showManage && } + {!showAttachments && !showManage && } {voiceOpen && cfg.features?.voice?.url && setVoiceOpen(false)} />} {railOpen &&
setRailOpen(false)} />}
@@ -442,7 +453,7 @@ export function ChatView() { {chatFilter === 'mine' ? 'Showing my chats' : 'Showing all chats'} )} -
+