Skip to content

feat: delete projects and bulk-delete a project's sessions - #59580

Open
xu-xiaofei wants to merge 2 commits into
anomalyco:devfrom
xu-xiaofei:project-session-delete
Open

xu-xiaofei wants to merge 2 commits into
anomalyco:devfrom
xu-xiaofei:project-session-delete

Conversation

@xu-xiaofei

@xu-xiaofei xu-xiaofei commented Oct 27, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #51197
Related to #41068

Type of change

  • New feature

What does this PR do?

A project whose folder was deleted or moved stays in the list forever: there is no server API to remove it and no UI entry. This adds DELETE /api/project/:projectID end to end (a project.removed event, core cascade delete that clears the sessions' event streams and orphaned blobs, a handler, and the generated client), plus delete entries in Settings > Projects and the Home project menu, each behind a confirm dialog.

It also adds POST /api/session/remove-project to delete every session of a project while keeping the project itself ("Delete history" in the Settings menu), reusing the existing per-session removal path, and wires the Home archive action to the real session.update({ archived }) API instead of the rejecting stub.

How did you verify your code works?

New core tests cover the cascade (sessions, event streams, blobs), global-project refusal, parent/child recursion, and other-project isolation. bun run check passes; the core (36), client (234), schema (89) and app (900) suites pass. Client changes come from bun run generate.

Visual

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" width="240" height="72">
  <style>
    .fg { fill: currentColor; }
    .row { fill: none; stroke: currentColor; stroke-opacity: .22; }
    .danger { fill: currentColor; fill-opacity: .9; }
    .menu { opacity: 0; animation: m 8s infinite; }
    .confirm { opacity: 0; animation: c 8s infinite; }
    .alive { animation: r 8s infinite; }
    .gone { opacity: 0; animation: g 8s infinite; }
    @keyframes m { 0%,14% {opacity:0} 20%,44% {opacity:1} 50%,100% {opacity:0} }
    @keyframes c { 0%,44% {opacity:0} 50%,72% {opacity:1} 78%,100% {opacity:0} }
    @keyframes r { 0%,74% {opacity:1} 84%,96% {opacity:0} 100% {opacity:1} }
    @keyframes g { 0%,82% {opacity:0} 86%,94% {opacity:.6} 100% {opacity:0} }
    @media (prefers-reduced-motion: reduce) { .menu,.confirm,.gone {opacity:0} .alive {opacity:1} }
  </style>
  <!-- project row -->
  <g class="alive">
    <rect class="row" x="14" y="14" width="150" height="24" rx="6"/>
    <circle class="fg" cx="27" cy="26" r="6" fill-opacity=".45"/>
    <rect class="fg" x="39" y="23" width="62" height="5.5" rx="2.75" fill-opacity=".8"/>
    <rect class="fg" x="39" y="31.5" width="40" height="4" rx="2" fill-opacity=".3"/>
    <circle class="fg" cx="154" cy="26" r="1.6"/>
    <circle class="fg" cx="149" cy="26" r="1.6"/>
    <circle class="fg" cx="159" cy="26" r="1.6"/>
  </g>
  <!-- second row stays -->
  <rect class="row" x="14" y="44" width="150" height="24" rx="6"/>
  <circle class="fg" cx="27" cy="56" r="6" fill-opacity=".45"/>
  <rect class="fg" x="39" y="53" width="48" height="5.5" rx="2.75" fill-opacity=".8"/>
  <!-- dropdown menu with delete action -->
  <g class="menu">
    <rect class="fg" x="118" y="10" width="84" height="40" rx="7" fill-opacity=".08"/>
    <rect class="row" x="118" y="10" width="84" height="40" rx="7"/>
    <rect class="fg" x="126" y="18" width="52" height="5" rx="2.5" fill-opacity=".55"/>
    <rect class="fg danger" x="126" y="34" width="66" height="8" rx="3" fill-opacity=".85"/>
  </g>
  <!-- confirm dialog -->
  <g class="confirm">
    <rect class="fg" x="52" y="16" width="136" height="40" rx="8" fill-opacity=".08"/>
    <rect class="row" x="52" y="16" width="136" height="40" rx="8"/>
    <rect class="fg" x="62" y="24" width="98" height="5" rx="2.5" fill-opacity=".7"/>
    <rect class="fg" x="62" y="33" width="76" height="4" rx="2" fill-opacity=".35"/>
    <rect class="fg danger" x="140" y="43" width="38" height="8" rx="3"/>
  </g>
  <!-- removed feedback -->
  <rect class="fg gone" x="14" y="14" width="150" height="24" rx="6" fill-opacity=".15"/>
</svg>

Screenshots / recordings

None; the SVG above sketches the delete flow.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Fixes #37280

Related: #41068

Public API: adds DELETE /api/project/:projectID, POST /api/session/remove-project and the project.removed event, with matching client methods (backwards compatible)

Xu added 2 commits October 27, 2026 18:32
- schema 新增 project.removed ephemeral 事件并入 manifest
- protocol 新增 DELETE /api/project/:projectID(NoContent + ProjectNotFoundError)
- core Project.remove:拒删 global、FK 级联删除 worktree 与全部会话、
  分批清理会话事件流(event_sequence 级联 event)、回收孤儿 blob、广播事件
- server handler 先经 session.removeProject 优雅中断并清理运行中会话再删行
- client 重新生成(bun run generate),data.ts 消费 project.removed 即时移除条目,
  app sync 同步镜像
- Settings 项目菜单与 Home 项目卡片菜单新增"删除项目"入口(critical 样式 +
  二次确认对话框 + 失败 toast),覆盖目录已删除/已迁移项目的清理场景
- core 测试:级联删除、事件流与 blob 清理、其他项目隔离、global/404 拒绝
- protocol 新增 POST /api/session/remove-project(按 projectID 清空其全部会话)
- core Session.removeProject 复用单会话删除路径(interrupt → 递归级联 →
  事件流清理),父会话先行删除后的子条目 NotFound 属正常级联并精确兜底
- server handler 挂载该端点
- Settings 项目菜单新增"删除历史…"入口(保留项目条目,二次确认)
- Home 列表归档动作改调真实 session.update({archived:true}),移除 reject stub
- core/client 契约测试同步更新

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new endpoints work as described against a source-built server: POST /api/session/remove-project clears a project's sessions and keeps the project, and DELETE /api/project/:projectID removes the project and its sessions (204, and it no longer appears with archived=true). On the base the route returns 404. The changed core, schema and client tests pass, and tsc is clean for every changed package. I didn't drive the app UI; a short recording or screenshots of the Home and Settings delete flows would help.

Public API: all additions, all backwards compatible:

  • DELETE /api/project/:projectID (project.remove, 204 or ProjectNotFoundError)
  • POST /api/session/remove-project (session.removeProject, payload { projectID }, 204)
  • a new project.removed event { projectID }
  • the matching project.remove and session.removeProject methods in the generated promise and effect clients

Concerns, most important first (details inline):

  • Global project: the delete handler removes the sessions before core refuses the global project, so DELETE /api/project/global wipes all global sessions and still returns 404.
  • Worktrees: deleting a project silently drops its worktree rows, leaving OpenCode-created git worktrees orphaned on disk.
  • Settings: after a delete from Settings, the page stays on the deleted project and the project isn't hidden from the app's saved project list, which is part of what #51197 reports.
  • session.removeProject declares ProjectNotFoundError but never raises it: unknown IDs return 204.

Fixes #37280
Related: #41068

.handle("project.remove", (ctx) =>
Effect.gen(function* () {
// Graceful session teardown first: interrupt, release blobs, clear each event stream.
yield* session.removeProject(ctx.params.projectID)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session.removeProject runs before project.remove checks the ID, and the global-project refusal lives only in core. So DELETE /api/project/global deletes every global session (e.g. ones imported from V1) and then returns 404. Could you check that the project exists and isn't global before removing sessions, ideally inside Project.remove or a single core operation so this ordering can't drift again?

)
).map((session) => session.id)
// The FK cascades take the sessions, their messages, inbox rows, instructions and blob refs with the row.
yield* db.delete(ProjectTable).where(eq(ProjectTable.id, projectID)).run().pipe(Effect.orDie)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting the project row cascades WorktreeTable too, but nothing goes through Worktree.remove. Git worktrees and sandboxes OpenCode created for this project stay on disk and in the repo's git worktree list, and nothing in OpenCode can remove them afterwards. Should these be removed first, or the user told they'll be kept? Also, the row delete, event-stream cleanup and blob sweep aren't in one transaction, so a failure part-way leaves partial state.


try {
await context().sdk.api.project.remove({ projectID: id })
props.onRemoved?.()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither ProjectOptions call site (project.tsx, project-row.tsx) passes onRemoved. After deleting from Settings, the page stays on the deleted project, and unlike the Home path this doesn't call projects.hide, so the project can stay in the app's saved project list (part of what #51197 reports).

This branch has not been deployed

No deployments
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.

project: no API or UI to remove projects and recently-closed project entries [FEATURE]: Allow projects and their sessions to be removed from OpenCode

1 participant