Skip to content

feat: implement chat thread deletion - #174

Open
pasan-wijekoon wants to merge 2 commits into
CopilotKit:mainfrom
pasan-wijekoon:pasan/delete-chat-thread
Open

pasan-wijekoon wants to merge 2 commits into
CopilotKit:mainfrom
pasan-wijekoon:pasan/delete-chat-thread

Conversation

@pasan-wijekoon

Copy link
Copy Markdown

Summary

This PR adds the ability to delete chat threads (conversations) from the sidebar. Previously, threads could only be created but never removed, leaving users with no way to clean up old or unwanted conversations. This implementation covers the full stack — from the UI trash icon in the sidebar all the way down to transactional database cleanup and CopilotKit Intelligence thread removal.


Motivation

Chat threads accumulate over time with no way to remove them. This change gives users control over their conversation history, keeping the sidebar clean and preventing stale threads from cluttering the workspace.


Changes

UI — ThreadList.tsx

The thread list sidebar items are restructured to support a hover-revealed delete action.

Before: each thread was a single <button> that selected the thread on click.

After: each thread is wrapped in a <div class="thread-row"> that holds two buttons side-by-side:

  • thread-select — the existing selection button (icon + title + dot name)
  • thread-delete — a new trash icon button, revealed on hover/focus, with a fully descriptive aria-label for accessibility
- <button
-   key={thread.id}
-   className={`nav-item ${selected === thread.id ? 'active' : ''}`}
-   onClick={() => onSelect(thread.id)}
- >
-   <MessageCircle size={15} />
-   <span className="thread-summary">...</span>
- </button>
+ <div className={`nav-item thread-row ${selected === thread.id ? 'active' : ''}`}>
+   <button className="thread-select" onClick={() => onSelect(thread.id)}>
+     <MessageCircle size={15} />
+     <span className="thread-summary">...</span>
+   </button>
+   <button
+     className="icon-button thread-delete"
+     aria-label={`Delete ${remote?.name || thread.title}`}
+     onClick={() => onDelete(thread.id)}
+   >
+     <Trash2 size={13} />
+   </button>
+ </div>

The component's prop interface gains a new required onDelete: (id: string) => void callback.


Styles — style.css

Three new CSS rule-sets handle the row layout and progressive disclosure of the delete button:

.thread-row {
  display: flex;
  align-items: center;
  gap: 4px;
}

.thread-select {
  display: flex;
  align-items: center;
  gap: 9px;
  flex: 1;        /* takes all available width */
  min-width: 0;   /* prevents text overflow from escaping flex */
  color: inherit;
  background: transparent;
  border: 0;
  text-align: left;
}

.thread-delete {
  opacity: 0;     /* hidden by default */
}

/* Reveal on hover or keyboard focus anywhere in the row */
.thread-row:hover .thread-delete,
.thread-row:focus-within .thread-delete {
  opacity: 1;
}

The delete button is invisible by default and only appears when the user hovers over or focuses within the row, keeping the sidebar uncluttered while remaining fully keyboard-accessible via :focus-within.


Client Logic — App.tsx

A new deleteConversation async handler is defined inside App:

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);
  }
};

Key behaviors:

  1. Guard: looks up the conversation by ID first; returns early if not found.
  2. Confirmation: shows a native browser confirm() dialog naming the thread. Cancelling aborts without making any network call.
  3. API call: DELETE /conversations/:id via the existing mutate helper. Returns early if the request fails (the helper already surfaces errors).
  4. Active-thread redirect: if the deleted thread was the currently selected one, the UI automatically switches to the next available thread (or undefined if there are none left), preventing a broken "ghost" view.

The handler is wired into <ThreadList> via the new onDelete prop:

 <ThreadList
   ...
   onNew={() => void newConversation()}
+  onDelete={(id) => void deleteConversation(id)}
 />

HTTP Route — workspace-routes.ts

A single new route is added to the Hono router:

app.delete('/conversations/:id', async (c) => {
  await platform.deleteConversation(c.req.param('id'));
  return c.json({ ok: true });
});

Errors thrown by platform.deleteConversation (e.g., thread not found, Intelligence API failure) propagate to the existing error-handling middleware, returning appropriate HTTP status codes without any additional boilerplate.


Platform Service — platform.ts

Platform gains a new deleteConversation method that orchestrates both the remote and local cleanup in the correct order:

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);
}

The method:

  1. Asserts that the Platform is fully configured (requireReady()).
  2. Verifies the thread belongs to this workspace owner before contacting Intelligence.
  3. Calls intelligence.deleteThread(...) to remove the thread's message history from CopilotKit Intelligence.
  4. Only then calls workspace.deleteThread(threadId) to remove local records — keeping remote and local state in sync. If the Intelligence call throws, the local DB is left untouched.

Data Layer — workspace.ts

WorkspaceStore gains a deleteThread method that wraps all removals in a SQLite transaction:

deleteThread(id: string) {
  this.requireThread(id);          // ownership check
  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;
  }
}

All seven dependent tables are cleaned up before the thread binding itself is removed. The ownerId guard on the final DELETE ensures a user cannot delete another owner's thread even if they somehow obtain the ID. Any failure rolls back the entire transaction so the DB is never left in a partial state.


Testing

Automated

# Unit tests (workspace data layer)
npx vitest run tests/workspace.test.ts

Manual Verification

  1. Start the app and create two or more conversations for a Dot.
  2. Hover over a thread in the sidebar — confirm the trash icon appears.
  3. Click the trash icon — confirm the browser confirm() dialog shows the thread title.
  4. Cancel the dialog — confirm the thread is not deleted.
  5. Click the trash icon again, confirm — confirm the thread disappears from the list.
  6. Delete the currently active thread — confirm the app automatically switches to another conversation without error.
  7. Delete the last remaining thread — confirm the view handles an empty state gracefully.
  8. Check the browser's Network tab: the DELETE /conversations/:id request returns {"ok":true}.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant