diff --git a/project_docs/UI_FEEDACK.md b/project_docs/UI_FEEDACK.md new file mode 100644 index 0000000..1700e1b --- /dev/null +++ b/project_docs/UI_FEEDACK.md @@ -0,0 +1,252 @@ +1 analyse + +De repo heeft al een bruikbaar taskmodel voor copy, move, download en duplicate, maar de main WebUI gebruikt dat model voor copy/move nog nauwelijks. In de hoofd-UI ziet de gebruiker na start nu vooral een korte statusregel of summary; live voortgang staat feitelijk alleen in `F1 > Settings > Logs`. Daardoor ontbreekt directe, persistente feedback in de hoofd-UI en is er geen zichtbare rem op dubbel starten. + +Belangrijkste conclusie: + +- Copy en move hebben al echte backend-tasks met progressvelden. +- De bron van truth voor lopende copy/move-taken is al `/api/tasks`. +- Er bestaat nu geen cancel/abort voor copy of move. +- Een eerlijke abortknop voor copy/move kan dus nu niet frontend-only worden toegevoegd. +- De kleinste veilige stap is een compacte live task-indicator in de bestaande header/toolbar-zone, gevoed door de bestaande task-feed. + +2 bestaande functionaliteit + +A. Taskmodel / backend + +- `copy` en `move` gebruiken hetzelfde taskmechanisme via [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py), [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py), [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py) en [move_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/move_task_service.py). +- Taskstatussen die al bestaan in [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py): + - `queued` + - `running` + - `completed` + - `failed` + - daarnaast voor download ook `requested`, `preparing`, `ready`, `cancelled` +- Progressinformatie bestaat al: + - files: `done_bytes`, `total_bytes`, `current_item` + - batch/directory: `done_items`, `total_items`, `current_item` +- Copy: + - file copy gebruikt byte-progress callback + - directory copy is grof: `0/1` naar `1/1` + - batch copy gebruikt item-progress +- Move: + - same-root file move heeft praktisch geen tussentijdse progress, alleen start/einde + - cross-root file move gebruikt copy-progress en delete na afloop + - directory move is grof `0/1` naar `1/1` + - batch move gebruikt item-progress +- Er is al read-API voor tasks: + - `GET /api/tasks` + - `GET /api/tasks/{task_id}` +- Er is geen cancel-API voor copy/move. +- De enige echte cancel in de repo zit nu bij archive-downloads in [archive_download_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/archive_download_task_service.py) en `POST /api/files/download/archive/{task_id}/cancel`. +- Copy/move workers in [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py) hebben geen cooperative cancel checks. +- Copy/move history bestaat al via [history_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/history_repository.py): `queued`, `completed`, `failed`. + +B. Bestaande frontend feedback + +- In de hoofd-UI starten copy en move vanuit [app.js](/workspace/webmanager-mvp/webui/html/app.js): + - `startCopySelected()` + - `executeMoveSelection()` +- Huidige feedback voor copy/move: + - `setStatus(...)` onderin/headerstatus + - `showActionSummary(...)` + - `openFeedbackModal(...)` via `actions-error` +- Die feedback is niet persistent als live taskweergave. +- Er is nu geen compacte taskindicator in de hoofd-UI. +- `state.selectedTaskId` en `refreshTasksSnapshot()` bestaan al in [app.js](/workspace/webmanager-mvp/webui/html/app.js), maar worden voor copy/move alleen gebruikt om een snapshotcount op te halen; er is geen zichtbare hoofd-UI-component die dit toont. +- Buiten download is er geen modal of popover voor actieve taken in de hoofd-UI. + +C. Logs / history / settings + +- `F1 > Settings > Logs` toont al twee side-by-side secties: + - `Tasks` + - `History` +- Deze UI gebruikt al de bestaande feeds: + - `/api/tasks` + - `/api/history` +- Polling bestaat al in [app.js](/workspace/webmanager-mvp/webui/html/app.js): + - `loadTasksForSettings()` + - `loadHistoryForSettings()` + - `loadLogsAndTasksForSettings()` + - `scheduleSettingsLogsPolling()` +- De UI rendert taskdetails al compact via `formatTaskLine(task)`: + - status + - source/destination + - `done_items/total_items` + - `current_item` +- Dat betekent dat de repo al een bruikbare frontend formatteringslaag heeft die ook buiten Settings herbruikbaar is. + +D. Abort/cancel haalbaarheid + +- Copy/move kunnen nu technisch niet veilig worden afgebroken via bestaande code. +- Er is geen taskstatus-overgang of API-contract voor copy/move-cancel. +- Er is geen cooperative worker-check in copy/move loops. +- Er is geen rollback. +- Eerlijke cancelsemantiek voor copy/move zou dus moeten zijn: + - stop resterende verwerking zo snel mogelijk op een checkpunt + - reeds verwerkte bestanden blijven zoals ze zijn + - geen rollback +- Maar die semantiek is nog niet geïmplementeerd. +- Conclusie: een abortknop voor copy/move is nu buiten scope zonder backendwerk. + +3 scope + +Minimale veilige volgende stap, op basis van wat al bestaat: + +- frontend-only hoofd-UI verbetering +- geen layoutwijziging van de dual-pane browse-UI +- geen nieuw vast paneel +- wel een compacte task/status chip in bestaande headerbar of function-bar zone +- alleen zichtbaar als er actieve taken zijn (`queued`, `running`, en eventueel download `requested/preparing`) +- klik opent een kleine popover/dropdown met actieve taken +- popover hergebruikt bestaande taskdata en formattering uit `/api/tasks` +- popover bevat link/actie naar `F1 > Settings > Logs` +- geen abortknop voor copy/move in deze fase + +Waarom dit binnen scope past: + +- gebruikt bestaande task-feed +- gebruikt bestaande taaksemantiek +- verandert de hoofd-layout niet +- geeft persistente feedback zonder modal-first patroon +- is compatibel met de OneDrive-achtige richting: compacte indicator, detail op aanvraag + +4 impact + +Positief: + +- gebruiker ziet direct in de hoofd-UI dat copy/move loopt +- feedback blijft zichtbaar zolang taak actief is +- minder kans op dubbel starten +- geen extra structureel paneel +- F1 Logs blijft intact als detailbron + +Beperkingen: + +- zonder backendwerk is er nog geen eerlijke cancel voor copy/move +- progress blijft zo nauwkeurig als bestaande taskdata toelaat +- same-root move en directory move blijven qua progress relatief grof + +5 risico + +Laag tot middel als alleen de voorgestelde frontendstap wordt gebouwd. + +Belangrijkste risico’s: + +- polling in de hoofd-UI kan onrustig worden als hij niet net zo stabiel wordt gebouwd als de bestaande Settings-polling +- een te opvallende indicator kan visueel concurreren met de bestaande headerstatus +- als een abortknop zonder backendsteun zou worden toegevoegd, zou dat misleidend zijn; dat moet expliciet niet gebeuren + +Expliciet risico buiten scope: + +- copy/move-cancel vereist backend-aanpassing aan taskmodel, runner en waarschijnlijk history + +6 testplan + +Voor de minimale frontendstap: + +- gerichte UI smoke/golden checks voor: + - indicator aanwezig in header/toolbar markup + - indicator alleen bedoeld voor actieve taken + - popover/dropdown markup aanwezig + - link naar bestaande logs-entrypoint aanwezig +- gerichte JS-checks voor: + - actieve taken worden uit `/api/tasks` gefilterd + - `queued`/`running` tonen indicator + - `completed`/`failed` verdwijnen uit de actieve indicator + - polling start/stop logisch zonder extra layoutreset +- geen backend golden updates nodig zolang `/api/tasks` contract ongewijzigd blijft + +Niet nu testen: + +- abort voor copy/move, want die functionaliteit bestaat nog niet + +7 acceptatiecriteria + +Voor de voorgestelde minimale stap: + +- Een gestart copy- of move-proces is zichtbaar in de hoofd-UI zonder navigatie naar `F1 > Settings / Logs`. +- De oplossing verandert de dual-panel layout niet structureel. +- De feedback blijft zichtbaar zolang de taak actief is. +- De oplossing gebruikt bestaande taskdata als bron van truth. +- Er wordt geen fake progress getoond. +- Er wordt geen fake cancelknop getoond voor copy/move. +- Bestaande task/log/history-functionaliteit blijft intact. +- API-contract blijft ongewijzigd. + +Voor abort/cancel: + +- Niet acceptabel in deze fase zonder backendsteun. +- Eerst aparte backendfase nodig. + +8 codex-uitvoering / voorstel + +Huidige stap: + +- Alleen analyse uitgevoerd. +- Geen functionele implementatie gedaan. + +Waarom: + +- `CHANGE_POLICY.md` zegt dat frontend flow aanpassen eerst een voorstel nodig heeft. +- De opdracht vroeg expliciet om eerst grondige repo-inspectie en pas daarna een minimaal voorstel. +- Cancel/abort voor copy/move is niet eerlijk implementeerbaar zonder backendwerk. + +Minimaal wijzigingsvoorstel dat ik hierna zou uitvoeren als vervolgstap: + +1. Frontend-only compacte task chip +- plaats in `#title-zone-actions` of direct naast `#status` +- toont bijvoorbeeld: + - `1 task running` + - `3 active tasks` + +2. Kleine popover/dropdown +- opent op klik op de chip +- toont alleen actieve taken uit `/api/tasks` +- hergebruikt bestaande `formatTaskLine(task)` of een kleine variant daarop +- toont eerlijke status: + - `queued` + - `running` + - eventueel later download `requested/preparing` + +3. Polling hergebruik +- hergebruik bestaande `/api/tasks` +- implementeer lichte polling alleen als er actieve taken zijn of als de popover open is +- gebruik stabiele rerender-aanpak zoals in Settings > Logs + +4. Doorgang naar detail +- knop of link `View in Logs` +- opent bestaande `F1 > Settings > Logs` + +5. Expliciet nog niet doen +- geen cancelknop voor copy/move +- geen extra paneel +- geen fake progressbar + +Vervolgvoorstel voor latere backendfase als abort gewenst is: + +- copy/move taskstatus uitbreiden met `cancelled` +- cancel-endpoint voor copy/move +- cooperative checks in `TaskRunner` tussen items/chunks +- eerlijke semantiek: + - stop resterende verwerking + - reeds verwerkte bestanden blijven bestaan + - geen rollback + +9 gewijzigde bestanden + +- [project_docs/UI_FEEDACK.md](/workspace/webmanager-mvp/project_docs/UI_FEEDACK.md) + +10 uitgevoerde tests + +Wel gedaan: + +- code-inspectie van backend taskmodel, runners, services, routes en frontend task/log UI + +Niet gedaan: + +- geen functionele tests +- geen implementatiechecks + +Reden: + +- deze stap is bewust alleen analyse + voorstel, geen implementatie diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 855c712..121f2a6 100644 Binary files a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc and b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/webui/backend/app/__pycache__/main.cpython-313.pyc b/webui/backend/app/__pycache__/main.cpython-313.pyc index bbbfe75..cf50032 100644 Binary files a/webui/backend/app/__pycache__/main.cpython-313.pyc and b/webui/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc index 581c374..d4322a0 100644 Binary files a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc and b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc differ diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index 11d23ee..e179f08 100644 Binary files a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc and b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc differ diff --git a/webui/backend/app/db/history_repository.py b/webui/backend/app/db/history_repository.py index 1e0acd4..20cb8e6 100644 --- a/webui/backend/app/db/history_repository.py +++ b/webui/backend/app/db/history_repository.py @@ -119,6 +119,27 @@ class HistoryRepository: ), ) + def reconcile_entries_failed( + self, + entry_ids: list[str], + *, + error_code: str = "task_interrupted", + error_message: str = "Task was interrupted before completion", + ) -> None: + if not entry_ids: + return + finished_at = self._now_iso() + placeholders = ", ".join("?" for _ in entry_ids) + with self._connection() as conn: + conn.execute( + f""" + UPDATE history + SET status = ?, error_code = ?, error_message = ?, finished_at = ? + WHERE id IN ({placeholders}) + """, + ("failed", error_code, error_message, finished_at, *entry_ids), + ) + def _ensure_schema(self) -> None: db_path = Path(self._db_path) if db_path.parent and str(db_path.parent) not in {"", "."}: diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 27a8b6e..ae98cd9 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -8,6 +8,7 @@ from pathlib import Path VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} VALID_OPERATIONS = {"copy", "move", "download", "duplicate"} +NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing") TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", "status": "TEXT NOT NULL DEFAULT 'queued'", @@ -394,6 +395,44 @@ class TaskRepository: with self._connection() as conn: conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,)) + def reconcile_incomplete_tasks( + self, + *, + error_code: str = "task_interrupted", + error_message: str = "Task was interrupted before completion", + ) -> list[str]: + finished_at = self._now_iso() + placeholders = ", ".join("?" for _ in NON_TERMINAL_STATUSES) + with self._connection() as conn: + rows = conn.execute( + f""" + SELECT id + FROM tasks + WHERE status IN ({placeholders}) + """, + NON_TERMINAL_STATUSES, + ).fetchall() + task_ids = [row["id"] for row in rows] + if not task_ids: + return [] + task_placeholders = ", ".join("?" for _ in task_ids) + conn.execute( + f""" + UPDATE tasks + SET status = ?, finished_at = ?, error_code = ?, error_message = ?, current_item = NULL + WHERE id IN ({task_placeholders}) + """, + ("failed", finished_at, error_code, error_message, *task_ids), + ) + conn.execute( + f""" + DELETE FROM task_artifacts + WHERE task_id IN ({task_placeholders}) + """, + task_ids, + ) + return task_ids + def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None: rows = conn.execute("PRAGMA table_info(tasks)").fetchall() existing_columns = {row["name"] for row in rows} diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py index 182bb10..b28e714 100644 --- a/webui/backend/app/main.py +++ b/webui/backend/app/main.py @@ -17,7 +17,9 @@ from backend.app.api.routes_move import router as move_router from backend.app.api.routes_search import router as search_router from backend.app.api.routes_settings import router as settings_router from backend.app.api.routes_tasks import router as tasks_router +from backend.app.dependencies import get_history_repository, get_task_repository from backend.app.logging import configure_logging +from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks configure_logging() @@ -40,6 +42,14 @@ app.include_router(history_router, prefix="/api") app.include_router(tasks_router, prefix="/api") +@app.on_event("startup") +async def reconcile_incomplete_tasks_on_startup() -> None: + reconcile_persisted_incomplete_tasks( + task_repository=get_task_repository(), + history_repository=get_history_repository(), + ) + + @app.exception_handler(AppError) async def handle_app_error(_: Request, exc: AppError) -> JSONResponse: return JSONResponse( diff --git a/webui/backend/app/services/__pycache__/history_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/history_service.cpython-313.pyc index f47342b..e3f1304 100644 Binary files a/webui/backend/app/services/__pycache__/history_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/history_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/__pycache__/task_recovery_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/task_recovery_service.cpython-313.pyc new file mode 100644 index 0000000..f8be4cb Binary files /dev/null and b/webui/backend/app/services/__pycache__/task_recovery_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/__pycache__/task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/task_service.cpython-313.pyc index b812dbd..0800c2e 100644 Binary files a/webui/backend/app/services/__pycache__/task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/task_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/task_recovery_service.py b/webui/backend/app/services/task_recovery_service.py new file mode 100644 index 0000000..15275aa --- /dev/null +++ b/webui/backend/app/services/task_recovery_service.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository + + +def reconcile_persisted_incomplete_tasks( + task_repository: TaskRepository, + history_repository: HistoryRepository, +) -> list[str]: + task_ids = task_repository.reconcile_incomplete_tasks() + if task_ids: + history_repository.reconcile_entries_failed(task_ids) + return task_ids diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index bebca03..7ae249a 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc index 86a8dd6..e7a76d1 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc index f599807..b067121 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 274fe8e..d6bcffc 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 5f15c39..07f3d03 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -231,6 +231,300 @@ class UiSmokeGoldenTest(unittest.TestCase): ) self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def _run_header_task_chip_behavior_check(self, app_js: str) -> None: + functions = "\n\n".join( + [ + self._extract_js_function(app_js, "headerTaskElements"), + self._extract_js_function(app_js, "formatTaskStatusLabel"), + self._extract_js_function(app_js, "inferDownloadTaskContext"), + self._extract_js_function(app_js, "formatTaskLine"), + self._extract_js_function(app_js, "isActiveTask"), + self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "headerTaskRenderKey"), + self._extract_js_function(app_js, "shouldPollHeaderTasks"), + self._extract_js_function(app_js, "stopHeaderTaskPolling"), + self._extract_js_function(app_js, "scheduleHeaderTaskPolling"), + self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"), + self._extract_js_function(app_js, "renderHeaderTaskPopover"), + self._extract_js_function(app_js, "renderHeaderTaskChip"), + self._extract_js_function(app_js, "updateHeaderTaskState"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + function createClassList(initialHidden = false) {{ + const names = new Set(initialHidden ? ["hidden"] : []); + return {{ + add(name) {{ names.add(name); }}, + remove(name) {{ names.delete(name); }}, + toggle(name, force) {{ + if (force === undefined) {{ + if (names.has(name)) {{ + names.delete(name); + }} else {{ + names.add(name); + }} + return; + }} + if (force) {{ + names.add(name); + }} else {{ + names.delete(name); + }} + }}, + contains(name) {{ return names.has(name); }}, + }}; + }} + + function createElement(initialHidden = false) {{ + return {{ + classList: createClassList(initialHidden), + textContent: "", + innerHTML: "", + children: [], + scrollTop: 0, + attributes: {{}}, + append(...nodes) {{ + this.children.push(...nodes); + }}, + setAttribute(name, value) {{ + this.attributes[name] = value; + }}, + }}; + }} + + const elements = {{ + "header-task-chip-container": createElement(true), + "header-task-chip-btn": createElement(false), + "header-task-chip-label": createElement(false), + "header-task-popover": createElement(true), + "header-task-popover-list": createElement(false), + "header-task-logs-btn": createElement(false), + }}; + + const document = {{ + getElementById(id) {{ + return elements[id] || null; + }}, + createElement() {{ + return createElement(false); + }}, + }}; + + const window = {{ + setTimeout(fn, delay) {{ + return 1; + }}, + clearTimeout(id) {{}}, + }}; + + function formatModified(value) {{ + return value || "now"; + }} + + let headerTaskState = {{ + activeItems: [], + popoverOpen: false, + pollTimer: null, + lastRenderKey: "", + }}; + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); + + {functions} + + const mixedTasks = [ + {{ id: "a", operation: "copy", status: "queued", source: "/src/a", destination: "/dst/a" }}, + {{ id: "b", operation: "move", status: "running", source: "/src/b", destination: "/dst/b", done_items: 1, total_items: 3, current_item: "b.mkv" }}, + {{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }}, + {{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, + {{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, + {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, + {{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }}, + {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, + {{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }}, + {{ id: "h", operation: "download", status: "cancelled", source: "/src/h", destination: "folder.zip" }}, + ]; + + const activeTasks = activeTasksFromItems(mixedTasks); + assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running 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 not be counted because it is not task-based in the current UI flow"); + assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task 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 === "3 active tasks", "Chip label should render active task 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 === 3, "Popover should render only active file-action tasks"); + + updateHeaderTaskState([ + {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, + {{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }}, + {{ id: "z3", operation: "download", status: "ready", source: "/src/z3", destination: "folder.zip" }}, + ]); + assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when no active tasks remain"); + assert(!headerTaskState.popoverOpen, "Popover should close when no active tasks remain"); + assert(elements["header-task-popover"].classList.contains("hidden"), "Popover should be hidden when no active tasks remain"); + assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "false", "Chip button should reset expanded state when hidden"); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + + def _run_task_snapshot_sync_behavior_check(self, app_js: str) -> None: + functions = "\n\n".join( + [ + self._extract_js_function(app_js, "headerTaskElements"), + self._extract_js_function(app_js, "formatTaskStatusLabel"), + self._extract_js_function(app_js, "inferDownloadTaskContext"), + self._extract_js_function(app_js, "formatTaskLine"), + self._extract_js_function(app_js, "isActiveTask"), + self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "headerTaskRenderKey"), + self._extract_js_function(app_js, "shouldPollHeaderTasks"), + self._extract_js_function(app_js, "stopHeaderTaskPolling"), + self._extract_js_function(app_js, "scheduleHeaderTaskPolling"), + self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"), + self._extract_js_function(app_js, "renderHeaderTaskPopover"), + self._extract_js_function(app_js, "renderHeaderTaskChip"), + self._extract_js_function(app_js, "updateHeaderTaskState"), + self._extract_js_function(app_js, "applyTaskSnapshot"), + ] + ) + script = textwrap.dedent( + f""" + const assert = (condition, message) => {{ + if (!condition) {{ + throw new Error(message); + }} + }}; + + function createClassList(initialHidden = false) {{ + const names = new Set(initialHidden ? ["hidden"] : []); + return {{ + add(name) {{ names.add(name); }}, + remove(name) {{ names.delete(name); }}, + toggle(name, force) {{ + if (force === undefined) {{ + if (names.has(name)) {{ + names.delete(name); + }} else {{ + names.add(name); + }} + return; + }} + if (force) {{ + names.add(name); + }} else {{ + names.delete(name); + }} + }}, + contains(name) {{ return names.has(name); }}, + }}; + }} + + function createElement(initialHidden = false) {{ + return {{ + classList: createClassList(initialHidden), + textContent: "", + innerHTML: "", + children: [], + scrollTop: 0, + attributes: {{}}, + append(...nodes) {{ + this.children.push(...nodes); + }}, + setAttribute(name, value) {{ + this.attributes[name] = value; + }}, + }}; + }} + + const elements = {{ + "header-task-chip-container": createElement(true), + "header-task-chip-btn": createElement(false), + "header-task-chip-label": createElement(false), + "header-task-popover": createElement(true), + "header-task-popover-list": createElement(false), + "header-task-logs-btn": createElement(false), + }}; + + const document = {{ + getElementById(id) {{ + return elements[id] || null; + }}, + createElement() {{ + return createElement(false); + }}, + }}; + + const window = {{ + setTimeout(fn, delay) {{ + return 1; + }}, + clearTimeout(id) {{}}, + }}; + + function formatModified(value) {{ + return value || "now"; + }} + + let state = {{ lastTaskCount: 0 }}; + let headerTaskState = {{ + activeItems: [], + popoverOpen: false, + pollTimer: null, + lastRenderKey: "", + }}; + const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); + + {functions} + + applyTaskSnapshot([ + {{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }}, + ]); + assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible"); + assert(elements["header-task-chip-label"].textContent === "1 active task", "Chip should show one active task"); + assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state"); + + applyTaskSnapshot([ + {{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }}, + ]); + assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks"); + assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed"); + assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length"); + """ + ) + result = subprocess.run( + ["node", "-e", script], + cwd="/workspace/webmanager-mvp", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) + def test_ui_mount_and_index_contains_expected_panels(self) -> None: mount = self._ui_mount() self.assertIsInstance(mount.app, StaticFiles) @@ -256,6 +550,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('/ui/assets/img/logo.svg', body) self.assertIn('id="title-zone-actions"', body) self.assertIn('id="status"', body) + self.assertIn('id="header-task-chip-container"', body) + self.assertIn('id="header-task-chip-btn"', body) + self.assertIn('id="header-task-popover"', body) + self.assertIn('id="header-task-popover-list"', body) + self.assertIn('id="header-task-logs-btn"', body) self.assertIn('id="theme-toggle"', body) self.assertIn('id="theme-toggle-icon"', body) self.assertIn('id="left-pane"', body) @@ -438,6 +737,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("#title-brand {", base_css) self.assertIn("#title-logo {", base_css) self.assertIn("height: 32px;", base_css) + self.assertIn(".header-task-chip-container {", base_css) + self.assertIn(".header-task-chip {", base_css) + self.assertIn(".header-task-popover {", base_css) + self.assertIn(".header-task-popover-list {", base_css) self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css) self.assertIn(".settings-activity-grid {", base_css) self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css) @@ -476,6 +779,32 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function formatTaskStatusLabel(task)', app_js) 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"]);', app_js) + self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js) + self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js) + self.assertIn('function headerTaskElements()', app_js) + self.assertIn('function isActiveTask(task)', app_js) + self.assertIn('function activeTasksFromItems(items)', app_js) + self.assertIn('function activeTaskChipLabel(count)', app_js) + self.assertIn('function shouldPollHeaderTasks()', app_js) + self.assertIn('function scheduleHeaderTaskPolling()', app_js) + self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js) + self.assertIn('function renderHeaderTaskPopover(items)', app_js) + 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('ACTIVE_TASK_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) + self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js) + self.assertIn('headerTasks.logsButton.onclick = () => {', app_js) + self.assertIn('setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen);', app_js) + self.assertIn('setHeaderTaskPopoverOpen(false);', app_js) + self.assertIn('updateHeaderTaskState(items);', app_js) + self.assertIn('renderTaskItems(applyTaskSnapshot(data.items));', app_js) self.assertIn('function renderTaskItems(items)', app_js) self.assertIn('async function loadTasksForSettings()', app_js) self.assertIn('async function loadLogsAndTasksForSettings()', app_js) @@ -529,6 +858,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js) self.assertIn('"/api/files/duplicate"', app_js) + self.assertIn('"/api/files/delete"', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) @@ -553,6 +883,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startDuplicateSelected()', app_js) + self.assertIn('async function deleteSelected()', app_js) self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) @@ -637,6 +968,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('Delete selected items and folder contents?', app_js) self.assertIn('async function loadSettings()', app_js) self.assertIn('await loadSettings();', app_js) + self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js) self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js) @@ -676,6 +1008,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function ensureFolderUploadPicker()', app_js) self.assertIn('function openFolderPicker()', app_js) self.assertIn('function uploadModalElements()', app_js) + self._run_header_task_chip_behavior_check(app_js) + self._run_task_snapshot_sync_behavior_check(app_js) self.assertIn('function setUploadModalVisible(', app_js) self.assertIn('function updateUploadModalDisplay(', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc new file mode 100644 index 0000000..fa89add Binary files /dev/null and b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc index 433237f..0bb0d52 100644 Binary files a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc and b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/test_task_recovery_service.py b/webui/backend/tests/unit/test_task_recovery_service.py new file mode 100644 index 0000000..d2e497a --- /dev/null +++ b/webui/backend/tests/unit/test_task_recovery_service.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository +from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks + + +class TaskRecoveryServiceTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.db_path = str(Path(self.temp_dir.name) / "tasks.db") + self.task_repo = TaskRepository(self.db_path) + self.history_repo = HistoryRepository(self.db_path) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_reconcile_persisted_incomplete_tasks_marks_old_non_terminal_tasks_failed(self) -> None: + self.task_repo.insert_task_for_testing( + { + "id": "task-running", + "operation": "copy", + "status": "running", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "created_at": "2026-03-10T10:00:00Z", + "started_at": "2026-03-10T10:00:01Z", + "current_item": "storage1/a.txt", + } + ) + self.history_repo.create_entry( + entry_id="task-running", + operation="copy", + status="queued", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + ) + self.task_repo.insert_task_for_testing( + { + "id": "task-ready", + "operation": "download", + "status": "ready", + "source": "single_directory_zip", + "destination": "docs.zip", + "created_at": "2026-03-10T10:02:00Z", + "finished_at": "2026-03-10T10:03:00Z", + } + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, ["task-running"]) + task = self.task_repo.get_task("task-running") + self.assertEqual(task["status"], "failed") + self.assertEqual(task["error_code"], "task_interrupted") + self.assertEqual(task["error_message"], "Task was interrupted before completion") + self.assertIsNone(task["current_item"]) + history = self.history_repo.list_history(limit=5)[0] + self.assertEqual(history["id"], "task-running") + self.assertEqual(history["status"], "failed") + self.assertEqual(history["error_code"], "task_interrupted") + ready_task = self.task_repo.get_task("task-ready") + self.assertEqual(ready_task["status"], "ready") + + def test_reconcile_persisted_incomplete_tasks_is_noop_when_all_tasks_terminal(self) -> None: + self.task_repo.insert_task_for_testing( + { + "id": "task-completed", + "operation": "move", + "status": "completed", + "source": "storage1/a.txt", + "destination": "storage2/a.txt", + "created_at": "2026-03-10T10:00:00Z", + "finished_at": "2026-03-10T10:00:02Z", + } + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, []) + self.assertEqual(self.task_repo.get_task("task-completed")["status"], "completed") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py index ddd6a15..dae4de8 100644 --- a/webui/backend/tests/unit/test_task_repository.py +++ b/webui/backend/tests/unit/test_task_repository.py @@ -107,6 +107,64 @@ class TaskRepositoryTest(unittest.TestCase): self.assertEqual(task["status"], "cancelled") self.assertIsNotNone(task["finished_at"]) + def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None: + self.repo.insert_task_for_testing( + { + "id": "task-running", + "operation": "copy", + "status": "running", + "source": "storage1/a", + "destination": "storage2/a", + "created_at": "2026-03-10T09:00:00Z", + "started_at": "2026-03-10T09:00:01Z", + "current_item": "storage1/a", + } + ) + self.repo.insert_task_for_testing( + { + "id": "task-completed", + "operation": "copy", + "status": "completed", + "source": "storage1/b", + "destination": "storage2/b", + "created_at": "2026-03-10T09:05:00Z", + "finished_at": "2026-03-10T09:06:00Z", + } + ) + + changed = self.repo.reconcile_incomplete_tasks() + + running = self.repo.get_task("task-running") + completed = self.repo.get_task("task-completed") + self.assertEqual(changed, ["task-running"]) + self.assertEqual(running["status"], "failed") + self.assertEqual(running["error_code"], "task_interrupted") + self.assertEqual(running["error_message"], "Task was interrupted before completion") + self.assertIsNone(running["current_item"]) + self.assertIsNotNone(running["finished_at"]) + self.assertEqual(completed["status"], "completed") + + def test_reconcile_incomplete_tasks_removes_stale_artifact(self) -> None: + created = self.repo.create_task( + operation="download", + source="storage1/docs", + destination="docs.zip", + status="preparing", + ) + self.repo.upsert_artifact( + task_id=created["id"], + file_path="/tmp/docs.zip.partial", + file_name="docs.zip", + expires_at="2026-03-10T10:30:00Z", + ) + + changed = self.repo.reconcile_incomplete_tasks() + + task = self.repo.get_task(created["id"]) + self.assertEqual(changed, [created["id"]]) + self.assertEqual(task["status"], "failed") + self.assertIsNone(self.repo.get_artifact(created["id"])) + def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None: legacy_db_path = Path(self.temp_dir.name) / "legacy.db" conn = sqlite3.connect(legacy_db_path) diff --git a/webui/html/app.js b/webui/html/app.js index 0055348..30abb2a 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -114,6 +114,16 @@ let settingsState = { selectedColorMode: "dark", zipDownloadLimits: null, }; +let headerTaskState = { + activeItems: [], + popoverOpen: false, + pollTimer: null, + lastRenderKey: "", +}; +// The header chip reflects only user-visible file actions that currently use the shared task system. +// Delete stays out of this set because it still runs as a direct request flow, not as a backend task. +const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]); +const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); const VALID_THEME_FAMILIES = [ "default", "macos-soft", @@ -189,6 +199,17 @@ function setStatus(msg) { document.getElementById("status").textContent = msg; } +function headerTaskElements() { + return { + container: document.getElementById("header-task-chip-container"), + chipButton: document.getElementById("header-task-chip-btn"), + chipLabel: document.getElementById("header-task-chip-label"), + popover: document.getElementById("header-task-popover"), + popoverList: document.getElementById("header-task-popover-list"), + logsButton: document.getElementById("header-task-logs-btn"), + }; +} + function setError(id, msg) { if (id === "actions-error") { document.getElementById(id).textContent = ""; @@ -1266,7 +1287,7 @@ async function uploadFileRequest(targetPath, file, overwrite = false) { async function refreshTasksSnapshot() { try { const data = await apiRequest("GET", "/api/tasks"); - state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount; + applyTaskSnapshot(data.items); } catch (_) { // Task list panel is not visible in current UI; silently keep flow stable. } @@ -3865,6 +3886,138 @@ function formatTaskLine(task) { }; } +function isActiveTask(task) { + return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ACTIVE_TASK_STATUSES.has(task.status); +} + +function activeTasksFromItems(items) { + return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : []; +} + +function activeTaskChipLabel(count) { + return `${count} active task${count === 1 ? "" : "s"}`; +} + +function headerTaskRenderKey(items) { + return JSON.stringify( + Array.isArray(items) + ? items.map((task) => ({ + id: task.id || "", + operation: task.operation || "", + status: task.status || "", + source: task.source || "", + destination: task.destination || "", + done_items: task.done_items, + total_items: task.total_items, + current_item: task.current_item || "", + })) + : [] + ); +} + +function shouldPollHeaderTasks() { + return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0; +} + +function stopHeaderTaskPolling() { + if (headerTaskState.pollTimer) { + window.clearTimeout(headerTaskState.pollTimer); + headerTaskState.pollTimer = null; + } +} + +function scheduleHeaderTaskPolling() { + stopHeaderTaskPolling(); + if (!shouldPollHeaderTasks()) { + return; + } + headerTaskState.pollTimer = window.setTimeout(async () => { + await refreshTasksSnapshot(); + scheduleHeaderTaskPolling(); + }, 1500); +} + +function setHeaderTaskPopoverOpen(nextOpen) { + const elements = headerTaskElements(); + const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0; + headerTaskState.popoverOpen = open; + if (elements.chipButton) { + elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false"); + } + if (elements.popover) { + elements.popover.classList.toggle("hidden", !open); + } + scheduleHeaderTaskPolling(); +} + +function renderHeaderTaskPopover(items) { + const elements = headerTaskElements(); + if (!elements.popoverList) { + return; + } + const renderKey = headerTaskRenderKey(items); + if (headerTaskState.lastRenderKey === renderKey) { + return; + } + const scrollTop = elements.popoverList.scrollTop; + elements.popoverList.innerHTML = ""; + 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."; + elements.popoverList.append(empty); + headerTaskState.lastRenderKey = renderKey; + return; + } + for (const task of items) { + const line = formatTaskLine(task); + const row = document.createElement("div"); + row.className = "header-task-item"; + const title = document.createElement("div"); + title.className = "header-task-item-title"; + title.textContent = line.title; + const path = document.createElement("div"); + path.className = "header-task-item-path"; + path.textContent = line.path; + const meta = document.createElement("div"); + meta.className = "header-task-item-meta"; + meta.textContent = line.meta; + row.append(title, path, meta); + elements.popoverList.append(row); + } + headerTaskState.lastRenderKey = renderKey; + elements.popoverList.scrollTop = scrollTop; +} + +function renderHeaderTaskChip(items) { + const elements = headerTaskElements(); + if (!elements.container || !elements.chipLabel) { + return; + } + const hasActiveTasks = Array.isArray(items) && items.length > 0; + elements.container.classList.toggle("hidden", !hasActiveTasks); + elements.chipLabel.textContent = activeTaskChipLabel(items.length); + if (!hasActiveTasks) { + headerTaskState.lastRenderKey = ""; + setHeaderTaskPopoverOpen(false); + return; + } + renderHeaderTaskPopover(items); +} + +function updateHeaderTaskState(taskItems) { + headerTaskState.activeItems = activeTasksFromItems(taskItems); + renderHeaderTaskChip(headerTaskState.activeItems); + scheduleHeaderTaskPolling(); +} + +function applyTaskSnapshot(taskItems) { + const items = Array.isArray(taskItems) ? taskItems : []; + state.lastTaskCount = items.length; + updateHeaderTaskState(items); + return items; +} + function renderHistoryItems(items) { const elements = settingsElements(); const renderKey = JSON.stringify(Array.isArray(items) ? items : []); @@ -3957,7 +4110,7 @@ async function loadHistoryForSettings() { async function loadTasksForSettings() { const data = await apiRequest("GET", "/api/tasks"); - renderTaskItems(data.items || []); + renderTaskItems(applyTaskSnapshot(data.items)); settingsState.tasksLoaded = true; } @@ -4517,6 +4670,11 @@ function handleKeyboardShortcuts(event) { if (!shouldHandleShortcut(event.target)) { return; } + if (event.key === "Escape" && headerTaskState.popoverOpen) { + event.preventDefault(); + setHeaderTaskPopoverOpen(false); + return; + } const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); if (isInfoShortcut) { @@ -4627,6 +4785,19 @@ function setupEvents() { setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("theme-toggle").onclick = toggleTheme; + const headerTasks = headerTaskElements(); + if (headerTasks.chipButton) { + headerTasks.chipButton.onclick = (event) => { + event.stopPropagation(); + setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen); + }; + } + if (headerTasks.logsButton) { + headerTasks.logsButton.onclick = () => { + setHeaderTaskPopoverOpen(false); + openSettings("logs"); + }; + } document.getElementById("upload-btn").onclick = openUploadPicker; document.getElementById("upload-menu-toggle").onclick = (event) => { event.stopPropagation(); @@ -4715,6 +4886,10 @@ function setupEvents() { } else { closeUploadMenu(); } + const headerTaskContainer = headerTaskElements().container; + if (headerTaskContainer && !headerTaskContainer.contains(event.target)) { + setHeaderTaskPopoverOpen(false); + } const contextMenu = contextMenuElements().menu; if (contextMenu && !contextMenu.contains(event.target)) { closeContextMenu(); @@ -4914,6 +5089,7 @@ async function init() { paneState("right").currentPath = "/Volumes"; await loadBrowsePane("right"); } + await refreshTasksSnapshot(); } init(); diff --git a/webui/html/base.css b/webui/html/base.css index 006d144..c6613fe 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -71,6 +71,92 @@ body { min-width: 0; } +.header-task-chip-container { + position: relative; + flex: 0 0 auto; +} + +.header-task-chip { + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-primary); + border-radius: 999px; + padding: 5px 10px; + font: inherit; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 1px 2px rgba(8, 14, 22, 0.08); +} + +.header-task-chip:hover, +.header-task-chip[aria-expanded="true"] { + border-color: var(--color-accent); +} + +.header-task-popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(360px, calc(100vw - 24px)); + padding: 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-elevated); + box-shadow: var(--shadow-elevated); + z-index: 30; +} + +.header-task-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.header-task-link { + border: 0; + background: none; + color: var(--color-accent); + padding: 0; + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.header-task-popover-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 260px; + overflow-y: auto; +} + +.header-task-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + padding: 8px 9px; +} + +.header-task-item-title { + font-size: 12px; + font-weight: 700; +} + +.header-task-item-path, +.header-task-item-meta, +.header-task-item-empty { + margin-top: 4px; + font-size: 12px; + color: var(--color-text-muted); + word-break: break-word; +} + h1, h2, h3 { margin: 0; } diff --git a/webui/html/index.html b/webui/html/index.html index 1678328..7e9e2b3 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -25,6 +25,25 @@
+