From 1f239de2f80ad9a5004c921b935ef9587d1f4f31 Mon Sep 17 00:00:00 2001
From: vastsa
Date: Wed, 16 Sep 2026 02:59:17 +0800
Subject: [PATCH] fix(projects): make project deletion reachable while sessions
are running
Both project menus refused Delete project with a transient
`project.deleteRunningBlocked` toast whenever one of the project's sessions
was running, and returned before `ProjectDeleteDialog` was mounted. The
refusal disappeared with the toast and left no path forward, so a project
with one live task could not be deleted at all (vastsa/PI-Desktop#360).
The menus now always open the dialog and hand it the project's live running
session ids. The dialog names how many sessions are still running, swaps its
confirm label to `project.deleteRunningConfirm` ("Stop tasks and delete"),
and aborts exactly those sessions before deleting the project. Removing a
running session therefore stays an explicit, second confirmation of a stated
consequence, and 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
reports that refusal with `project.deleteRunningBlocked` for a turn that
starts after the abort loop.
Refs #360 item 5 only. Items 1 and 2 are visual claims with no code-level
inconsistency (every choice/interaction panel already carries a `--ds-*`
surface, and the sidebar and window backgrounds differ in both themes);
items 3 and 4 are feature requests that change a default shortcut or add UI
and belong in their own issues.
Renderer only: no protocol, storage, host, permission, or migration change,
and no new default. `project.deleteRunningBlocked` keeps its meaning, copy,
and every translation. See D429 and
E2E-PROJECT-delete-running-sessions-are-named-and-stopped.
---
.../src/components/ProjectDeleteDialog.tsx | 40 +++++-
apps/desktop/src/components/Sidebar.tsx | 12 +-
apps/desktop/src/pages/ProjectsPage.tsx | 25 ++--
apps/desktop/src/styles/projects.css | 19 +++
apps/desktop/test/project-delete.test.mjs | 116 ++++++++++++++----
docs/spec/06-delivery/04-e2e-test-plan.md | 38 +++++-
docs/spec/08-meta/decisions-log.md | 28 +++++
.../spec/06-delivery/04-e2e-test-plan.md | 29 ++++-
packages/i18n/src/locales/de/index.ts | 3 +
packages/i18n/src/locales/en/index.ts | 3 +
packages/i18n/src/locales/es/index.ts | 3 +
packages/i18n/src/locales/fr/index.ts | 3 +
packages/i18n/src/locales/ko/index.ts | 3 +
packages/i18n/src/locales/tr/index.ts | 3 +
packages/i18n/src/locales/zh-CN/index.ts | 3 +
packages/i18n/src/locales/zh-TW/index.ts | 3 +
16 files changed, 280 insertions(+), 51 deletions(-)
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 685e1b5fc..d668a026f 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -8218,9 +8218,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
@@ -8233,6 +8235,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, D429
+- **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 c31606cc2..5dace09d1 100644
--- a/docs/spec/08-meta/decisions-log.md
+++ b/docs/spec/08-meta/decisions-log.md
@@ -5321,3 +5321,31 @@ Users reported a tooltip that occasionally never went away (the trigger
unmounted, the window lost focus, or the pointer left the window without a
leave event) and a session row whose hidden overflow control silently opened
its menu instead of the conversation.
+
+## 2026-09-16 — The delete dialog, not the menu, owns the running-task refusal (#360, D429)
+
+- 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 88f8a3379..aa42a9491 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、D429
+- **验收**:D(工作区)、品质
+- **里程碑**:M6+
+- **状态**:部分自动化 —— `apps/desktop/test/project-delete.test.mjs` 固定了两个菜单在传入项目
+ 当前运行会话 id 的情况下都能到达对话框、对话框的运行中会话行与“停止任务并删除”标签、abort 循环
+ 先于 `deleteProject` 执行、`CONFLICT` 兜底路径,以及所有已发布语言包中的新文案;端到端旅程仍为
+ 草稿
+
### US-UI-59 基于会话的后台工具
- 在项目 A 中启动可见轮次,在项目 B 运行时切换到项目 B,并且
检查两个侧边栏状态指示器。
diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts
index 6cf18c44d..ddc382d1b 100644
--- a/packages/i18n/src/locales/de/index.ts
+++ b/packages/i18n/src/locales/de/index.ts
@@ -1373,6 +1373,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 7b17d97e7..2ec4e1fb6 100644
--- a/packages/i18n/src/locales/en/index.ts
+++ b/packages/i18n/src/locales/en/index.ts
@@ -1390,6 +1390,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 8e504dc45..e4cbb6c5a 100644
--- a/packages/i18n/src/locales/es/index.ts
+++ b/packages/i18n/src/locales/es/index.ts
@@ -1373,6 +1373,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 2b0172b8a..fe365253d 100644
--- a/packages/i18n/src/locales/fr/index.ts
+++ b/packages/i18n/src/locales/fr/index.ts
@@ -1373,6 +1373,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 04c5f5b59..350bdb2b6 100644
--- a/packages/i18n/src/locales/ko/index.ts
+++ b/packages/i18n/src/locales/ko/index.ts
@@ -1391,6 +1391,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 24c1b2a4a..1b20c2816 100644
--- a/packages/i18n/src/locales/tr/index.ts
+++ b/packages/i18n/src/locales/tr/index.ts
@@ -1391,6 +1391,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 25f6ff913..a6f505a58 100644
--- a/packages/i18n/src/locales/zh-CN/index.ts
+++ b/packages/i18n/src/locales/zh-CN/index.ts
@@ -1379,6 +1379,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 64be5a530..32b87fc12 100644
--- a/packages/i18n/src/locales/zh-TW/index.ts
+++ b/packages/i18n/src/locales/zh-TW/index.ts
@@ -1379,6 +1379,9 @@ sklm: {
deleteSessions_other: "{{count}} 個會話及其對話記錄將被永久刪除。",
deleteFolderKept: "磁碟上的資料夾不會被刪除。",
deleteRunningBlocked: "請先停止該專案中正在執行的任務,再刪除專案。",
+ deleteRunning_one: "該專案中還有 {{count}} 個會話正在執行,刪除專案會將其停止。",
+ deleteRunning_other: "該專案中還有 {{count}} 個會話正在執行,刪除專案會將它們停止。",
+ deleteRunningConfirm: "停止任務並刪除",
deleteConfirm: "刪除專案",
deleteCancel: "取消",
deleting: "刪除中…",