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: "刪除中…",