You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 —
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),
the same panel maximized to full width (f), and
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
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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 —
ActivityLogPanel,src/hatty/ui/activity_log_panel.py), embeddedindependently in both the main entity-table screen (
HACLIinmain.py) and the fullscreengraph screen (
GraphPreviewScreeninui/graph/preview_screen.py),f), andLogEntryPopup(ui/log_entry_popup.py, opened withV) whose only real job isshowing the untruncated text of one entry, since the docked panel always truncates lines to
its width.
On top of that,
vdoesn't open anything — it's an instant, opaque scope cycle(
base → base_devices → cursor → cursor_deviceon the main screen,entity → deviceon thegraph 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 thedevice 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
interactive full-screen view. No separate popup screen for browsing entries.
f) switches the panel from apassive, 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 uniquecapability. Un-maximizing returns to the passive ticker.
vbecomes a preview popup, not a blind cycle. Pressingvopens a modal listing theavailable 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).
Entercommits the highlighted scope (closes the popup, applies it — refetch +resubscribe);
Escapecancels, leaving the current scope untouched.subscription lifecycle out of
HACLIandGraphPreviewScreeninto one sharedLogbookController(src/hatty/controllers/logbook.py), following the existinglist_ctl/dash_ctl/graph_ctlpattern. Each host keeps its ownActivityLogPanelinstanceand 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.
instead of retrofitting today's ~150 tests spread across 15 files.
Non-goals
logbook/get_events),REST-fallback (
/api/logbook/{start}) fetch split inclient.py(_logbook_ws_supportedlatch and all), the
logbook/event_streamlive subscription, thestate_changedfallback forwhen 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.pyall stay).src/hatty/logbook.py) beyond whatever's needed tokeep serving both the ticker line format and the inline detail format.
easy to pick up afterward.
naturally from the controller extraction.
Design
1. Panel: passive ticker ⇄ interactive full-screen
ActivityLogPanelcurrently uses a non-focusableLogwidget 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 —
OptionListis the natural fit, matching whatLogEntryPopupalready uses internally —plus a detail region that renders
format_log_detailfor the highlighted row. Un-maximizingdrops back to the passive
Logticker.left/right(older/newer paging) keep workingidentically in both states;
↑/↓only do anything (select within the loaded window) whilemaximized.
format_log_detail/format_log_datetime(hatty/logbook.py) are kept — they move from servingthe 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:LogScopePopupNew popup (subclassing
PopupScreen/ListPopupperui/popup_base.pyconvention) that:LogScopeOption(id, label, resolve() -> (entity_ids, device_ids)). The controller supplies theoption 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_VIEWStuples._get_device_entity_ids/_device_ids_for_entitiesand the existing 200-entity/50-device caps) and renders theresulting entity/device names into a scrollable box (
VerticalScrollor equivalent) — this isthe "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.
Enterapplies the highlighted option (closes popup, controller applies new scope → refetch +resubscribe, exactly like today's cycle's end state).
Escapecancels with no change.3.
LogbookControllerNew
src/hatty/controllers/logbook.py, constructed like the other controllers (injected appreference). Owns, per host session:
_log_endequivalent) and fetch orchestration (fetch_log_entries'slogbook + continuous-sensor merge logic, moved from
HACLI.fetch_log_entries)._resync_log_subscriptionequivalent) — unsubscribe-then-maybe-resubscribeto
logbook/event_streamon open/scope-change/page/timeframe-change.ConnectionController,which today reaches directly into
HACLIattributes —connection.py:229-252— and shouldroute through the controller instead).
Each host (
HACLI,GraphPreviewScreen) keeps owning itsActivityLogPanelwidget instance andits own
BINDINGS/check_actionwiring (since e.g. the graph screen's paging is tied to thegraph's own zoom/pan, and
ALLOWED_APP_ACTIONScarve-outs are screen-specific), but calls intothe shared controller for everything state-related instead of maintaining a second copy.
Keybinding changes
aivLogScopePopup(preview, then confirm/cancel)f↑/↓select, inline detail)VLogEntryPopup←/→↑/↓TTest plan
tests/test_log_entry_popup.py(dialog gone).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).
tests/test_log_maximize.py,tests/test_graph_log_maximize.py→ assert maximizenow also flips the panel into selectable/interactive mode with a working detail region, on top
of the existing width-reflow assertions.
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, andtests/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.
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.tests/unit/test_connection_controller.pyfor the new controller hookup (live-appendroutes through
LogbookControllerinstead of directly pokingHACLIattributes).tests/unit/test_log_detail_format.py— same functions, now serving theinline full-screen detail region instead of the popup.
Open questions for the implementer
Enteron 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.
LogScopePopup's per-option preview resolve eagerly for every option up front, orlazily 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.
ConnectionControllercurrently distinguishes logbook-stream frames fromstate_changedframes 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
LogbookControllerexists.