diff --git a/apps/desktop/src/components/ProjectDeleteDialog.tsx b/apps/desktop/src/components/ProjectDeleteDialog.tsx
index 3d56236c5..545ff13c1 100644
--- a/apps/desktop/src/components/ProjectDeleteDialog.tsx
+++ b/apps/desktop/src/components/ProjectDeleteDialog.tsx
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { ErrorCodes } from "@pi-desktop/shared";
import { useAppStore } from "../stores/app-store";
import { Button, TooltipButton } from "./ui";
-import { IconCircleAlert, IconClose, IconTrash } from "./icons";
+import { IconCircleAlert, IconClose, IconStop, IconTrash } from "./icons";
/**
* Second confirmation for deleting a project. The store action removes the
@@ -12,20 +12,32 @@ import { IconCircleAlert, IconClose, IconTrash } from "./icons";
*/
export function ProjectDeleteDialog({
project,
+ runningSessionIds,
onClose,
onDeleted,
onError,
}: {
project: { name: string; path: string; sessionCount: number };
+ /**
+ * Sessions of this project whose turn is still live. The host refuses the
+ * bulk delete (and the single session delete) while one exists, so the dialog
+ * names them and stops them as the explicit step its confirm label promises.
+ */
+ runningSessionIds: string[];
onClose: () => void;
onDeleted: () => void | Promise;
onError: (error: unknown) => void;
}) {
const { t } = useTranslation();
const deleteProject = useAppStore((s) => s.deleteProject);
+ const abortSession = useAppStore((s) => s.abortSession);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const dialogRef = useRef(null);
+ // The surfaces that own this dialog subscribe to `runningSessions`, so a turn
+ // that starts or finishes while the dialog is open is reflected here before
+ // the user confirms.
+ const runningCount = runningSessionIds.length;
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
@@ -68,11 +80,19 @@ export function ProjectDeleteDialog({
busyRef.current = true;
setBusy(true);
try {
+ // A live turn still owns its session's tools and transcript, so the host
+ // refuses the bulk delete until every attached session is idle. Stopping
+ // the listed sessions is the step the confirm button names; a turn that
+ // starts after this loop still makes the host refuse with CONFLICT below,
+ // which is why the refusal path stays reachable.
+ for (const sessionId of runningSessionIds) {
+ await abortSession(sessionId);
+ }
await deleteProject(project.path);
await onDeleted();
} catch (error) {
// The host refuses the delete while a task of this project is running;
- // show the same localized explanation the menu guard uses.
+ // show the same localized explanation the menu guard used to show.
if ((error as { errorCode?: unknown } | null)?.errorCode === ErrorCodes.CONFLICT) {
onError(new Error(t("project.deleteRunningBlocked")));
return;
@@ -98,7 +118,9 @@ export function ProjectDeleteDialog({
role="dialog"
aria-modal="true"
aria-labelledby="project-delete-dialog-title"
- aria-describedby="project-delete-dialog-description project-delete-dialog-sessions project-delete-dialog-folder-kept"
+ aria-describedby={`project-delete-dialog-description project-delete-dialog-sessions project-delete-dialog-folder-kept${
+ runningCount > 0 ? " project-delete-dialog-running" : ""
+ }`}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
>
@@ -129,6 +151,12 @@ export function ProjectDeleteDialog({
{t("project.deleteSessions", { count: project.sessionCount })}
+ {runningCount > 0 ? (
+
+
+ {t("project.deleteRunning", { count: runningCount })}
+
+ ) : null}
{t("project.deleteFolderKept")}
@@ -144,7 +172,11 @@ export function ProjectDeleteDialog({
disabled={busy}
onClick={() => void confirm()}
>
- {busy ? t("project.deleting") : t("project.deleteConfirm")}
+ {busy
+ ? t("project.deleting")
+ : runningCount > 0
+ ? t("project.deleteRunningConfirm")
+ : t("project.deleteConfirm")}
diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx
index 08e4ad927..dea5816b8 100644
--- a/apps/desktop/src/components/Sidebar.tsx
+++ b/apps/desktop/src/components/Sidebar.tsx
@@ -1976,14 +1976,9 @@ export function Sidebar({
className="danger"
data-action="delete-project"
onClick={() => {
+ // Never refuse silently: the dialog names the running sessions
+ // and asks for an explicit confirmation before it stops them.
closeMenus(false);
- const runningCount = entry.sessions.filter(
- (session) => runningSessions[session.id] === true,
- ).length;
- if (runningCount > 0) {
- showToast(t("project.deleteRunningBlocked"), { variant: "warning" });
- return;
- }
setDeleteProjectFor(entry);
}}
>
@@ -2290,6 +2285,9 @@ export function Sidebar({
path: deleteProjectFor.path,
sessionCount: deleteProjectFor.sessions.length,
}}
+ runningSessionIds={deleteProjectFor.sessions
+ .filter((session) => runningSessions[session.id] === true)
+ .map((session) => session.id)}
onClose={() => setDeleteProjectFor(null)}
onDeleted={() => {
setDeleteProjectFor(null);
diff --git a/apps/desktop/src/pages/ProjectsPage.tsx b/apps/desktop/src/pages/ProjectsPage.tsx
index fbd2dc675..37315baf5 100644
--- a/apps/desktop/src/pages/ProjectsPage.tsx
+++ b/apps/desktop/src/pages/ProjectsPage.tsx
@@ -151,10 +151,13 @@ export function ProjectsPage() {
roots?: ProjectGroupRecord["roots"];
legacy?: boolean;
} | null>(null);
+ // `roots` rides along so the dialog can keep deriving the project's live
+ // running sessions while it is open, instead of a snapshot taken on click.
const [deleteFor, setDeleteFor] = useState<{
name: string;
path: string;
sessionCount: number;
+ roots: ProjectGroupRecord["roots"];
} | null>(null);
const searchRef = useRef(null);
const [instructionsFor, setInstructionsFor] = useState<{
@@ -814,21 +817,14 @@ export function ProjectsPage() {
data-action="delete-project"
onClick={() => {
setMenuFor(null);
- const runningCount = sessions.filter(
- (session) =>
- sessionMatchesIndexProject(session, project) &&
- runningSessions[session.id] === true,
- ).length;
- if (runningCount > 0) {
- showToast(t("project.deleteRunningBlocked"), {
- variant: "warning",
- });
- return;
- }
+ // Never refuse silently: the dialog names the
+ // running sessions and asks for an explicit
+ // confirmation before it stops them.
setDeleteFor({
name: project.name,
path: project.path,
sessionCount: totalSessions,
+ roots: project.roots,
});
}}
>
@@ -1021,6 +1017,13 @@ export function ProjectsPage() {
{deleteFor ? (
+ sessionMatchesIndexProject(session, deleteFor) &&
+ runningSessions[session.id] === true,
+ )
+ .map((session) => session.id)}
onClose={() => setDeleteFor(null)}
onDeleted={() => {
setDeleteFor(null);
diff --git a/apps/desktop/src/styles/projects.css b/apps/desktop/src/styles/projects.css
index fd7e38af4..fea61a123 100644
--- a/apps/desktop/src/styles/projects.css
+++ b/apps/desktop/src/styles/projects.css
@@ -777,6 +777,25 @@
margin-top: 2px;
}
+/*
+ The running-session line is a "these will be stopped" notice, not the
+ destructive warning above it, so it carries the warning tone.
+*/
+.project-delete-dialog-running {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin: 0;
+ color: var(--ds-warning);
+ font-size: var(--text-sm);
+ line-height: var(--leading-body);
+}
+
+.project-delete-dialog-running svg {
+ flex: 0 0 auto;
+ margin-top: 2px;
+}
+
.project-delete-dialog-confirm {
background: var(--ds-error);
border-color: var(--ds-error);
diff --git a/apps/desktop/test/project-delete.test.mjs b/apps/desktop/test/project-delete.test.mjs
index 06bc10721..b3c31fdff 100644
--- a/apps/desktop/test/project-delete.test.mjs
+++ b/apps/desktop/test/project-delete.test.mjs
@@ -13,6 +13,9 @@ const DELETE_KEYS = [
"deleteSessions_other",
"deleteFolderKept",
"deleteRunningBlocked",
+ "deleteRunning_one",
+ "deleteRunning_other",
+ "deleteRunningConfirm",
"deleteConfirm",
"deleteCancel",
"deleting",
@@ -69,6 +72,16 @@ function deleteHandler(source) {
return rest.slice(0, end);
}
+/** The props `` receives on a surface, up to `onClose`. */
+function dialogProps(source) {
+ const start = source.indexOf("= 0, "the dialog is rendered");
+ const rest = source.slice(start);
+ const end = rest.indexOf("onClose=");
+ assert.ok(end > 0, "the dialog receives props");
+ return rest.slice(0, end);
+}
+
test("the delete dialog is a real confirmation backed by the store action", () => {
assert.match(dialogSource, /export function ProjectDeleteDialog/);
assert.match(dialogSource, /const deleteProject = useAppStore\(\(s\) => s\.deleteProject\)/);
@@ -88,7 +101,8 @@ test("the delete dialog is a labelled modal that blocks cancel while busy", () =
assert.match(dialogSource, /role="dialog"/);
assert.match(dialogSource, /aria-labelledby="project-delete-dialog-title"/);
assert.match(dialogSource, /id="project-delete-dialog-title"/);
- assert.match(dialogSource, /aria-describedby="project-delete-dialog-description/);
+ // The running-session line joins the description by id only while rendered.
+ assert.match(dialogSource, /aria-describedby=\{`project-delete-dialog-description/);
for (const id of [
"project-delete-dialog-description",
"project-delete-dialog-sessions",
@@ -98,7 +112,10 @@ test("the delete dialog is a labelled modal that blocks cancel while busy", () =
}
assert.match(dialogSource, /event\.key === "Escape"/);
assert.match(dialogSource, /disabled=\{busy\}/);
- assert.match(dialogSource, /busy \? t\("project\.deleting"\) : t\("project\.deleteConfirm"\)/);
+ assert.match(
+ dialogSource,
+ /busy\s*\?\s*t\("project\.deleting"\)\s*:\s*runningCount > 0\s*\?\s*t\("project\.deleteRunningConfirm"\)\s*:\s*t\("project\.deleteConfirm"\)/,
+ );
});
test("both project menus expose a destructive delete action", () => {
@@ -136,35 +153,65 @@ test("both project menus expose a destructive delete action", () => {
assert.match(sidebarSource, /onError=\{reportError\}/);
});
-test("deleting a project is blocked while its tasks are running", () => {
- for (const [surface, source, dialogSetter] of [
- ["ProjectsPage", projectsSource, "setDeleteFor("],
- ["Sidebar", sidebarSource, "setDeleteProjectFor("],
+test("both project menus reach the confirmation dialog while tasks run", () => {
+ for (const [surface, source] of [
+ ["ProjectsPage", projectsSource],
+ ["Sidebar", sidebarSource],
]) {
const handler = deleteHandler(source);
- const runningCheck = handler.indexOf("runningSessions[session.id]");
- assert.ok(runningCheck >= 0, `${surface} consults runningSessions`);
- assert.match(handler, /\.filter\(/, `${surface} filters the project sessions`);
- assert.match(
- handler,
- /showToast\(\s*t\("project\.deleteRunningBlocked"\),\s*\{\s*variant: "warning",?\s*\},?\s*\)/,
- `${surface} warns instead of deleting`,
- );
+ // The previous behaviour warned through a transient toast and returned
+ // before the dialog state was touched, so a project with a live task could
+ // never be deleted and the refusal disappeared with the toast.
+ assert.doesNotMatch(handler, /deleteRunningBlocked/, `${surface} drops the toast refusal`);
+ assert.doesNotMatch(handler, /showToast/, `${surface} does not toast instead of confirming`);
+ assert.doesNotMatch(handler, /runningSessions/, `${surface} keeps the guard out of the menu`);
+ assert.match(handler, /setDelete(?:Project)?For\(/, `${surface} opens the dialog`);
- // The guard must short-circuit before the dialog state is touched.
- const dialogState = handler.indexOf(dialogSetter);
- assert.ok(dialogState > runningCheck, `${surface} checks running tasks first`);
+ // The dialog still has to learn which of the project's sessions are live.
+ const props = dialogProps(source);
+ assert.match(props, /runningSessionIds=\{/, `${surface} passes the running sessions`);
assert.match(
- handler.slice(runningCheck, dialogState),
- /return;/,
- `${surface} returns before opening the dialog`,
+ props,
+ /runningSessions\[session\.id\] === true/,
+ `${surface} derives them from the live store`,
);
+ assert.match(props, /\.map\(\(session\) => session\.id\)/, `${surface} passes session ids`);
}
- // Each surface counts the sessions that belong to the project row itself.
- assert.match(deleteHandler(projectsSource), /sessionMatchesIndexProject\(session, project\)/);
- assert.match(deleteHandler(sidebarSource), /entry\.sessions\.filter\(/);
+ // Each surface counts the sessions that belong to its own row.
+ assert.match(dialogProps(projectsSource), /sessionMatchesIndexProject\(session, deleteFor\)/);
+ assert.match(dialogProps(sidebarSource), /deleteProjectFor\.sessions\s*\.filter\(/);
+});
+
+test("the delete dialog names the running sessions and asks for an explicit confirmation", () => {
+ assert.match(dialogSource, /runningSessionIds: string\[\]/);
+ assert.match(dialogSource, /const runningCount = runningSessionIds\.length/);
+ assert.match(dialogSource, /id="project-delete-dialog-running"/);
+ assert.match(dialogSource, /t\("project\.deleteRunning", \{ count: runningCount \}\)/);
+ // The description lists the line only while the line is actually rendered.
+ assert.match(dialogSource, /runningCount > 0 \? " project-delete-dialog-running" : ""/);
+ assert.match(dialogSource, /t\("project\.deleteRunningConfirm"\)/);
+ assert.ok(
+ dialogSource.indexOf('t("project.deleteRunningConfirm")') <
+ dialogSource.indexOf('t("project.deleteConfirm")'),
+ "the running label is offered instead of the plain confirm label",
+ );
+});
+
+test("the delete dialog stops the running sessions before it deletes the project", () => {
+ assert.match(dialogSource, /const abortSession = useAppStore\(\(s\) => s\.abortSession\)/);
+ const confirmBlock =
+ dialogSource.match(/const confirm = async \(\) => \{[\s\S]*?\n \};/)?.[0] ?? "";
+ assert.ok(confirmBlock, "confirm handler exists");
+ const loop = confirmBlock.indexOf("for (const sessionId of runningSessionIds)");
+ const abortCall = confirmBlock.indexOf("await abortSession(sessionId)");
+ const deleteCall = confirmBlock.indexOf("await deleteProject(project.path)");
+ assert.ok(loop >= 0, "the confirm handler stops the running sessions");
+ assert.ok(abortCall > loop, "each running session is aborted");
+ assert.ok(deleteCall > abortCall, "the delete is awaited only after every stop");
+});
+test("deleting a project still explains a host-side running-task refusal", () => {
const englishBlock = projectBlock(catalogs.get("en"));
assert.equal(
projectValue(englishBlock, "deleteRunningBlocked"),
@@ -191,6 +238,29 @@ test("deleting a project is blocked while its tasks are running", () => {
);
}
}
+
+ // The host still refuses a turn that starts after the dialog opened, so the
+ // localized explanation has to stay reachable from the dialog.
+ assert.match(dialogSource, /onError\(new Error\(t\("project\.deleteRunningBlocked"\)\)\)/);
+});
+
+test("the running-session copy is translated in every shipped catalog", () => {
+ const englishBlock = projectBlock(catalogs.get("en"));
+ assert.equal(
+ projectValue(englishBlock, "deleteRunning_one"),
+ "{{count}} session in this project is still running. Deleting the project stops it.",
+ );
+ assert.equal(projectValue(englishBlock, "deleteRunningConfirm"), "Stop tasks and delete");
+ assert.deepEqual(placeholders(projectValue(englishBlock, "deleteRunning_other")), ["count"]);
+ assert.deepEqual(placeholders(projectValue(englishBlock, "deleteRunningConfirm")), []);
+ assert.equal(
+ projectValue(projectBlock(catalogs.get("zh-CN")), "deleteRunningConfirm"),
+ "停止任务并删除",
+ );
+ assert.deepEqual(
+ placeholders(projectValue(projectBlock(catalogs.get("zh-TW")), "deleteRunning_one")),
+ ["count"],
+ );
});
test("every shipped catalog defines the delete keys with matching placeholders", () => {
diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md
index 72e05134d..eee9b03cd 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -8293,9 +8293,11 @@ This test plan spec is accepted when:
was moved or deleted on disk is still removable. Deleting C is refused with a
message and the group is unchanged; a path the host has no durable row for is
removed from the archive and the sidebar anyway, without a missing-project
- error. Deleting D while its task runs is refused with a message and removes
- nothing — the project row, its session, and the running turn all survive —
- and the same delete succeeds once that task has stopped.
+ error. Deleting D while its task runs opens the confirmation dialog instead
+ of a warning that disappears with its toast; the dialog names the running
+ sessions and its confirm button reads as stopping them, cancelling removes
+ nothing, and confirming stops exactly those turns and then deletes D (see
+ E2E-PROJECT-delete-running-sessions-are-named-and-stopped).
- **Specs linked**: `03-runtime/06-host-rpc-protocol.md` §Projects,
`03-runtime/04-data-storage.md`, `04-ux/08-component-spec.md` §3.9, ADR 0251
- **Acceptance criterion**: D (workspace), F (persistence), Quality
@@ -8308,6 +8310,36 @@ This test plan spec is accepted when:
sandboxed preload; the Settings archive → dialog → sidebar journey remains
Draft
+
+### E2E-PROJECT-delete-running-sessions-are-named-and-stopped
+
+- **Preconditions**: a durable project D with one session whose turn is still
+ streaming, reachable both from the sidebar project menu and from Settings →
+ Project archive.
+- **Steps**: from each surface, open D's row menu and choose Delete project
+ without stopping the turn. Expect the confirmation dialog with a
+ running-session line and a stop-and-delete confirm label; press Cancel and
+ expect nothing to change. Open the dialog again and confirm.
+- **Expected**: the menu never replaces the dialog with a bare warning, so the
+ action stays reachable while a task runs. The dialog keeps naming the
+ project, its session count, and the untouched folder; while a turn is live it
+ also names how many sessions are still running, its confirm button reads as
+ stopping them, and that line joins the dialog's `aria-describedby` only while
+ it is rendered. Cancelling deletes nothing and leaves the turn streaming.
+ Confirming stops exactly the listed sessions and only then removes the
+ project, its sessions, their transcripts, and its durable memory, leaving the
+ folder on disk. A turn that starts between the dialog opening and the
+ confirmation is still refused by the host, and the dialog reports that
+ refusal with `project.deleteRunningBlocked` while removing nothing.
+- **Specs linked**: `03-runtime/06-host-rpc-protocol.md` §Projects,
+ `04-ux/08-component-spec.md` §3.9, ADR 0251, D421, D431
+- **Acceptance criterion**: D (workspace), Quality
+- **Milestone**: M6+
+- **Status**: Partially automated — `apps/desktop/test/project-delete.test.mjs`
+ pins both menus reaching the dialog with the project's live running session
+ ids, the dialog's running-session line and stop-and-delete label, the abort
+ loop running before `deleteProject`, the `CONFLICT` fallback, and the new
+ copy in every shipped catalog; the end-to-end journey remains Draft
### US-UI-59 Session-rooted background tools
- Start a visible turn in project A, switch to project B while it runs, and
inspect both sidebar status indicators.
diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md
index 5633cf753..afcc2a327 100644
--- a/docs/spec/08-meta/decisions-log.md
+++ b/docs/spec/08-meta/decisions-log.md
@@ -5350,3 +5350,31 @@ its menu instead of the conversation.
dropped. See ADR 0265, `03-runtime/01-ipc-protocol.md` (§5.6),
`03-runtime/04-data-storage.md`, `04-ux/08-component-spec.md` (§11), and
E2E-QUEUE-promote-orders-delivery-by-click.
+
+## 2026-09-16 — The delete dialog, not the menu, owns the running-task refusal (#360, D431)
+
+- Amends the renderer half of D421. The sidebar menu and the Projects index menu
+ refused Delete project with a transient `project.deleteRunningBlocked` warning
+ whenever any of the project's sessions was running, and returned before
+ `ProjectDeleteDialog` was ever mounted. The refusal vanished with the toast
+ and left no path forward, so a project with one live task could not be deleted
+ at all — which is how #360 ("项目管理中无法真正删除项目") reads.
+- Both menus now always open the dialog. Each surface passes the project's live
+ running session ids (`runningSessions[session.id]`, over the rows it already
+ matches: `entry.sessions` in the sidebar, `sessionMatchesIndexProject` in the
+ index), and the dialog derives its copy from that prop on every render, so a
+ turn that starts or finishes while the dialog is open is reflected before the
+ user confirms.
+- The dialog adds a warning line naming `{{count}}` running sessions and swaps
+ its confirm label to `project.deleteRunningConfirm` ("Stop tasks and delete").
+ Confirming aborts exactly those sessions and only then calls `deleteProject`,
+ so removing a running turn stays a second, explicit confirmation of a stated
+ consequence. Cancelling removes nothing.
+- The host guard is unchanged: `projects.remove` still refuses with 1008 /
+ `CONFLICT` while an attached session has a running turn, and the dialog still
+ maps that refusal to `project.deleteRunningBlocked`. A turn that starts after
+ the abort loop is what that fallback covers.
+- Renderer only: no protocol, storage, host, permission, or migration change,
+ and no new default. `project.deleteRunningBlocked` keeps its meaning, copy,
+ and every translation. See ADR 0251, D421, and
+ E2E-PROJECT-delete-running-sessions-are-named-and-stopped.
diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
index f9b0b1087..81c482839 100644
--- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
@@ -5483,9 +5483,10 @@ IPC 请求无法关闭。
的会话与转录本不受影响。当被删除的项目曾是活动工作区时,工作区回退到另一个已打开的项目
或 Temporary,且下次启动不会重新打开已删除的路径。磁盘上文件夹已被移动或删除的项目仍可
移除。删除 C 会被拒绝并给出提示消息,该组保持不变;宿主已无持久行的路径仍会从项目存档与
- 侧边栏中移除,不会报出缺少项目的错误。当 D 的任务仍在运行时删除 D 会被拒绝并给出提示消息,
- 且不会移除任何内容——项目行、其会话以及正在运行的轮次都会保留——待该任务停止后同一次删除
- 即可成功。
+ 侧边栏中移除,不会报出缺少项目的错误。当 D 的任务仍在运行时删除 D 会打开确认对话框,而不是给出
+ 一条随 toast 消失的警告;对话框会指明正在运行的会话,其确认按钮表述为停止这些任务,取消不会移除
+ 任何内容,确认则先停止这些轮次,然后再删除 D(见
+ E2E-PROJECT-delete-running-sessions-are-named-and-stopped)。
- **链接规格**:`03-runtime/06-host-rpc-protocol.md` §项目、
`03-runtime/04-data-storage.md`、`04-ux/08-component-spec.md` §3.9、ADR 0251
- **验收**:D(工作区)、F(持久化)、品质
@@ -5495,6 +5496,28 @@ IPC 请求无法关闭。
幂等),`pnpm test:e2e:boot` 通过沙箱 preload 往返 `pi-desktop/project/remove`;设置 →
项目存档 → 对话框 → 侧边栏这条完整旅程仍为草稿
+### E2E-PROJECT-delete-running-sessions-are-named-and-stopped
+
+- **前提条件**:一个持久项目 D,其中一个会话的轮次仍在流式输出,并且该项目同时可从侧边栏项目
+ 菜单与设置 → 项目存档中访问。
+- **步骤**:从这两个界面分别打开 D 的行菜单,在不停止该轮次的情况下选择删除项目。预期出现带
+ 运行中会话行与“停止任务并删除”确认按钮的确认对话框;按取消,预期不发生任何变化。再次打开
+ 对话框并确认。
+- **预期**:菜单不会用一条纯警告替代对话框,因此在任务运行时该操作始终可达。对话框仍会指明项目
+ 名称、会话数量与不会被删除的文件夹;当有轮次在运行时,它还会指明还有多少个会话正在运行,其确认
+ 按钮表述为停止这些任务,且该行仅在渲染时才加入对话框的 `aria-describedby`。取消不会删除任何
+ 内容,轮次继续流式输出。确认会且仅会停止列出的这些会话,之后才移除项目、其会话、其转录本及其
+ 持久记忆,磁盘上的文件夹保持原样。若在对话框打开与确认之间启动了新的轮次,宿主仍会拒绝,对话框
+ 会用 `project.deleteRunningBlocked` 报告该拒绝且不移除任何内容。
+- **链接规格**:`03-runtime/06-host-rpc-protocol.md` §项目、
+ `04-ux/08-component-spec.md` §3.9、ADR 0251、D421、D431
+- **验收**:D(工作区)、品质
+- **里程碑**:M6+
+- **状态**:部分自动化 —— `apps/desktop/test/project-delete.test.mjs` 固定了两个菜单在传入项目
+ 当前运行会话 id 的情况下都能到达对话框、对话框的运行中会话行与“停止任务并删除”标签、abort 循环
+ 先于 `deleteProject` 执行、`CONFLICT` 兜底路径,以及所有已发布语言包中的新文案;端到端旅程仍为
+ 草稿
+
### US-UI-59 基于会话的后台工具
- 在项目 A 中启动可见轮次,在项目 B 运行时切换到项目 B,并且
检查两个侧边栏状态指示器。
diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md
index aac658f0c..d5dcf6b29 100644
--- a/docs/zh-CN/spec/08-meta/decisions-log.md
+++ b/docs/zh-CN/spec/08-meta/decisions-log.md
@@ -4185,3 +4185,11 @@ the retained upstream work-panel lifecycle. See
- 被提升的行在 Host 投递之前保持锁定:上移/下移、编辑与删除均被禁用,“立即发送”显示为已决定。
- 优先区块以**相邻的用户消息**投递:第一个已优先条目在边界处启动回合,其余条目通过引导通道注入同一回合,因此整块只被回复一次,而不是每行各回一次。被注入条目自己的回合被标记为已取消;运行时拒绝接收的条目仍留在队列中,在下一个边界作为自己的回合启动。
- 已结算的回合对队列具有权威性:终态事件可能被丢弃(Main 不会转发点名它已不再拥有的回合的终态事件),也可能根本没发出,而留在 Host 中处于活动状态的回合会永久占住该会话的队列——“点立即发送再暂停”导致的卡住正源于此。现在结算会在模块内关闭该回合,且在 drain 进行中到达的请求会被重试而不是丢弃。见 ADR 0265、`03-runtime/01-ipc-protocol.md`(§5.6)、`03-runtime/04-data-storage.md`、`04-ux/08-component-spec.md`(§11)与 E2E-QUEUE-promote-orders-delivery-by-click。
+
+## 2026-09-16 —— 运行中任务的拒绝归属删除对话框,而不是菜单(#360,D431)
+
+- 修订 D421 的渲染层一半。侧边栏菜单与项目索引菜单一旦发现该项目有任何会话在运行,就用一条转瞬即逝的 `project.deleteRunningBlocked` 警告拒绝「删除项目」,并在 `ProjectDeleteDialog` 挂载之前就返回。这条拒绝随 toast 一起消失,没有留下任何可继续的路径,于是带有一个在跑任务的项目根本无法删除 —— 这正是 #360(「项目管理中无法真正删除项目」)所描述的体感。
+- 现在两个菜单都始终打开该对话框。每个入口都会传入该项目当前在运行的会话 id(`runningSessions[session.id]`,作用于它本来就已匹配的行:侧边栏的 `entry.sessions`、索引的 `sessionMatchesIndexProject`),对话框在每次渲染时从该 prop 推导文案,因此对话框打开期间新开始或已结束的回合都会在用户确认之前被反映出来。
+- 对话框新增一行警告,点名有 `{{count}}` 个会话正在运行,并把确认按钮文案换成 `project.deleteRunningConfirm`(「停止任务并删除」)。确认会精确中止这些会话,然后才调用 `deleteProject`,所以删除一个正在运行的回合仍然是针对已陈述后果的第二次显式确认。取消不删除任何东西。
+- 宿主侧的守卫未改动:只要仍有关联会话存在运行中的回合,`projects.remove` 依旧以 1008 / `CONFLICT` 拒绝,对话框也依旧把该拒绝映射为 `project.deleteRunningBlocked`。中止循环之后才开始的回合,正是这条兜底覆盖的情况。
+- 仅渲染层:无协议、存储、宿主、权限或迁移改动,也没有新增默认值。`project.deleteRunningBlocked` 的含义、文案与全部翻译保持不变。见 ADR 0251、D421 与 E2E-PROJECT-delete-running-sessions-are-named-and-stopped。
diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts
index 7d0d434ea..2b3d8282d 100644
--- a/packages/i18n/src/locales/de/index.ts
+++ b/packages/i18n/src/locales/de/index.ts
@@ -1377,6 +1377,9 @@ sklm: {
"deleteSessions_other": "{{count}} gespeicherte Sitzungen werden mit ihren Verläufen dauerhaft gelöscht.",
"deleteFolderKept": "Der Ordner auf dem Datenträger wird nicht gelöscht.",
"deleteRunningBlocked": "Stoppen Sie die laufenden Aufgaben dieses Projekts, bevor Sie es löschen.",
+ "deleteRunning_one": "In diesem Projekt läuft noch {{count}} Sitzung. Beim Löschen wird sie gestoppt.",
+ "deleteRunning_other": "In diesem Projekt laufen noch {{count}} Sitzungen. Beim Löschen werden sie gestoppt.",
+ "deleteRunningConfirm": "Aufgaben stoppen und löschen",
"deleteConfirm": "Projekt löschen",
"deleteCancel": "Abbrechen",
"deleting": "Wird gelöscht…",
diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts
index 53464dbb3..9d131ad72 100644
--- a/packages/i18n/src/locales/en/index.ts
+++ b/packages/i18n/src/locales/en/index.ts
@@ -1394,6 +1394,9 @@ sklm: {
deleteSessions_other: "{{count}} saved sessions and their transcripts are deleted permanently.",
deleteFolderKept: "The folder on disk is not deleted.",
deleteRunningBlocked: "Stop this project's running tasks before deleting it.",
+ deleteRunning_one: "{{count}} session in this project is still running. Deleting the project stops it.",
+ deleteRunning_other: "{{count}} sessions in this project are still running. Deleting the project stops them.",
+ deleteRunningConfirm: "Stop tasks and delete",
deleteConfirm: "Delete project",
deleteCancel: "Cancel",
deleting: "Deleting…",
diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts
index bdc44b7b2..d6a434f2d 100644
--- a/packages/i18n/src/locales/es/index.ts
+++ b/packages/i18n/src/locales/es/index.ts
@@ -1377,6 +1377,9 @@ sklm: {
"deleteSessions_other": "{{count}} sesiones guardadas se eliminarán de forma permanente junto con sus transcripciones.",
"deleteFolderKept": "La carpeta del disco no se elimina.",
"deleteRunningBlocked": "Detenga las tareas en ejecución de este proyecto antes de eliminarlo.",
+ "deleteRunning_one": "Todavía se está ejecutando {{count}} sesión de este proyecto. Al eliminar el proyecto se detendrá.",
+ "deleteRunning_other": "Todavía se están ejecutando {{count}} sesiones de este proyecto. Al eliminar el proyecto se detendrán.",
+ "deleteRunningConfirm": "Detener tareas y eliminar",
"deleteConfirm": "Eliminar proyecto",
"deleteCancel": "Cancelar",
"deleting": "Eliminando…",
diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts
index b316d7fe0..83f5511f7 100644
--- a/packages/i18n/src/locales/fr/index.ts
+++ b/packages/i18n/src/locales/fr/index.ts
@@ -1377,6 +1377,9 @@ sklm: {
"deleteSessions_other": "{{count}} sessions enregistrées seront supprimées définitivement avec leurs transcriptions.",
"deleteFolderKept": "Le dossier sur le disque n'est pas supprimé.",
"deleteRunningBlocked": "Arrêtez les tâches en cours de ce projet avant de le supprimer.",
+ "deleteRunning_one": "{{count}} session de ce projet est encore en cours d'exécution. La suppression du projet l'arrêtera.",
+ "deleteRunning_other": "{{count}} sessions de ce projet sont encore en cours d'exécution. La suppression du projet les arrêtera.",
+ "deleteRunningConfirm": "Arrêter les tâches et supprimer",
"deleteConfirm": "Supprimer le projet",
"deleteCancel": "Annuler",
"deleting": "Suppression…",
diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts
index fdd1487ce..e3bb642ba 100644
--- a/packages/i18n/src/locales/ko/index.ts
+++ b/packages/i18n/src/locales/ko/index.ts
@@ -1395,6 +1395,9 @@ sklm: {
deleteSessions_other: "{{count}}개의 저장된 세션이 대화 기록과 함께 영구적으로 삭제됩니다.",
deleteFolderKept: "디스크의 폴더는 삭제되지 않습니다.",
deleteRunningBlocked: "이 프로젝트에서 실행 중인 작업을 중지한 후 프로젝트를 삭제하세요.",
+ deleteRunning_one: "이 프로젝트에서 아직 실행 중인 세션이 {{count}}개 있습니다. 프로젝트를 삭제하면 중지됩니다.",
+ deleteRunning_other: "이 프로젝트에서 아직 실행 중인 세션이 {{count}}개 있습니다. 프로젝트를 삭제하면 모두 중지됩니다.",
+ deleteRunningConfirm: "작업 중지 후 삭제",
deleteConfirm: "프로젝트 삭제",
deleteCancel: "취소",
deleting: "삭제 중…",
diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts
index 91b800fd0..5f47afb4d 100644
--- a/packages/i18n/src/locales/tr/index.ts
+++ b/packages/i18n/src/locales/tr/index.ts
@@ -1395,6 +1395,9 @@ sklm: {
deleteSessions_other: "{{count}} kayıtlı oturum, konuşma dökümleriyle birlikte kalıcı olarak silinir.",
deleteFolderKept: "Diskteki klasör silinmez.",
deleteRunningBlocked: "Bu projeyi silmeden önce çalışan görevleri durdurun.",
+ deleteRunning_one: "Bu projede hâlâ çalışan {{count}} oturum var. Proje silinirse durdurulur.",
+ deleteRunning_other: "Bu projede hâlâ çalışan {{count}} oturum var. Proje silinirse hepsi durdurulur.",
+ deleteRunningConfirm: "Görevleri durdur ve sil",
deleteConfirm: "Projeyi sil",
deleteCancel: "İptal",
deleting: "Siliniyor…",
diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts
index e6bd69d2b..8a0c99d49 100644
--- a/packages/i18n/src/locales/zh-CN/index.ts
+++ b/packages/i18n/src/locales/zh-CN/index.ts
@@ -1383,6 +1383,9 @@ sklm: {
deleteSessions_other: "{{count}} 个会话及其对话记录将被永久删除。",
deleteFolderKept: "磁盘上的文件夹不会被删除。",
deleteRunningBlocked: "请先停止该项目中正在运行的任务,再删除项目。",
+ deleteRunning_one: "该项目中还有 {{count}} 个会话正在运行,删除项目会将其停止。",
+ deleteRunning_other: "该项目中还有 {{count}} 个会话正在运行,删除项目会将它们停止。",
+ deleteRunningConfirm: "停止任务并删除",
deleteConfirm: "删除项目",
deleteCancel: "取消",
deleting: "删除中…",
diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts
index a01d72260..208d479b6 100644
--- a/packages/i18n/src/locales/zh-TW/index.ts
+++ b/packages/i18n/src/locales/zh-TW/index.ts
@@ -1383,6 +1383,9 @@ sklm: {
deleteSessions_other: "{{count}} 個會話及其對話記錄將被永久刪除。",
deleteFolderKept: "磁碟上的資料夾不會被刪除。",
deleteRunningBlocked: "請先停止該專案中正在執行的任務,再刪除專案。",
+ deleteRunning_one: "該專案中還有 {{count}} 個會話正在執行,刪除專案會將其停止。",
+ deleteRunning_other: "該專案中還有 {{count}} 個會話正在執行,刪除專案會將它們停止。",
+ deleteRunningConfirm: "停止任務並刪除",
deleteConfirm: "刪除專案",
deleteCancel: "取消",
deleting: "刪除中…",