From ae6a9d8c45ce8e8b584f8a317df617439a312eba Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 15:30:55 +0100 Subject: [PATCH] feat: feedback verbetering 05 --- project_docs/API_GOLDEN.md | 5 ++ .../__pycache__/tasks_runner.cpython-313.pyc | Bin 30563 -> 31712 bytes .../move_task_service.cpython-313.pyc | Bin 11559 -> 11928 bytes .../backend/app/services/move_task_service.py | 5 +- webui/backend/app/tasks_runner.py | 24 +++++- webui/backend/data/tasks.db | Bin 356352 -> 368640 bytes .../test_api_move_golden.cpython-313.pyc | Bin 36203 -> 40916 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 92297 -> 92597 bytes .../tests/golden/test_api_move_golden.py | 79 ++++++++++++++++++ .../tests/golden/test_ui_smoke_golden.py | 29 ++++--- webui/html/app.js | 12 +-- webui/html/base.css | 10 +-- webui/html/index.html | 6 +- 13 files changed, 139 insertions(+), 31 deletions(-) diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 0fc7452..817945b 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -86,6 +86,11 @@ Success (202): } ``` +Notes: +- Batch move is supported as one task-based operation via `{ "sources": [...], "destination_base": "..." }`. +- Cross-root batch move is supported for file-only selections. +- Cross-root batch move with any directory in the selection remains unsupported in v1. + ## Tasks read endpoints ### `GET /api/tasks` diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index e924dbfc296d5ec5356de3595127b4b506df675c..1b285864fbb8b3d56a16abfad2b81a8b8a542803 100644 GIT binary patch delta 2977 zcmai$3s98T6@c&A$GP*^yX21XZ z=iYPf`Omrc{CoQY82=ce@0-m=Ca%0U&h(^g8jZHY(5NS;MpdWfS|9VN>!ey8*EyMx zHFf%012-tLw$4~<;wD9w>Y{4R-0Wm_=4L)!GV|$+ROFK}9zD7*2=in;#Hyrpmd<)dECM+2D_?G%ImC;Q6P(2N*@rc?h_)B#P)Zkrp_M&QE zE{*r9c#f~um#55~>8tbQD^jk|6(~}kuaM_|Mdhsz8=E_WYnz4#2K@Y9G&X8Jff%_? zy8*x|Uy$kmQso%^e1Pe4ona+Fj{JS2cv^tBOb?+#elN-ZP%r<{oCDA-$H%w<+T~(P zw;BfJ1Mwk%F1bHpsTTIir;||wUb)7x1mL{9+j$fqC>Ob(RYR0~ZblrypXFa=ysd$K z@(bAu0A|YXJxU5|(e2H$+I^Bw_jziI zU}DUtpR2{Ze4liRF%xs*&8#FWE{=g@Jelu-G; zIKR*-HHxr;7qPSOFr?rtS82hR-7#i&kJ&wA_G$0AGY$_Oefr4LCrqz5U-6V*aW9FO zn5PmVO!6dtV|gT+S?##lm92BJ)SO5;;A^gJj$pD_=dIXsRYkVyVz#l$qPYU^+Y~cQ`VIJ&R@8=^lZyVtN;YG|`Jj=|F(=1J2PjHAI#Gb|IRl+z)GbRmM z=y3+&8Nw)GJI*MpfwOX;?0_16h^ERc_zMWH}WU@t>vInO(d8Ish(?fW( zy;L4;dLBehU1CeWignFS*o}S7)1gWpZ7x&`Cl{Znu-p;rg!SAnDjW27^YdtL&6SkH zFpBl93*ZTPduu1e&^a@SpPDha|4kL1Xef_KXD!-Hp`)wQ#qgtsGS%{G4CUG|(`LoA z#zef|kcht6cvYJj!))TVGbg{PR32Vl5taAfn1JV?ay^OU;O_Y(x zuD1W^n&KWC5|8s*MKtvkjr|M4*y0e4d_XFbmYwuJl2*fNwovV0tAXDZKD?%h6VoPS zPBHQq2>pcp1Pvh|5Z3xR9|(lCm8}((D>!A-tD7jJi{?zJ5j|3a;kKY)P$%G=xF+_~ zF-&XoS~gPoT1C3IvPc{=m~rz{w!KfrZ7~@Ve;;EXvskZLoFR+znk6e_$vTnsdTXe# z>T>g!FL(5Xn)@$XvVLnB82`VMLY9Fko^nE#oL8D3`MNT5=~YX$=s^bi{(_J`^|_ca zebhBQo@-jLS!{1s(>eQ5^9s(XlrTWjb-YVAmNd_38_;WjU2<^(7S$)mov#TPqXg-nJ;neuMJc zDBzVIui#|es7U;M<;a}E)9usYkQ{D*M%AkHGcukycVa&>zK?uG@iink0Nm5$uCp%~mc(esRZz?b22OZPlj2!=z_%z@P1w-YInd?r>l5dfiJik&2&HI7N6kn0}Wb(7&$FKjiPuM$6g@309g*A@FX3pOAomT3Z8oSlD;orKa(CLIS}i z@Lw%dBYb+L646w6l3d#F1UQZz0~r}g8I{!bkW0}C=zWxy=paXm)`>qDC;}JW8Ypy5 zYN;w-BX^OVQlU{HJ;Ygoa`DCgaloGcDvc4SMS+zMnv;Bw(6}NgKHS2{ZH}7i z5YGkDGL)12n477~P6SKFXkC{W`w#M}#G|b02K>o7D;$>_*3F|9y>l?NdI3)--j-_;gs zjStqy#lgie*R6Go$66y=agRtWVOm5-lAa}}MGPbvSz=PeL=ycciNug(VMg-<3=A!h z$A?BBEhcR2=vX~GG|c@S9b8nB@%sW{RUjxnCY#oeiU6WFxaQ;vWkd}CH<Z>Ou2EJF!?vy`2tn| delta 2004 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 F{sBnQe+&Qs delta 1292 zcmah}U5Fc16ux(MlHDeg$tIKE&1Pr1n`E0!HmgWwQ-l^P7+Y6D#_47gyGwp1B)BHa zok@#}xD?+M3UW{@7S~e5R_cSK=z}2k$)7hV;xhQM?oQLl_^WE>9 zbI-lZ+_?N%&G)y@=Rx%O`p#Fq{FlCWJ@~?C-}4UUssOj5q0E`92sK26>J!Yx9+gv1 zsJ!aYxF@jcE%G|92jGT~Ow=?~k;b0mSY4`w;EzL)04p}|X%OY>?I z8m9`XujuLv8ed5;Ju95XW3PhN$_ROpg6hemiuJjpsG>l}mv;GRy?NFZr{PInlEu1{ zjgZD4RT7cve~Qy4DjC*c%BM~j`M#*ZceCM?=2JyA@IP6!@`+&4r6AgE$?mqyD(#7? z=YUP6=j#uL0JV9|9)`y>GNb@7|UcZFqLM*Y<1y}WCrE~ zWn2LxaLc<+Ymq904UmJgI0nxIRo8BB$BMvaCgQQC8ck!cJv4Q=AFKuZBuu^5q%jzh zmTr&=$dZg}a8{ZLSbm~Y>lMAdHC~2IDG6^&Y2HtB;J&nhuY(x+C4Arw{~BNYIllVP zyZT#j<{+Foh?Pg)@cVf2Dn_$S{Md?DA3v&MK@yE1&3zC~l_LCuv*A*L|30)_Qbs>S z_S{&7QnHOr_#$~2JvIkT$VNzj61fbY&gGtK(a@Ug8C#v+wr;s;(C!gSaCW=3(=fZF z%{-FcH_7W;MBi?hy({{7Ws?;eLO^~IcVJV_PHRl4Fu26vRro;O@oup241;wDD$nEh zMm1%D#{}L?B%T>C@&5A`gma5$)>jW=3uZ* z?%H9y*D|v_xz5U*XE2so8f|2^uw^!mV}Orxa-7-7=M=J>okH$8F+9v&SQxO?XBq5K zumr=Tm(qGoH@ky2X)^s9oXe;2htSEdOkTvUHT+j|bTfY&Q|VkGhQEQ0LLpG)aT`BE JHWBt0`41DtAP@im diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py index 64b234d..049e6bf 100644 --- a/webui/backend/app/services/move_task_service.py +++ b/webui/backend/app/services/move_task_service.py @@ -95,10 +95,11 @@ class MoveTaskService: ) root_alias = next(iter(source_aliases)) - if root_alias != resolved_destination_base.alias: + has_directory = any(resolved_source.absolute.is_dir() for resolved_source in resolved_sources) + if root_alias != resolved_destination_base.alias and has_directory: raise AppError( code="invalid_request", - message="Cross-root batch directory move is not supported in v1", + message="Cross-root batch move with directories is not supported in v1", status_code=400, details={"destination_base": destination_base}, ) diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index 538193a..a5f7f86 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import os import shutil import threading @@ -355,7 +356,13 @@ class TaskRunner: completed_items = self._move_directory_item(task_id, item, completed_items, total_items) else: file_entry = self._file_entries(item)[0] - completed_items = self._move_single_planned_file(task_id, file_entry, completed_items, total_items) + completed_items = self._move_single_planned_file( + task_id, + file_entry, + completed_items, + total_items, + same_root=bool(item.get("same_root", True)), + ) if self._is_cancel_requested(task_id): self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) return @@ -631,6 +638,8 @@ class TaskRunner: file_entry: dict[str, str], completed_items: int, total_items: int, + *, + same_root: bool, ) -> int: self._repository.update_progress( task_id=task_id, @@ -638,7 +647,18 @@ class TaskRunner: total_items=total_items, current_item=file_entry["label"], ) - self._filesystem.move_file(source=file_entry["source"], destination=file_entry["destination"]) + try: + if same_root: + self._filesystem.move_file(source=file_entry["source"], destination=file_entry["destination"]) + else: + self._filesystem.copy_file(source=file_entry["source"], destination=file_entry["destination"]) + self._filesystem.delete_file(Path(file_entry["source"])) + except OSError as exc: + if same_root and exc.errno == errno.EXDEV: + self._filesystem.copy_file(source=file_entry["source"], destination=file_entry["destination"]) + self._filesystem.delete_file(Path(file_entry["source"])) + else: + raise completed_items += 1 self._repository.update_progress( task_id=task_id, diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 100c233fc32f2ffb3e2c6c895c7380507ea6dd12..8e6eeeed29fd569741a5c58ebcd3eb98cd370a91 100644 GIT binary patch delta 8836 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 QVg}*zC?`o(WmyURA7}JKYybcN delta 2445 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 delta 5409 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 UI$du$U0+Gq`l3BcJb$e4f33n37qXTUa2? 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 diff --git a/webui/backend/tests/golden/test_api_move_golden.py b/webui/backend/tests/golden/test_api_move_golden.py index 9d9a1ee..6ab2adf 100644 --- a/webui/backend/tests/golden/test_api_move_golden.py +++ b/webui/backend/tests/golden/test_api_move_golden.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import errno import sys import tempfile import threading @@ -52,6 +53,11 @@ class BlockingMoveFilesystemAdapter(FilesystemAdapter): super().move_file(source, destination) +class CrossDeviceMoveFilesystemAdapter(FilesystemAdapter): + def move_file(self, source: str, destination: str) -> None: + raise OSError(errno.EXDEV, "Invalid cross-device link") + + class MoveApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -312,6 +318,59 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertTrue((self.root1 / "b.txt").exists()) self.assertFalse((target / "b.txt").exists()) + def test_move_batch_cross_root_files_success(self) -> None: + first = self.root1 / "first.txt" + second = self.root1 / "second.txt" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first.txt", "storage1/second.txt"], + "destination_base": "storage2", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((self.root2 / "first.txt").exists()) + self.assertTrue((self.root2 / "second.txt").exists()) + self.assertFalse(first.exists()) + self.assertFalse(second.exists()) + + def test_move_batch_cross_root_files_falls_back_from_exdev(self) -> None: + first = self.root1 / "first.txt" + second = self.root1 / "second.txt" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + + path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)}) + self._set_services(path_guard=path_guard, filesystem=CrossDeviceMoveFilesystemAdapter()) + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first.txt", "storage1/second.txt"], + "destination_base": "storage2", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((self.root2 / "first.txt").exists()) + self.assertTrue((self.root2 / "second.txt").exists()) + self.assertFalse(first.exists()) + self.assertFalse(second.exists()) + def test_move_batch_cross_root_directories_blocked(self) -> None: first = self.root1 / "first-dir" second = self.root1 / "second-dir" @@ -329,6 +388,26 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["error"]["code"], "invalid_request") + self.assertEqual(response.json()["error"]["message"], "Cross-root batch move with directories is not supported in v1") + + def test_move_batch_cross_root_mixed_files_and_directories_blocked(self) -> None: + first = self.root1 / "first.txt" + first.write_text("a", encoding="utf-8") + second = self.root1 / "second-dir" + second.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/first.txt", "storage1/second-dir"], + "destination_base": "storage2", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + self.assertEqual(response.json()["error"]["message"], "Cross-root batch move with directories is not supported in v1") def test_move_batch_mixed_root_selection_blocked(self) -> None: first = self.root1 / "first-dir" diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 3ba8b38..c7b24df 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -368,7 +368,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); + const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -388,28 +388,28 @@ class UiSmokeGoldenTest(unittest.TestCase): ]; const activeTasks = activeTasksFromItems(mixedTasks); - assert(activeTasks.length === 5, "Only active task-based file actions should count as active"); + assert(activeTasks.length === 4, "Only active user-visible operations should count as active"); assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); - assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow"); + assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should stay out of operation UI until it maps cleanly to one user-visible operation"); assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping"); - assert(activeTaskChipLabel(activeTasks) === "5 active tasks", "Chip label should reflect active task count"); + assert(activeTaskChipLabel(activeTasks) === "4 active operations", "Chip label should reflect active operation count"); updateHeaderTaskState(mixedTasks); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); - assert(elements["header-task-chip-label"].textContent === "5 active tasks", "Chip label should render active task count"); + assert(elements["header-task-chip-label"].textContent === "4 active operations", "Chip label should render active operation count"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling"); setHeaderTaskPopoverOpen(true); assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); - assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active file-action tasks"); + assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active operations"); const moveRow = elements["header-task-popover-list"].children[1]; const moveProgress = moveRow.children[3]; const moveCurrent = moveRow.children[4]; assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available"); assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item"); - const cancellingRow = elements["header-task-popover-list"].children[4]; + const cancellingRow = elements["header-task-popover-list"].children[3]; const cancellingProgress = cancellingRow.children[3]; const cancellingCurrent = cancellingRow.children[4]; const cancellingSubtext = cancellingRow.children[5]; @@ -417,7 +417,7 @@ class UiSmokeGoldenTest(unittest.TestCase): assert(cancellingCurrent.textContent === "nested/final-file.txt", "Cancelling tasks should show current item"); assert(cancellingSubtext.textContent === "Stopping after current item...", "Cancelling tasks should explain stop semantics"); const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0]; - const cancellingActionButton = elements["header-task-popover-list"].children[4].children[6].children[0]; + const cancellingActionButton = elements["header-task-popover-list"].children[3].children[6].children[0]; assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action"); assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); @@ -582,7 +582,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); + const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -905,6 +905,7 @@ class UiSmokeGoldenTest(unittest.TestCase): def test_ui_static_assets_are_present_and_mapped(self) -> None: mount = self._ui_mount() static_root = Path(mount.app.directory) + index_html = (static_root / "index.html").read_text(encoding="utf-8") self.assertTrue((static_root / "app.js").exists()) self.assertTrue((static_root / "base.css").exists()) self.assertTrue((static_root / "theme-default.css").exists()) @@ -964,9 +965,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('let headerTaskState = {', app_js) - self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js) + self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js) self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js) - self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", app_js) + self.assertIn("The header chip/popover reflects user-visible file operations, not every task-backed file action.", app_js) self.assertIn('function headerTaskElements()', app_js) self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function activeTasksFromItems(items)', app_js) @@ -986,12 +987,12 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function renderHeaderTaskChip(items)', app_js) self.assertIn('function updateHeaderTaskState(taskItems)', app_js) self.assertIn('function applyTaskSnapshot(taskItems)', app_js) - self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', app_js) + self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js) self.assertIn('return task.operation === "copy" || task.operation === "duplicate";', app_js) self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js) self.assertIn('return `${action} running`;', app_js) self.assertIn('return "Stopping after current item...";', app_js) - self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js) + self.assertIn('ACTIVE_OPERATION_OPERATIONS.has(task.operation)', app_js) self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js) self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js) self.assertIn('const headerTasks = headerTaskElements();', app_js) @@ -1135,6 +1136,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('destination_base: baseDestination,', app_js) self.assertIn('setStatus("Copy: operation started");', app_js) self.assertIn('setStatus("Move: operation started");', app_js) + self.assertIn('Active operations', index_html) + self.assertIn('No active operations right now.', app_js) self.assertIn('const confirmed = await openConfirmModal({', app_js) self.assertIn('title: selectedItems.length === 1 ? "Delete item?" : "Delete selected items?"', app_js) self.assertIn('title: "Discard unsaved changes?"', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 94df0d8..0a36ff3 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -120,8 +120,8 @@ let headerTaskState = { pollTimer: null, lastRenderKey: "", }; -// The header chip reflects only user-visible file actions that use the shared task system. -const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); +// The header chip/popover reflects user-visible file operations, not every task-backed file action. +const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); const VALID_THEME_FAMILIES = [ "default", @@ -3870,7 +3870,7 @@ function formatTaskLine(task) { } function isActiveTask(task) { - return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ACTIVE_TASK_STATUSES.has(task.status); + return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ACTIVE_TASK_STATUSES.has(task.status); } function activeTasksFromItems(items) { @@ -3878,7 +3878,7 @@ function activeTasksFromItems(items) { } function taskIsCancellable(task) { - return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status); + return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status); } async function cancelTaskRequest(taskId) { @@ -3925,7 +3925,7 @@ function compactTaskCurrentItem(task) { function activeTaskChipLabel(items) { const count = Array.isArray(items) ? items.length : 0; if (count !== 1) { - return `${count} active task${count === 1 ? "" : "s"}`; + return `${count} active operation${count === 1 ? "" : "s"}`; } const task = items[0]; const action = formatTaskOperationLabel(task); @@ -4025,7 +4025,7 @@ function renderHeaderTaskPopover(items) { if (!Array.isArray(items) || items.length === 0) { const empty = document.createElement("div"); empty.className = "header-task-item-empty"; - empty.textContent = "No active tasks right now."; + empty.textContent = "No active operations right now."; elements.popoverList.append(empty); headerTaskState.lastRenderKey = renderKey; return; diff --git a/webui/html/base.css b/webui/html/base.css index 731dcaa..cd541ec 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -100,8 +100,8 @@ body { position: absolute; top: calc(100% + 8px); right: 0; - width: min(360px, calc(100vw - 24px)); - padding: 12px; + width: min(540px, calc(100vw - 24px)); + padding: 14px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface-elevated); @@ -131,8 +131,8 @@ body { .header-task-popover-list { display: flex; flex-direction: column; - gap: 8px; - max-height: 260px; + gap: 10px; + max-height: 360px; overflow-y: auto; } @@ -140,7 +140,7 @@ body { border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface); - padding: 8px 9px; + padding: 10px 12px; } .header-task-item-title { diff --git a/webui/html/index.html b/webui/html/index.html index 7e9e2b3..4c8835d 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -34,11 +34,11 @@ aria-expanded="false" aria-controls="header-task-popover" > - 1 active task + 1 active operation -