cctally 1.59.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.60.0] - 2026-07-03
9
+
10
+ ### Changed
11
+ - Dashboard **Recent Sessions** is denser and more useful. When every recent session uses the same model, the redundant per-row model column is dropped entirely — it previously rendered as a column of dots under a still-labelled "Model" header, which read as broken — and a single "all · model" caption in the header says it instead; a mixed set of models still shows the column. Two columns that carry real signal take its place: a **Session** title for each row (its first prompt, or an AI-generated summary when one exists) and a **Cache** hit-rate. The title is private conversation content, so it appears only when transcript viewing is enabled for how you reached the dashboard (on your own machine, or on the LAN only if you have explicitly exposed transcripts) and shows a dash otherwise. All the columns now fit at a common laptop width without sideways scrolling, and a long title shortens with the full text available on hover (#264).
12
+ - Dashboard **$/1% Trend** detail view reads better and gains a cost column. When there is too little history to compute a four-week median, the two formerly-empty "—" median tiles collapse into a single "needs 4 weeks" hint instead of looking broken. The weekly table gains a sortable **Cost** column (what you spent that week) and the whole view opens in a wider two-pane layout — the chart and its headline numbers on the left, the sortable weekly table on the right — so it fills the space instead of stacking tall and scrolling; sorting the table reorders only the pop-up, not the small card behind it (#264).
13
+ - The dashboard's usage history is split back into three independent **Daily**, **Weekly**, and **Monthly** cards, each opening its own detail view — undoing an earlier consolidation that folded them into a single "History" card and hid Weekly and Monthly behind an easy-to-miss Day/Week/Month toggle. Every card now has a discoverable header and a keyboard shortcut, and the Weekly and Monthly views open in a wider two-pane layout — the period navigator across the top, the selected period's model-and-token breakdown on the left, and a sortable table of every period on the right — so the full history fits without scrolling. The daily heatmap still deep-links a clicked day straight into the Daily view (#264).
14
+ - Dashboard board and hero redesigned to fix the wasted space the previous facelift left behind. The board is now a content-aware **bento grid** — cards sit in height-matched rows sized to their content and the layout goes full-width (fluid up to 2100px) instead of capping at 1600px — so a wide screen fills with data instead of stranding a tall column of half-empty full-width cards, roughly halving the page height, while still collapsing to a single column on mobile and keeping drag-to-reorder within each row. The hero is rebuilt into three zones so the two questions you actually ask each get a dominant number: weekly usage % (with 5-hour usage paired beside it and the reset countdown) and dollars spent this week (with $/1% as its sub-line), alongside a compact support column for the end-of-week forecast, the week-over-week $/1% trend, and a snapshot-freshness reading that now stays calm for a normal few-minute-old snapshot and only turns amber, then red, once the data is genuinely stale. Every card also gains a consistent ⤢ expand control in its header to open its detail view (previously some cards signalled "open" only via the share icon, and the Blocks card had no click-to-open at all), the Forecast card gains a pace bar toward its 100% cap, and the empty Alerts card shows a teaching gauge of your current usage against the configured alert thresholds (#264).
15
+ - Dashboard desktop board fixes that finish the bento redesign. Cards whose content is taller than their row now scroll inside their own frame instead of overflowing and rendering on top of the card below them — previously the $/1% Trend chart could spill over the Daily card beneath it, and the Weekly and Monthly cards did the same. The 5-hour **Blocks** card now shows every block for the week (scroll inside the card to reach them all) instead of only the three most recent with no way to see the rest. The **Daily** heatmap shows each day's dollar amount again on the card, not just in its detail view. And the per-card collapse arrow — which did nothing on desktop once the board became height-matched — is removed there (the ⤢ expand control opens each card's detail); it still works on smaller stacked screens (#264).
16
+ - Dashboard mobile fixes. The **Recent Sessions** panel title is no longer crushed to nothing on a phone; each session in the list now leads with its title (what the session was about) instead of hiding it; the at-a-glance hero is a compact card rather than three tall stacked zones; and the Daily card title no longer clips (#264).
17
+
18
+ ### Fixed
19
+ - Dashboard: the empty **Recent Alerts** card no longer sits bottom-heavy with a band of empty space above it — it now shows the same "you're clear" gauge as the alerts detail view (your current usage against the configured alert thresholds), centered and sized to the card, so it reads as balanced as its Forecast and Blocks neighbours (#265).
20
+ - Dashboard: the **$/1% Trend** card keeps its sparkline and "older ▸ newer" legend pinned in view and scrolls the weekly table beneath them, so with only a few weeks of history the chart is no longer half-hidden below the card's internal scroll fold (#265).
21
+ - Dashboard: the **Blocks** card's expand (⤢) control is now disabled on a week with no activity blocks yet, instead of looking clickable but opening nothing when pressed (#265).
22
+ - Dashboard: the **Weekly** and **Monthly** cards now list every week and month and scroll within the card, instead of showing only the three most recent behind a scrollbar that revealed nothing more — so the whole history is reachable without opening the detail view (#265).
23
+ - Dashboard: the **Daily** heatmap no longer clips the top off each day-of-month number — the card row is a little taller so every day's number and its dollar amount are fully legible (#265).
24
+
8
25
  ## [1.59.0] - 2026-07-02
9
26
 
10
27
  ### Changed
@@ -4500,10 +4500,22 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4500
4500
  monotonic_now: "float | None" = None,
4501
4501
  oauth_usage_cfg: "dict | None" = None,
4502
4502
  display_tz_pref_override: "str | None" = None,
4503
- runtime_bind: "str | None" = None) -> dict:
4503
+ runtime_bind: "str | None" = None,
4504
+ transcripts_visible: bool = False) -> dict:
4504
4505
  """Serialize a DataSnapshot into the JSON envelope consumed by the
4505
4506
  browser (design spec §2.2).
4506
4507
 
4508
+ ``transcripts_visible`` gates the transcript-derived session ``title``
4509
+ (#264 S3): the key is emitted ONLY when the flag is True AND the row has
4510
+ a title, so False (the default) fails closed for any caller that forgets
4511
+ to pass it — no ``title`` key, no leaked prompt content. The two
4512
+ browser-serving emit sites (``GET /api/data`` + the SSE loop) pass the
4513
+ per-request ``_transcripts_visible_to_request()`` — the SAME predicate
4514
+ that drives ``transcriptsEnabled`` and the per-row "open conversation"
4515
+ button; every other caller (share builders, fixtures, tests) keeps the
4516
+ default. ``cache_hit_pct`` (sessions) and ``cost_usd`` (trend) are plain
4517
+ numbers and are never gated.
4518
+
4507
4519
  Pure function — no I/O on the snapshot data path. Reads
4508
4520
  ``config.json`` once for ``display.tz`` and once for the
4509
4521
  ``alerts_settings`` mirror (cheap atomic-rename reads); the
@@ -5101,6 +5113,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5101
5113
  "dollar_per_pct": w.dollars_per_percent,
5102
5114
  "delta": w.delta_dpp,
5103
5115
  "is_current": bool(w.is_current),
5116
+ # #264 S3: additive weekly cost (already on the row via
5117
+ # build_trend_view); rendered by the Trend modal's Cost
5118
+ # column. ``getattr`` (like ``project_key`` /
5119
+ # ``trend_history_median_dpp``) tolerates minimal trend
5120
+ # shapes — SimpleNamespace test rows / older fixtures —
5121
+ # that predate this nullable field. ``None`` when the
5122
+ # week has no cost snapshot.
5123
+ "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
5104
5124
  }
5105
5125
  for w in snap.trend
5106
5126
  ],
@@ -5112,6 +5132,9 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5112
5132
  "dollar_per_pct": w.dollars_per_percent,
5113
5133
  "delta": w.delta_dpp,
5114
5134
  "is_current": bool(w.is_current),
5135
+ # #264 S3: additive weekly cost (see weeks[] above; same
5136
+ # getattr tolerance for minimal/older trend shapes).
5137
+ "cost_usd": round(_wc, 4) if (_wc := getattr(w, "weekly_cost_usd", None)) is not None else None,
5115
5138
  }
5116
5139
  for w in (snap.weekly_history or [])
5117
5140
  ],
@@ -5155,6 +5178,20 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5155
5178
  # resolve (the React cell falls back to plain text).
5156
5179
  "project_key": getattr(s, "project_key", None),
5157
5180
  "cost_usd": round(s.cost_usd, 4) if s.cost_usd is not None else None,
5181
+ # #264 S3: cache-hit % is a plain metric — always serialized
5182
+ # (never gated). ``None`` when the row has no denominator.
5183
+ "cache_hit_pct": round(s.cache_hit_pct, 1) if s.cache_hit_pct is not None else None,
5184
+ # The session title is transcript-derived content: emit the
5185
+ # `title` KEY only when the per-request transcript gate is
5186
+ # open AND a title exists. Omitted (not null) otherwise, so
5187
+ # a gated-off or untitled session renders the client's
5188
+ # em-dash fallback and committed goldens (empty conversation
5189
+ # fixtures -> no title) never carry a `title` key. Default
5190
+ # transcripts_visible=False therefore fails fully closed.
5191
+ **({"title": getattr(s, "title", None)}
5192
+ if (transcripts_visible
5193
+ and getattr(s, "title", None) is not None)
5194
+ else {}),
5158
5195
  }
5159
5196
  for s in snap.sessions
5160
5197
  ],
@@ -7356,6 +7393,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7356
7393
  cfg_oauth = _get_oauth_usage_config(load_config())
7357
7394
  except OauthUsageConfigError:
7358
7395
  cfg_oauth = dict(sys.modules["cctally"]._OAUTH_USAGE_DEFAULTS)
7396
+ # Resolve the per-request transcript gate ONCE and use it for both the
7397
+ # in-envelope session `title` gate (#264 S3) and the `transcriptsEnabled`
7398
+ # client signal below — one predicate, two consumers, desync impossible.
7399
+ visible = self._transcripts_visible_to_request()
7359
7400
  env = snapshot_to_envelope(
7360
7401
  snap,
7361
7402
  # `_now_utc()` honors CCTALLY_AS_OF for harness determinism;
@@ -7368,6 +7409,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7368
7409
  oauth_usage_cfg=cfg_oauth,
7369
7410
  display_tz_pref_override=type(self).display_tz_pref_override,
7370
7411
  runtime_bind=type(self).cctally_host,
7412
+ transcripts_visible=visible,
7371
7413
  )
7372
7414
  # Conversation viewer (Plan 2, spec §5): inject the client signal
7373
7415
  # PER-REQUEST and Host-aware — NOT inside snapshot_to_envelope (the
@@ -7376,7 +7418,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7376
7418
  # request that the transcript GETs would 403 shows
7377
7419
  # transcriptsEnabled=false, never enabled-then-403 (the pass-2 P2
7378
7420
  # finding) — one predicate, two consumers, desync impossible.
7379
- env["transcriptsEnabled"] = self._transcripts_visible_to_request()
7421
+ env["transcriptsEnabled"] = visible
7380
7422
  body = json.dumps(env, ensure_ascii=False).encode("utf-8")
7381
7423
  self.send_response(200)
7382
7424
  self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -8295,6 +8337,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8295
8337
  oauth_usage_cfg=cfg_oauth,
8296
8338
  display_tz_pref_override=type(self).display_tz_pref_override,
8297
8339
  runtime_bind=type(self).cctally_host,
8340
+ # #264 S3: gate the in-envelope session `title` on the same
8341
+ # connection-scoped predicate that drives transcriptsEnabled.
8342
+ transcripts_visible=transcripts_enabled,
8298
8343
  )
8299
8344
  env["transcriptsEnabled"] = transcripts_enabled
8300
8345
  msg = (
@@ -1609,7 +1609,40 @@ def _tui_build_sessions(
1609
1609
  view = c.build_sessions_view(
1610
1610
  entries, now_utc=now_utc, limit=limit, display_tz=None,
1611
1611
  )
1612
- return list(view.rows)
1612
+ rows = list(view.rows)
1613
+ # Attach human-readable titles for the dashboard/TUI Sessions surface.
1614
+ # Reuses the conversation viewer's title derivation (AI title wins, else
1615
+ # first non-marker human line), which reads the CACHE db
1616
+ # (conversation_messages / conversation_ai_titles). Fail-soft: any cache
1617
+ # failure leaves titles ``None`` -> the client renders an em-dash. One
1618
+ # bounded indexed query (<= `limit` session ids, rides idx_conv_session_ts)
1619
+ # per snapshot build. Titles are stashed unconditionally on this
1620
+ # server-internal row; the privacy gate is applied later, at envelope
1621
+ # serialization (``snapshot_to_envelope(transcripts_visible=...)``).
1622
+ if rows:
1623
+ session_ids = [r.session_id for r in rows if r.session_id]
1624
+ try:
1625
+ conn = c.open_cache_db()
1626
+ except (sqlite3.DatabaseError, OSError):
1627
+ conn = None
1628
+ if conn is not None:
1629
+ try:
1630
+ titles = c._load_sibling(
1631
+ "_lib_conversation_query"
1632
+ )._session_titles_map(conn, session_ids)
1633
+ rows = [
1634
+ dataclasses.replace(r, title=titles.get(r.session_id))
1635
+ if r.session_id in titles else r
1636
+ for r in rows
1637
+ ]
1638
+ except (sqlite3.DatabaseError, OSError):
1639
+ pass # fail-soft: leave titles None
1640
+ finally:
1641
+ try:
1642
+ conn.close()
1643
+ except sqlite3.Error:
1644
+ pass
1645
+ return rows
1613
1646
 
1614
1647
 
1615
1648
  @dataclass
@@ -264,6 +264,16 @@ class TuiSessionRow:
264
264
  # without re-reading ``session_files`` and so the share path can
265
265
  # privacy-scrub via ``_lib_share._scrub``.
266
266
  project_path: str | None = None
267
+ # Dashboard/TUI-only: the human-readable session title (AI-generated
268
+ # title when present, else the first non-marker user prompt). Populated
269
+ # by the dashboard/TUI snapshot wrapper (``_cctally_tui._tui_build_sessions``)
270
+ # via ``_lib_conversation_query._session_titles_map``; left ``None`` on the
271
+ # CLI ``session`` / share paths (they call ``build_sessions_view`` directly).
272
+ # This is transcript-derived content — it rides the per-request transcript
273
+ # privacy gate at envelope serialization (never emitted when the gate is
274
+ # closed). Defaults ``None`` so fixture rows built positionally stay
275
+ # compatible.
276
+ title: str | None = None
267
277
 
268
278
 
269
279
  # === Internal helpers ======================================================