feat(chat): add server-computed thread statistics #340

Merged
rwachters merged 7 commits from feat/session-thread-stats into master 2026-10-10 23:54:04 +00:00
rwachters commented 2026-10-10 23:52:56 +00:00 (Migrated from github.com)

Summary

Adds server-computed statistics for the thread a chat session currently displays: a new shared ThreadStats/DurationStats payload, a GET /api/v1/sessions/{sessionId}/stats endpoint restricted to the session owner, and a Compose "Thread Stats" dialog opened from the chat top-bar overflow menu. The branch also carries two follow-up refactorings: one shared thread-branch resolver and a thinner ChatViewModel.

Changes

  • Added the shared ThreadStats and DurationStats DTOs plus the SessionResource.ById.Stats resource, with an unmeasured value kept as null rather than collapsed into a zero.
  • Added the pure ThreadStatsCalculator, which measures only the root-to-leaf branch of the session's current leaf message and fails loudly on a corrupt parent chain.
  • Added ThreadStatsService and its implementation, loading the session, its messages and its tool calls in one transaction so the numbers describe a single consistent snapshot.
  • Added GET /api/v1/sessions/{sessionId}/stats behind the existing owner-only requireSessionAccess(AccessMode.READ) gate.
  • Added the app-side API, repository and use-case layers that fetch the statistics for the active session and publish them as dialog state.
  • Added the ThreadStatsDialog with message, token, timing and rate sections, rendering unmeasured values explicitly as "not available".

Refactorings

  • Extracted one shared resolveThreadBranch into the common module, replacing the three duplicated parent-chain walks while preserving each caller's failure policy (the app logs and continues; the server throws with its existing messages).
  • Moved the remaining chat dialog openers out of ChatViewModel into the use case that performs the action, so dialog publication lives in one place and every dismissal goes through ChatState.cancelDialog().

Implementation notes

  • null is the single convention for "not measured": a counter reported as zero stays 0, while an unreported one stays null, so the dialog never presents a fabricated number.
  • The statistics are deliberately uncached; every open measures the conversation as it is at that moment, which keeps the endpoint free of invalidation logic.
  • The endpoint relies on the session authorizer's ownership enforcement instead of adding a second authorization check.
  • A corrupt parent chain raises IllegalStateException rather than returning a partial thread, so fabricated aggregates are never surfaced as authoritative.

Validation

  • ./gradlew :common:desktopTest
  • ./gradlew :server:test
  • ./gradlew :app:desktopTest
  • ./gradlew :app:desktopMainClasses
  • ./gradlew :app:compileKotlinWasmJs

Risks and follow-up

  • The statistics snapshot reflects the session's current leaf message at request time; the dialog does not live-update while a turn is running.
  • The dialog's Compose UI is covered by use-case and repository tests but not by a UI-level test; that gap is deferred, along with two documentation/naming nits, as a deliberate follow-up.
## Summary Adds server-computed statistics for the thread a chat session currently displays: a new shared `ThreadStats`/`DurationStats` payload, a `GET /api/v1/sessions/{sessionId}/stats` endpoint restricted to the session owner, and a Compose "Thread Stats" dialog opened from the chat top-bar overflow menu. The branch also carries two follow-up refactorings: one shared thread-branch resolver and a thinner `ChatViewModel`. ## Changes - Added the shared `ThreadStats` and `DurationStats` DTOs plus the `SessionResource.ById.Stats` resource, with an unmeasured value kept as `null` rather than collapsed into a zero. - Added the pure `ThreadStatsCalculator`, which measures only the root-to-leaf branch of the session's current leaf message and fails loudly on a corrupt parent chain. - Added `ThreadStatsService` and its implementation, loading the session, its messages and its tool calls in one transaction so the numbers describe a single consistent snapshot. - Added `GET /api/v1/sessions/{sessionId}/stats` behind the existing owner-only `requireSessionAccess(AccessMode.READ)` gate. - Added the app-side API, repository and use-case layers that fetch the statistics for the active session and publish them as dialog state. - Added the `ThreadStatsDialog` with message, token, timing and rate sections, rendering unmeasured values explicitly as "not available". ## Refactorings - Extracted one shared `resolveThreadBranch` into the common module, replacing the three duplicated parent-chain walks while preserving each caller's failure policy (the app logs and continues; the server throws with its existing messages). - Moved the remaining chat dialog openers out of `ChatViewModel` into the use case that performs the action, so dialog publication lives in one place and every dismissal goes through `ChatState.cancelDialog()`. ## Implementation notes - `null` is the single convention for "not measured": a counter reported as zero stays `0`, while an unreported one stays `null`, so the dialog never presents a fabricated number. - The statistics are deliberately uncached; every open measures the conversation as it is at that moment, which keeps the endpoint free of invalidation logic. - The endpoint relies on the session authorizer's ownership enforcement instead of adding a second authorization check. - A corrupt parent chain raises `IllegalStateException` rather than returning a partial thread, so fabricated aggregates are never surfaced as authoritative. ## Validation - `./gradlew :common:desktopTest` - `./gradlew :server:test` - `./gradlew :app:desktopTest` - `./gradlew :app:desktopMainClasses` - `./gradlew :app:compileKotlinWasmJs` ## Risks and follow-up - The statistics snapshot reflects the session's current leaf message at request time; the dialog does not live-update while a turn is running. - The dialog's Compose UI is covered by use-case and repository tests but not by a UI-level test; that gap is deferred, along with two documentation/naming nits, as a deliberate follow-up.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Torvian.eu/chatbot!340
No description provided.