Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion server/src/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -242,6 +250,28 @@ export async function conversationRoutes(app: FastifyInstance): Promise<void> {
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)
Expand Down
51 changes: 51 additions & 0 deletions server/test/bulkDelete.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
16 changes: 16 additions & 0 deletions web/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ function decorateConversations<T extends OwnedSummary>(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
Expand Down
138 changes: 138 additions & 0 deletions web/src/components/ChatsManager.tsx
Original file line number Diff line number Diff line change
@@ -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<Set<string>>(new Set())
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(false)
const [note, setNote] = useState('')
const lastClicked = useRef<number | null>(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 (
<div className="chats-manager">
<div className="chats-manager-head">
<h2>Conversations</h2>
<span className="muted">{rows.length}</span>
<button className="btn-secondary chats-manager-close" onClick={onClose}>Done</button>
</div>
<div className="chats-manager-bar">
<label className="chats-check-all">
<input type="checkbox" checked={allShownPicked} disabled={!deletable.length} onChange={toggleAll} />
<span>{filter ? 'All shown' : 'All'}</span>
</label>
<input className="field chats-filter" placeholder="Filter by title" value={filter} onChange={e => setFilter(e.target.value)} />
{!confirming
? <button className="btn-danger" disabled={!picked.size || busy} onClick={() => setConfirming(true)}>
Delete{picked.size ? ` ${picked.size}` : ''}
</button>
: <span className="chats-confirm">
<span>Delete {picked.size} conversation{picked.size === 1 ? '' : 's'} and their transcripts? This cannot be undone.</span>
<button className="btn-danger" disabled={busy} onClick={() => void remove()}>{busy ? 'Deleting…' : 'Delete'}</button>
<button className="btn-secondary" disabled={busy} onClick={() => setConfirming(false)}>Cancel</button>
</span>}
</div>
{note && <p className="muted chats-note">{note}</p>}
<ul className="chats-list" role="list">
{shown.map((c, i) => {
const own = isOwnConversation(c)
return (
<li key={c.id} className={`chats-row${picked.has(c.id) ? ' picked' : ''}${own ? '' : ' locked'}`}>
<input
type="checkbox"
aria-label={`Select ${c.title || 'Untitled'}`}
checked={picked.has(c.id)}
disabled={!own}
onChange={() => { /* handled on click, which carries the shift key */ }}
onClick={e => toggle(i, e.shiftKey)}
/>
<button className="chats-row-open" onClick={() => onOpen(c.id)} title="Open">
<span className="chats-row-title">{c.title || c.preview || 'Untitled'}</span>
<span className="chats-row-meta">
{when(c.updatedAt ?? c.createdAt)} · {c.messageCount} message{c.messageCount === 1 ? '' : 's'}
</span>
</button>
</li>
)
})}
{!shown.length && <li className="muted chats-empty">{rows.length ? 'No conversation matches.' : 'No conversations yet.'}</li>}
</ul>
</div>
)
}
57 changes: 57 additions & 0 deletions web/src/components/ManageChatsRailItem.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(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<HTMLButtonElement>(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(
<button
type="button"
className={`flex items-center h-9 px-2 gap-3 rounded-lg hover:theme-muted-panel w-full ade-rail-select${active ? ' active' : ''}`}
title="Select chats to delete"
aria-pressed={active}
onClick={onSelect}
>
<svg className="flex-shrink-0 w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<rect x="2" y="2.5" width="4" height="4" rx="1" />
<path d="m2.9 11.4 1 1 1.9-2.1" />
<path d="M8.5 4.5h5.5M8.5 11.5h5.5" />
</svg>
<span className={labelClass}>Select chats</span>
</button>,
host,
)
}
28 changes: 28 additions & 0 deletions web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Loading
Loading