diff --git a/src/client/App.tsx b/src/client/App.tsx index e5cc6466..b5e36959 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -247,6 +247,24 @@ export function App() { setBusy(false); } }; + + const deleteConversation = async (id: string) => { + const conversation = workspace?.conversations.find( + (item) => item.id === id, + ); + if ( + !conversation || + !window.confirm(`Delete “${conversation.title}”? This cannot be undone.`) + ) + return; + if (!(await mutate(`/conversations/${encodeURIComponent(id)}`, 'DELETE'))) + return; + if (selectedThread === id) { + const next = workspace?.conversations.find((item) => item.id !== id); + setSelectedThread(next?.id); + } + }; + if (needsAuth) return (
@@ -465,6 +483,7 @@ export function App() { setMobile(false); }} onNew={() => void newConversation()} + onDelete={(id) => void deleteConversation(id)} /> ) : (
diff --git a/src/client/ThreadList.tsx b/src/client/ThreadList.tsx index aeeeed48..fac6250f 100644 --- a/src/client/ThreadList.tsx +++ b/src/client/ThreadList.tsx @@ -1,5 +1,5 @@ import { useThreads } from '@copilotkit/react-core/v2'; -import { MessageCircle, Plus } from 'lucide-react'; +import { MessageCircle, Plus, Trash2 } from 'lucide-react'; import type { Conversation, Dot } from '../shared/types'; export function ThreadList({ dots, @@ -8,6 +8,7 @@ export function ThreadList({ selected, onSelect, onNew, + onDelete, }: { dots: Dot[]; dotId: string; @@ -15,6 +16,7 @@ export function ThreadList({ selected?: string; onSelect: (id: string) => void; onNew: () => void; + onDelete: (id: string) => void; }) { const threads = useThreads({ agentId: dotId, @@ -42,17 +44,30 @@ export function ThreadList({ {local.map((thread) => { const remote = threads.threads.find((item) => item.id === thread.id); return ( - + + +
); })} {!local.length && ( diff --git a/src/client/style.css b/src/client/style.css index 75be32de..24443ec4 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -1976,6 +1976,29 @@ h3 { padding: 9px 12px; font-size: 10px; } +.thread-row { + display: flex; + align-items: center; + gap: 4px; +} +.thread-select { + display: flex; + align-items: center; + gap: 9px; + flex: 1; + min-width: 0; + color: inherit; + background: transparent; + border: 0; + text-align: left; +} +.thread-delete { + opacity: 0; +} +.thread-row:hover .thread-delete, +.thread-row:focus-within .thread-delete { + opacity: 1; +} .thread-list .nav-item > span { overflow: hidden; text-overflow: ellipsis; diff --git a/src/server/platform.ts b/src/server/platform.ts index 9e3478eb..b4638a94 100644 --- a/src/server/platform.ts +++ b/src/server/platform.ts @@ -182,6 +182,18 @@ export class Platform { .join('\n') .slice(-12000); } + + async deleteConversation(threadId: string) { + this.requireReady(); + const thread = this.workspace.requireThread(threadId); + await this.intelligence!.deleteThread({ + threadId, + userId: this.workspace.ownerId, + agentId: thread.dotId, + }); + this.workspace.deleteThread(threadId); + } + async handle(request: Request): Promise { if (!this.handler) return Response.json( diff --git a/src/server/workspace-routes.ts b/src/server/workspace-routes.ts index 672ffda1..9b3d3d26 100644 --- a/src/server/workspace-routes.ts +++ b/src/server/workspace-routes.ts @@ -161,6 +161,12 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) { 201, ); }); + + app.delete('/conversations/:id', async (c) => { + await platform.deleteConversation(c.req.param('id')); + return c.json({ ok: true }); + }); + app.get('/conversations/:id/capture', (c) => c.json(platform.workspace.capture(c.req.param('id'))), ); diff --git a/src/server/workspace.ts b/src/server/workspace.ts index e98061c3..c9e8b1da 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -297,6 +297,31 @@ export class WorkspaceStore { throw new Error('Conversation does not belong to this Dot and owner.'); return thread; } + + deleteThread(id: string) { + this.requireThread(id); + this.db.exec('BEGIN'); + try { + for (const [table, column] of [ + ['task_threads', 'threadId'], + ['calls', 'threadId'], + ['captures', 'threadId'], + ['page_reviews', 'threadId'], + ['page_threads', 'threadId'], + ['mcp_approvals', 'threadId'], + ['mcp_actions', 'threadId'], + ]) + this.db.prepare(`DELETE FROM ${table} WHERE ${column}=?`).run(id); + this.db + .prepare('DELETE FROM thread_bindings WHERE id=? AND ownerId=?') + .run(id, this.ownerId); + this.db.exec('COMMIT'); + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + bindTask(taskId: string, threadId: string) { this.requireThread(threadId); this.db diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts index f5a9ef4f..bd3869f2 100644 --- a/tests/workspace.test.ts +++ b/tests/workspace.test.ts @@ -24,6 +24,21 @@ it('rejects a dot in a nonexistent space and does not rebind an existing thread' store.close(); }); +it('deletes a thread binding and all thread-specific local records', () => { + const store = new WorkspaceStore(':memory:', 'owner'); + const dot = store.dots()[0]; + store.bindThread('deleted', dot.id, 'Delete me'); + const call = store.createCall('deleted'); + store.saveCapture('deleted', { value: true }); + store.deleteThread('deleted'); + + expect(store.conversations()).toHaveLength(0); + expect(() => store.requireThread('deleted')).toThrow(); + expect(() => store.call(call.id)).toThrow(); + expect(() => store.capture('deleted')).toThrow(); + store.close(); +}); + it('migrates legacy Space ownership once and never restores revoked access on restart', () => { const dir = mkdtempSync(join(tmpdir(), 'opendots-access-')); const path = join(dir, 'workspace.sqlite');