Skip to content

Refactor the activity log: two views, a scope-preview popup, and one shared state machine #38

Description

@iTerminate

Motivation

The activity log grew feature-by-feature across #2, #13, #17#19, #21#23, #25, #27, #29 and it
shows: today there are effectively three surfaces —

  1. a docked side panel (ActivityLogPanel, src/hatty/ui/activity_log_panel.py), embedded
    independently in both the main entity-table screen (HACLI in main.py) and the fullscreen
    graph screen (GraphPreviewScreen in ui/graph/preview_screen.py),
  2. the same panel maximized to full width (f), and
  3. a modal LogEntryPopup (ui/log_entry_popup.py, opened with V) whose only real job is
    showing the untruncated text of one entry, since the docked panel always truncates lines to
    its width.

On top of that, v doesn't open anything — it's an instant, opaque scope cycle
(base → base_devices → cursor → cursor_device on the main screen, entity → device on the
graph screen) with no preview of what a step actually resolves to before it's applied, and each
host reimplements the entire scope/paging/subscription state machine separately (explicitly
flagged as duplicated in preview_screen.py:223-226). #28 (open) would add a third host on the
device tree screen, reusing the same duplicated pattern again if we don't fix this first.

This issue is a from-scratch redesign of the feature knowing what we've learned building it.

Goals

  1. Two views only: the docked side panel, and that same panel maximized to a genuinely
    interactive full-screen view. No separate popup screen for browsing entries.
  2. Full-screen view supports entry selection. Maximizing (f) switches the panel from a
    passive, non-focusable ticker into an interactive list: / moves a selection highlight,
    and the currently-selected entry's full untruncated detail (timestamp, name, detail,
    entity_id — today's format_log_detail) renders inline, replacing the dialog's only unique
    capability. Un-maximizing returns to the passive ticker.
  3. v becomes a preview popup, not a blind cycle. Pressing v opens a modal listing the
    available scope options for the current context (e.g. "This list — 42 entities",
    "This list's devices — 17 devices", "Selected entity", "Selected entity's device"). Highlighting
    an option resolves and lists the actual entity/device names it would log, in a scrollable box
    (some of these lists are long — device-widened scopes can hit the existing 200-entity/50-device
    caps). Enter commits the highlighted scope (closes the popup, applies it — refetch +
    resubscribe); Escape cancels, leaving the current scope untouched.
  4. Unify the state machine. Extract scope resolution, paging, fetch orchestration, and live
    subscription lifecycle out of HACLI and GraphPreviewScreen into one shared
    LogbookController (src/hatty/controllers/logbook.py), following the existing
    list_ctl/dash_ctl/graph_ctl pattern. Each host keeps its own ActivityLogPanel instance
    and keybindings, but both drive the same controller instead of two parallel implementations.
    This is what makes Area/device-scoped activity log on the device tree #28 (device-tree-scoped log) a matter of wiring a third host rather than a
    third state machine.
  5. Design from scratch, not patch in place. Rewrite the test suite around the new shape
    instead of retrofitting today's ~150 tests spread across 15 files.

Non-goals

  • No change to the wire protocol / data layer: keep the WS-first (logbook/get_events),
    REST-fallback (/api/logbook/{start}) fetch split in client.py (_logbook_ws_supported
    latch and all), the logbook/event_stream live subscription, the state_changed fallback for
    when no stream is subscribed, and the continuous-sensor gap-filling via fetch_state_log
    (Activity log shows no history for numeric sensors #29). These are correct, hard-won, and independently tested (test_client_logbook.py,
    test_connection_controller.py, test_logbook_normalize.py all stay).
  • No change to the pure normalization layer (src/hatty/logbook.py) beyond whatever's needed to
    keep serving both the ticker line format and the inline detail format.
  • Not building Area/device-scoped activity log on the device tree #28 (device-tree log) in this issue — this refactor should just make that issue
    easy to pick up afterward.
  • Not adding request-level caching/debouncing of fetches — out of scope unless it falls out
    naturally from the controller extraction.

Design

1. Panel: passive ticker ⇄ interactive full-screen

ActivityLogPanel currently uses a non-focusable Log widget by design (can_focus = False,
so paging keys reach the host screen instead of scrolling the log — activity_log_panel.py).
Keep that for the docked/side state. On maximize, swap in (or reveal) a focusable, selectable
list — OptionList is the natural fit, matching what LogEntryPopup already uses internally —
plus a detail region that renders format_log_detail for the highlighted row. Un-maximizing
drops back to the passive Log ticker. left/right (older/newer paging) keep working
identically in both states; / only do anything (select within the loaded window) while
maximized.

format_log_detail/format_log_datetime (hatty/logbook.py) are kept — they move from serving
the popup to serving this inline detail region. Their unit tests
(tests/unit/test_log_detail_format.py) stay, just re-scoped in intent.

LogEntryPopup (ui/log_entry_popup.py) is deleted outright, along with its 5 acceptance tests
(tests/test_log_entry_popup.py).

2. v: LogScopePopup

New popup (subclassing PopupScreen/ListPopup per ui/popup_base.py convention) that:

  • Lists scope options as data, not a hardcoded cycle: something like
    LogScopeOption(id, label, resolve() -> (entity_ids, device_ids)). The controller supplies the
    option set for the current host/context (main screen: up to 4 options depending on how the log
    was opened — whole list vs. cursor vs. graph panel; graph screen: 2 options), replacing today's
    _LOG_VIEWS_TABLE / _LOG_VIEWS_ENTITIES / _LOG_VIEWS tuples.
  • On highlight change, resolves the option (reusing _get_device_entity_ids /
    _device_ids_for_entities and the existing 200-entity/50-device caps) and renders the
    resulting entity/device names into a scrollable box (VerticalScroll or equivalent) — this is
    the "what would be logged" preview. Needs to stay responsive even for large lists; resolution
    should reuse the same capped/bounded logic already in place rather than resolving everything
    eagerly.
  • Enter applies the highlighted option (closes popup, controller applies new scope → refetch +
    resubscribe, exactly like today's cycle's end state). Escape cancels with no change.

3. LogbookController

New src/hatty/controllers/logbook.py, constructed like the other controllers (injected app
reference). Owns, per host session:

  • Current scope (resolved entity_ids/device_ids) and the option set available for that context.
  • Paging window (_log_end equivalent) and fetch orchestration (fetch_log_entries's
    logbook + continuous-sensor merge logic, moved from HACLI.fetch_log_entries).
  • Subscription lifecycle (_resync_log_subscription equivalent) — unsubscribe-then-maybe-resubscribe
    to logbook/event_stream on open/scope-change/page/timeframe-change.
  • A method each host calls on the relevant live WS frame (routed via ConnectionController,
    which today reaches directly into HACLI attributes — connection.py:229-252 — and should
    route through the controller instead).

Each host (HACLI, GraphPreviewScreen) keeps owning its ActivityLogPanel widget instance and
its own BINDINGS/check_action wiring (since e.g. the graph screen's paging is tied to the
graph's own zoom/pan, and ALLOWED_APP_ACTIONS carve-outs are screen-specific), but calls into
the shared controller for everything state-related instead of maintaining a second copy.

Keybinding changes

Key Today After
a toggle log, default scope unchanged
i toggle log, cursor-entity scope (main screen only) unchanged
v instant scope cycle opens LogScopePopup (preview, then confirm/cancel)
f maximize to full width (passive) maximize to full-screen (now interactive: / select, inline detail)
V opens LogEntryPopup removed — detail viewing lives inside full-screen state
/ page older/newer unchanged
/ (unused by the log) select entry, only while maximized
T timeframe popup unchanged

Test plan

  • Delete: tests/test_log_entry_popup.py (dialog gone).
  • Rewrite: tests/test_log_scope_cycle.py → scope-popup tests (options offered per context,
    preview list resolves per highlighted option and respects the existing caps, confirm applies +
    refetches + resubscribes, cancel leaves scope untouched).
  • Rewrite: tests/test_log_maximize.py, tests/test_graph_log_maximize.py → assert maximize
    now also flips the panel into selectable/interactive mode with a working detail region, on top
    of the existing width-reflow assertions.
  • Add: tests/unit/test_logbook_controller.py — the extracted state machine in isolation
    (scope resolution, paging, subscribe/resubscribe decisions), fast and Pilot-free. This should
    absorb most of the logic-level coverage currently duplicated across
    tests/test_activity_log.py, tests/test_device_log.py, tests/test_device_log_list.py, and
    tests/test_graph_event_log.py, letting those four shrink to host-wiring/integration checks
    (keybindings reach the controller, panel reflects controller state) rather than re-proving
    scope-resolution logic at the Pilot level for both hosts.
  • Keep as-is (unaffected by this refactor): tests/unit/test_logbook_normalize.py,
    tests/unit/test_log_line_format.py, tests/unit/test_client_logbook.py,
    tests/unit/test_client_ws_request.py, tests/unit/test_demo_logbook.py,
    tests/test_demo_mode.py, tests/test_fake_client_parity.py.
  • Adjust: tests/unit/test_connection_controller.py for the new controller hookup (live-append
    routes through LogbookController instead of directly poking HACLI attributes).
  • Keep, re-scope: tests/unit/test_log_detail_format.py — same functions, now serving the
    inline full-screen detail region instead of the popup.

Open questions for the implementer

  1. Does Enter on a selected entry in full-screen mode do anything beyond showing detail (e.g.
    jump to that entity in the main table)? Not requested here — fine to leave as view-only.
  2. Should the LogScopePopup's per-option preview resolve eagerly for every option up front, or
    lazily on highlight? Lazy avoids paying for all options' device-widening resolution when the
    user only ever picks one, but adds a beat of latency on each highlight move — worth checking
    against the existing 200-entity/50-device caps to see if eager resolution is actually cheap
    enough not to matter.
  3. ConnectionController currently distinguishes logbook-stream frames from state_changed
    frames by shape, not message id, due to an ordering hazard where a fast HA server can push
    the first stream frame before subscribe_logbook()'s id is stored (connection.py:50-55) —
    confirm the controller extraction preserves this, it's easy to accidentally lose during a
    refactor.

Related

Builds on / supersedes the incremental design from #2, #13, #17, #18, #19, #21, #22, #23, #25,
#27, #29. Should land before #28 (device-tree-scoped log), which becomes "wire up a third host"
instead of "write a third state machine" once the LogbookController exists.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions