Commit Graph
6 Commits
Author SHA1 Message Date
Vito 9ab25f9aaa feat(system): Maintenance UI (System S7) (#322)
* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
2026-10-25 05:51:57 -07:00
Vito 6505d339aa feat(system): thumbnails on Activity (#311)
* feat(system): run thumbnail generation as an Activity job

GET /prompt_manager/images/thumbnails/plan counts what a run would do
(targets, fresh, remaining past the cap, source labels) and writes
nothing. POST /prompt_manager/jobs/thumbnails {mode, size, quality}
starts the "thumbnails" job: admission guarded, cancellable at a file
boundary, outcome groups generated / skipped / errors. size is the
longest edge (128-2048), quality the encoder, and rebuild writes fresh
previews again through the same atomic rename, so a failed file keeps
its old preview and originals are only read.

The legacy gallery stream now starts or attaches to the same job and
streams its unchanged frames. A client that goes away no longer stops
the work; the gallery's Cancel asks the server to cancel the job.

* feat(activity): per-kind job descriptors

One descriptor table (activity-kinds.mjs) decides each job kind's pill
verb, toasts, line, tiles, outcomes, openable rows and labels; the
store re-exports it. The scan and image tagging views are unchanged.
A thumbnail job counts Generated, Skipped and Errors, reads
"Generating thumbnails" in the pill and "Thumbnails finished · N
generated · M errors" when it ends.

Any other kind (the health check, a held legacy-page operation) gets
the generic descriptor with no tiles and no Results, instead of the
scan's zero tiles. A running job without a known total shows an
indeterminate progress bar and no percentage, and only a job the
server can cancel shows Cancel.

* feat(system): review and start thumbnail jobs

Sources' Thumbnail generation -> Review options and Overview's Review
thumbnails open the concept's Thumbnail maintenance dialog: scope, quality
(preselected from Image loading quality) and preview size limit, a plan
line from the read-only thumbnail plan (D99) and Start, which starts the
thumbnails Activity job and opens it. A size outside 128-2048 requests
nothing; a refused start shows the busy link toast.

* fix(system): thumbnail job review fixes (#249 S5)

- The legacy stream's first frame is job {id, started}, sent after the
  subscription so no runner frame is missed; it reads the registry after
  prepare, so a job started in that gap is attached to, not refused.
- Every exit sets a truthful final status line; nothing to do never
  reads "Processing file 0 of 0".
- Cancel is checked per file during the planning walk; a job cancelled
  there publishes no start frame.
- The ffmpeg fallback keeps the chosen quality (-q:v) and the aspect
  ratio; the placeholder uses the chosen JPEG settings.
- A locked preview on Windows reads "Preview in use by another program;
  the old one was kept", not the raw WinError.
- Thumbnail work no longer defers the monitor's Needs review recording:
  it writes previews only, never records.
- The disconnect test now proves the job outlives its stream (one worker,
  9 files, three batches); the attach test waits on an event.

* fix(gallery): cancel the stream's own thumbnail job by id

The settings panel and the startup dialog cancel the job named by the
stream's job frame, and only one their stream started. A Cancel before
the frame waits for it, a double Cancel posts once, and a job another
tab or System started keeps running. The startup dialog now settles on
Cancel instead of staying on "Cancelling...".

* fix(activity): indeterminate pill bar and the nothing-to-do line

The pill's mini bar sweeps while a job's total is unknown instead of
showing an empty fill. A finished thumbnail job with nothing to do shows
the server's final line. Coverage floors raised for gallery.js and
activity.mjs.
2026-10-23 16:58:06 -07:00
Vito c86739178d feat(system): System page shell, review dialog and Overview (#298)
* feat(system): read-only health check and truthful overview counts

POST /prompt_manager/system/health runs the health check off-loop as a
read registry job of kind "health" (one at a time, beside a scan, not
cancellable, no file outcomes) and keeps the last report; GET returns it
or null. The check reads one snapshot on its own mode=ro connection,
closed in finally: integrity_check(100), foreign_key_check, stored hashes
against utils.hashing, duplicate pair rows, the legacy consistency
queries (now shared with check_consistency), and image links whose file
is gone, counted only under readable sources. An unavailable source, or
the absent Needs-review queue, makes its check unavailable and the
report incomplete instead of producing a count.

GET /prompt_manager/system/overview returns Library-eligible positive
prompts and active negative variants, indexed images, the last health
summary or null, backup null, the Needs-review count null, the effective
sources with whether they were auto-detected, and the thumbnail plan's
missing count. GET /prompt_manager/system/statistics lists runs and
average rating per category of Library parents, with the /stats totals.

* feat(system): native System route, drafts and shared review dialog

- #/system/<section> renders the concept's System shell: section nav,
  settings layout and Save row with truthful status (D92); each section
  not delivered yet hands off to its legacy tools (D94). Overview's System
  card opens #/system/overview (Q12).
- A module-level draft store saves browser, then host (expected_revision),
  then runtime; failed domains stay dirty, a stale revision is a conflict.
- System row builders (setting, operation, section nav) on the scope.
- openReviewDialog and backupNotice extracted from the scan review, which
  is now a thin caller with identical markup.
- Overview images gain a "rated" count (images whose linked prompt is
  rated); the missing-files health check reports per source, so partial
  availability is visible.

* feat(system): native Overview with health report and statistics

System > Overview renders the concept's metrics and its Library health and
Workspace preferences surfaces from GET /system/overview: real counts,
"Not checked" until a health check ran, no invented review count, backup
or thumbnail status (D93). Explore opens "Usage statistics" with the real
runs and ratings per category and the Library totals (D96); the
statistics route now also returns rated_images.

Maintenance shows the concept's notice with Run health checks above its
hand-off: the read-only check runs as a registry read job and opens the
"Health report" with a chip per real result, missing files per source
(D95). A load failure shows Retry and no zeros; late answers never
repaint a newer section.

Adds the System concept comparison (shell, Overview, both dialogs, the
notice, mutation checks), the System workflow spec and its fixture
hooks, and the TESTING.md "Native System" section.

* test(system): coverage floors for the native Overview

Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes.

* fix(system): keep health reports per database and off the event loop

- A restore that replaces the database forgets the stored health report,
  and a check that started before the swap is not kept (database
  generation stamped at start).
- The Overview no longer walks every source for the missing-thumbnail
  count; the page reads /images/thumbnails/status on its own.
- Image sources for the health check and the Overview resolve in the
  executor; a failure to start the check answers the coded envelope.
- Any exception fails only its own check; missing files reads its rows in
  the snapshot and stats the files after it ended.
- The read-only URI keeps Windows UNC paths (empty authority).
- An integrity count at SQLite's limit is flagged as capped.

* fix(system): thumbnail row read, tracked health job and guarded host save

- The Overview's thumbnail row is its own read of the thumbnail status:
  "Checking thumbnails…" until it answers, unavailable when it fails.
- The health job is tracked in the Activity store (new store.track) and
  followed through its event stream instead of polling every 150 ms.
- A host save before a read returned a revision fails without a request;
  System reads the host values on mount and after a conflict.
- "100+ problems found" when the integrity count is capped.
- D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone.
- The concept spec builds the health report from the concept's own rows
  and asserts the full unmapped path list; surface tops pin the 18px
  offset.
- TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI
  and the new behavior. Coverage floors raised for the touched files.

* fix(system): make two health tests portable to Windows

- Windows forbids "?" in file names, so the URI-character open test
  uses "#", "%" and a space there.
- ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of
  raising, so the bad-path test raises as POSIX does to reach the
  missing-files failure path on every platform.
2026-10-23 04:59:18 -07:00
Vito 3b1d00501f fix(config): serialized config store and registry admission (#289)
* fix(config): report settings persistence failures (#220)

Settings saves now go through one serialized config store
(py/config_store.py). A commit re-reads config.json under a process
lock, applies the change to a copy and replaces the file atomically
(temp file beside it, fsync, close, os.replace), so a failed write
leaves the old file in place.

POST /prompt_manager/settings commits first and only then publishes
the values at runtime. A write failure is 500 config-write-failed, an
unparseable config.json is 409 config-unreadable (the file is kept
instead of replaced) and a stale expected_revision is 409
settings-changed. A save reports persisted, revision, runtime_applied
and restart_required_fields; GET adds the revision. Only submitted keys
are written; other sections and the CivitAI key are kept.

* fix(lora): report integration save failures (#220)

POST /prompt_manager/lora/enable commits integrations.lora_manager
through the config store and publishes the runtime values only after
config.json holds them. Before, it set the runtime values, rewrote the
whole runtime config (dropping sections it did not know) and reported
success even when the write had failed.

A write failure is 500 config-write-failed and changes nothing at
runtime; an unparseable config.json is 409 config-unreadable. The
body goes through the capped JSON reader and the flags must be
booleans. A trigger word cache that fails to load is reported as
persisted:true, cache_loaded:false. The CivitAI key is never echoed.
The LoRA tests now write to a temporary config path.

* fix(jobs): refuse legacy record-changing operations while a job runs

The job registry gains admission classes. A records job is refused
while any records or guarded job runs, a guarded job while a records
job runs, and read jobs share only the one-job-per-kind rule.
record_changing=True stays as the spelling of records. Job summaries
add cancellable and admission, and a non-cancellable job ignores
cancel.

JobRegistry.hold() registers a running, non-cancellable job for a
legacy-page operation and ends it when the request does. The legacy
restore, maintenance, prompt cleanup, duplicate image delete, LoRA
import and batch auto-tag routes hold as records; thumbnail generation
(both routes) and thumbnail clear hold as guarded. A maintenance run
of only read checks holds as read. So a restore during a scan, or a
scan during a legacy restore, is refused with the running job named
instead of interleaving; this closes the gap noted on #269.

Refusals keep each route's shape: the JSON routes return the legacy
409 "<title> already running", and the thumbnail and LoRA streams
send their existing error event.

* test(lora): keep the preserved-section fixture free of secret values

CodeQL flagged the fixture that writes the secret constant into a test
config file as clear-text storage. The test only needs a marker value
to prove the stored key survives a save.

* fix(config): read under the commit lock and publish saves in write order

- ConfigStore.read takes the commit lock, so no read handle is open while
  config.json is replaced (Windows refuses that replace).
- os.replace is retried briefly on a Windows PermissionError (antivirus or
  indexer handles) before the save is reported as not written.
- The temp file is written beside realpath(config.json), so a symlinked
  config keeps its link and a bind mount fails truthfully.
- commit() takes a publish callback that runs under the lock after the
  write; settings and LoRA saves publish there, so two concurrent saves
  reach the runtime in write order and the runtime ends on the last one.
- The concurrency tests now wait until the second caller is blocked on the
  lock (tests/lock_probe.py) before releasing the first.

* fix(jobs): require an admission class, admit holds on entry, true Activity states

- JobRegistry.start raises TypeError when neither admission nor
  record_changing is given, so a new job never runs as "read" beside a
  scan by omission.
- hold() admits when its block is entered, so a hold that is never entered
  leaves no running records job behind; callers that answer a refusal in
  their own way check blocker() first, with no await before the block.
- A held SSE operation that ends in an error event (thumbnail generation,
  legacy batch auto-tag) is listed as failed, and refused input (duplicate
  delete without paths, an unreadable thumbnail body) is answered before
  the hold, so it is not listed as a job.
- Activity keeps a job's cancellable flag (true when an older server omits
  it) and shows Cancel only for a running, cancellable job, in Recent work
  and in the job view. A held legacy job no longer offers a Cancel that
  does nothing.

* fix(admin): legacy pages show why a request was refused

A job running in the workspace refuses the legacy record-changing requests,
and a corrupt config.json refuses a settings save. The legacy pages now
name the reason instead of hiding it:

- LoRA import: the busy and failure frames carry status and progress, and
  the admin modal handles type "error": it shows the message, notifies and
  closes, instead of staying on "Initializing..." at 0%.
- Legacy batch auto-tag: a refusal by another job is a 200 stream with one
  error frame ({type, status, message}), which both pages already render;
  a second batch keeps its exact JSON 409.
- Gallery cache clear: a refused server clear shows the reason and stops
  before the success toast and the "Not generated" reset.
- Admin settings save and maintenance show the reply's error ("config.json
  cannot be read...", "Scan for metadata already running") instead of
  "Failed to save settings" or "HTTP 409: Conflict".
- Tests cover the read-only /maintenance branch: a read-only selection runs
  during a scan as a read hold; a mixed or default selection is refused.

* refactor(api): move the settings routes out of admin.py

GET and POST /prompt_manager/settings, their validation, the config commit
and runtime publication, and the shared commit-failure reply now live in
py/api/settings_routes.py as SettingsRoutesMixin, before S3 adds host
fields there. No behaviour change: the same routes, payloads and status
codes; admin.py drops about 300 lines.

* chore(coverage): raise the floors of the frontend files this round touched

admin.js, gallery.js, activity.mjs and activity-job-view.mjs, to their
measured coverage. No floor is lowered.

* fix(admin): the gallery starts no thumbnail status read while it leaves

The gallery's startup thumbnail check runs one second after load. When that
second ended while the page was leaving (CI, WebKit: the workflow test
navigates from the gallery to the admin page), WebKit failed the new read
with "Fetch API cannot load ... due to access control checks." and
Playwright reported a page error.

Like the admin page (#290), the gallery now has a page lifetime that ends
on beforeunload and pagehide (and starts again on a back/forward restore).
The check starts no read once it has ended and passes it as the read's
signal. A page-clock workflow test that leaves before the second ends
failed on both events, in Chromium and WebKit, before this change.
2026-10-22 21:39:01 -07:00
Vito a8aff3a39d feat(annotations): bulk Add tags and Auto-tag selected as Activity jobs (#283)
* feat(activity): image tagging jobs for bulk Add tags and Auto-tag

POST /prompt_manager/jobs/image-tags starts one record-changing
"image-tags" job over 1..500 listed images (202, or the same 409 busy
envelope as /jobs/scan). Add tags hashes each current file and unions a
fixed set into that byte record; Auto-tag captures each still image, asks
one legacy engine for its top N suggestions on the capture (count from
the options or autotag.defaults.suggestion_limit), re-hashes the file and
unions only onto the same bytes. Outcomes are grouped as tagged,
unchanged, skipped (video, too large, file changed, file not found, not a
still image) and errors; cancel stops at a file boundary.

Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.

* feat(annotations): Add tags and Auto-tag selected in the selection bar

The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.

* feat(activity): show image tagging jobs with their own outcomes

Image tagging jobs get their own Activity view (D89): the pill reads
"Tagging", the item and heading line name the images and end with
"N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped
and Errors tiles and outcomes, opens Tagged and Unchanged rows in the
viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table
lives in activity-store.mjs; the scan's view is unchanged.

The selection bar's copy without a concept line is recorded as D117.

* fix(annotations): final review round for bulk image tagging

- Rename the D117 copy deviation to D118 (D117 belongs to another PR).
- The tagging job's progress line gives the scan's "about X min left"
  estimate, with each finished file as one batch.
- The start body cap fits 500 targets of the longest legal path, even
  escaped; one byte over answers 413.
- A capture store at capacity is retried with a short backoff, then the
  file is skipped with a rerun sentence instead of a Tag-dialog error.
- Output roots and their labels are resolved once per job in the
  executor, not twice per file on the event loop.
- Review each image holds Stop reviewing, Escape and x while a save is in
  flight, and its primary reads "Save and next" when only the manual
  field would be saved.
- An Add tags field that parses to no names keeps the dialog and its
  text and reports the message on the field.
- Image tagging finish toasts carry the counts ("... finished · N tagged
  · M errors").
- Specs: the bulk spec waits for the answered Start before checking that
  the invalid press sent nothing; the Activity spec uses a real error row.
2026-10-22 18:55:24 -07:00
Vito a6f6697c50 feat(activity): shared job registry, Activity page and Scan for metadata (#269)
* feat(activity): shared session job registry with the scan as its first kind

The output scan now runs as the first kind of an in-memory JobRegistry
(py/api/jobs.py, replacing py/api/scan_job.py). Each job keeps exact
counters, at most 200 outcome rows per group and a log capped at 2,000
lines, with root-relative paths and public root labels only. New routes
list, inspect, follow (SSE) and cancel jobs and start the scan
(POST /prompt_manager/jobs/scan, 409 busy while another record-changing
job runs). The legacy /prompt_manager/scan routes attach to the same job
and stream unchanged payloads.

* feat(activity): concept Activity page, top-bar button and progress pill

The workspace gains the concept's Activity page (renderActivity), its
top-bar button and sidebar row, fed by one session job store: a /jobs
read, one SSE stream per running job, a 1-30 s capped backoff after a
dropped stream and a 15 s list poll while visible. A job the server no
longer lists shows as ended, so no pill stays stuck after a restart.

D52: the counts show running jobs and hide at 0; the phone top-bar row
may wrap. D53: the mockup's progress pill links the running job. D54:
real job items with state chips, detail lines and Cancel job. Start and
finish toasts follow the spec copy. Fake browser jobs come from
tests/browser/workspace_activity_fixture.py.

* feat(activity): Scan for metadata with its review dialog and job view

Images -> Import keeps Rescan and adds the concept's "Review scan" row as
"Scan for metadata". It opens the concept's "Review metadata scan" with the
real source count, the fixed "All files" scope, a backup box that downloads
the existing database backup, and Cancel / Start scan. Start scan starts the
session scan job and opens its view; a start refused by another
record-changing job toasts its title as a link.

#/activity/<id> is now the approved job-view mockup: the heading with
Download report (disabled until Activity-2) and Cancel scan, the Progress
surface with the five counter tiles, the Results surface with the outcome
list, path filter and capped rows (Added and Already known open the viewer
through the listing identity), and the collapsed Scan log. Activity item
titles link their views. The scan's progress line groups thousands.

Deviations D55-D57 are recorded in TESTING.md.

* fix(activity): stop the job store on pagehide and let proxied routes outlive teardown

CI's touch coverage run failed "a resolver failure for the ComfyUI link never
stops the shell from mounting" with "route.fulfill: Fetch response has been
disposed": its trace shows two Library thumbnail requests started by the last
route still in the spec's proxy-mount handler while the page fixture was torn
down.

- The proxy-mount handler ignores only that teardown error, and the workspace
  and Activity specs unroute with ignoreErrors before teardown.
- The Activity store now ends its work on pagehide: the poll and backoff timers
  stop, the job streams close and a list read in flight is aborted (an older
  read is also aborted when a newer one starts). A pageshow from the
  back-forward cache reads the list again.

* test(activity): compare the job view log's line height to the hundredth of a pixel

WebKit reports the mockup's 11px/1.7 line height as 18.700001px, so the job view comparison failed on WebKit only. The measurement now rounds it, as other engine-dependent values are normalized.

* fix(activity): final review round for the job store, job view and scan rows

- Store: one failed list read no longer marks running jobs as ended; two in
  a row, or a good list without them, do (M1). A good read resets the
  backoff, except for a stream that keeps dropping while the list reads fine
  (M2). Finished jobs the server pruned are dropped (M3). Numbers group as
  en-US, like the server's lines (M13).
- Job view: a job the store's list no longer has shows the not-found state
  (M3). Focus moves to the job heading when Cancel scan goes away, and to the
  item's title link in the list (M4). The detail is read only when its listed
  rows change or the open log grows, using the summary's new listed and
  log_length (M12).
- Viewer rows open by exact identity: each scan row records its root's
  listing index, the source must still carry the row's label, and the
  workspace listing gains an exact `path` filter (M8, M9).
- Scan rows name the prompt by its Library title, stored title first (M10).
- A toast with a link stays 20 s, and any toast stays while hovered or
  focused (M14).
- A real two-batch scan cancelled after batch 1 keeps partial counts (M7).
- TESTING.md: D54, D56 and D57 updated (M5, M6).

* test(activity): cover the held and linked toast and a job view pruned by the server

Unit tests for the toast's 6.5 s and 20 s timeouts and its hover and focus hold, and a browser test for a finished job's open view after the server stops listing it. Coverage floors are only raised.
2026-10-21 14:22:30 -07:00