Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Added
- **Global sessions page: `/sessions` lists every session across every project.** The web UI could only list sessions per project, so "what ran yesterday, everywhere" meant visiting each project page. The new page is the cross-project cockpit: filter by text (summary substring or session-ID prefix), provider, project, model, and date range; group by project, day, provider, or model under sticky headers with per-group aggregates (count, projects, tokens); sort by recency, messages, prompts, or tokens. Dense two-line rows keep every sort key visible as an aligned column (design: docs/design/0003-global-sessions.md). State lives entirely in the URL so filtered views are shareable; active filters render as chips clearable per-param; the whole control bar is a real GET form that works without JavaScript. Default window is 100 sessions with an honest `Showing X of Y` footer and explicit show-more links.
- **`GET /api/sessions` (no project suffix) serves the same query as JSON** — filter/group/sort/limit params identical to the page, wrapped in an envelope with `total` and `shown` so truncation is visible. The per-project `/api/sessions/<project>` keeps its existing bare-array shape.
- **`--sort tokens` for `ccx sessions`.** The web sort landed in the shared catalog layer, so the CLI gains it too: ranks by input+output tokens (cache reads excluded — they would double-count long sessions).

### Fixed
- **Dark-mode provider badges were white-on-light (~2.1:1 contrast).** Dark-theme accent hues are light; badge text now uses the page ground color in dark mode.
- **`.empty-state` only existed inside the memory-section CSS**, so any other page rendering it got unstyled body text. Promoted to the shared stylesheet.
- **The advertised `d` theme shortcut now works on `/sessions`.** The top-nav button has said "Toggle theme (d)" since the shell redesign, but no page bound the key.

## [0.14.0] - 2026-08-03

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ ccx reads session files from `~/.claude/`, `~/.codex/`, and `~/.grok/` and gives

- **Multi-provider** — Claude Code + Codex + Grok sessions merged by project, with provider badges
- **Two-panel navigation** — Projects → Sessions → Conversation tree
- **Global sessions** — `/sessions` lists every session across projects; filter by text/provider/project/model/date, group by project/day/provider/model, sort by recency/messages/prompts/tokens
- **Live tail** — Watch active sessions update in real-time
- **In-session search** — Filter by User, Response, Tools, Agents, Thinking
- **Memory inspector** — View CLAUDE.md, MEMORY.md, AGENTS.md per project
Expand Down
492 changes: 492 additions & 0 deletions docs/design/0003-global-sessions.md

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions docs/devlog/2026-08-11-web-global-sessions.org
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
* [2026-08-11] Dev Log: Global sessions page (/sessions) :WEB:UX:

** Context
The web UI listed sessions only per project. With ~1,800 main
sessions across ~50 projects, "what ran yesterday, everywhere" meant
visiting every project page. The CLI already answered this
(=ccx sessions --all=) through catalog.SessionQuery; the web had no
equivalent surface.

** Why
Cross-project session listing with filter by / group by / order by
is the auditor's cockpit view. Design mandate: spec the UX against
the existing material contract (0002 quiet-terminal token plan)
before building — an Opus 5 design pass via the design-system skill
produced docs/design/0003-global-sessions.md, then implementation
followed its 12-row delta table.

** What
- =FEAT= /sessions page: filter q (summary OR ID prefix), provider,
project, model, after/before; group project/day/provider/model
with sticky aggregate headers; sort time/messages/prompts/tokens;
limit=100 default with Showing X of Y footer and show-more links.
- =FEAT= GET /api/sessions global endpoint, envelope {sessions,
total, shown}; per-project endpoint keeps its bare-array shape.
- =FEAT= catalog SortTokens — CLI gains =--sort tokens= for free.
- =FIX= dark-mode provider badge contrast (white on light accent,
~2.1:1 → ground-color text).
- =FIX= .empty-state promoted from memSectionCSS to style.css.
- =FIX= the advertised-but-unbound =d= theme shortcut (this page).

** How
Reuse over reimplementation: the handler builds a
catalog.SessionQuery (ScopeAll / ScopeProject) and applies the same
filter the CLI uses; the page-only "summary OR ID prefix" q
semantics run as a post-filter so SessionFilter.Query (shared with
the CLI) stays untouched. Grouping is a presentation transform:
first-encounter buckets, then groups ordered by most-recent session
(provider mode: fixed CC/CX/GX). Rows are dense two-line =.srow=
entries — every sort key is a visible column; tools/duration move to
the row tooltip. Controls are one GET form; JS adds a 400ms
debounced auto-submit and strips empty params, chips give per-param
removal as plain links, so zero-JS remains fully usable.

** Decisions
| Decision | Alternatives | Rationale | DRI | Timestamp |
|----------+--------------+-----------+-----+-----------|
| Rows, not cards | reuse .session-card | 2,300-session scan needs density + column alignment; cards halve rows/screen | design pass | 2026-08-11 |
| q = summary OR ID prefix, post-filter | widen SessionFilter.Query | CLI shares SessionFilter; web-only semantics stay web-local | design pass | 2026-08-11 |
| limit before grouping | group full match set | grouping is a view of what you look at, not a second query | design pass | 2026-08-11 |
| groups ordered by most recent session | order by sort aggregate | one predictable rule for all four modes | design pass | 2026-08-11 |
| no pagination | offset paging | auditors narrow with filters; limit + show-all covers the tail | design pass | 2026-08-11 |

** Notes
- Session.Title is populated on the full-parse path only; the row
label precedence (Title else Summary) is written and inert until
quick-parse carries Title (SessionMeta + quickParseListableSession
+ CacheFormatVersion bump — follow-up, Eric-gated).
- Deferred repo-wide flags from the design audit: Tailwind CDN in
pageHeader() contradicts single-binary; sidebar hides at 900px
vs the 0002 two-breakpoint rule; index/project =/= focus behavior
contradicts its own placeholder copy.
- Verified live against the real store: default page 200KB/~40ms,
limit=0 (1,808 sessions) 2.4MB — opt-in, labeled "(slower)".
12 changes: 10 additions & 2 deletions internal/catalog/session_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
SortTime SessionSort = "time"
SortMessages SessionSort = "messages"
SortPrompts SessionSort = "prompts"
SortTokens SessionSort = "tokens"
)

type SessionQuery struct {
Expand Down Expand Up @@ -86,6 +87,13 @@ func SortSessions(sessions []*parser.Session, sortBy SessionSort) {
sort.SliceStable(sessions, func(i, j int) bool {
return sessions[i].Stats.UserPrompts > sessions[j].Stats.UserPrompts
})
case SortTokens:
// Input+output only, matching what the UIs display; cache
// tokens would double-count long sessions.
sort.SliceStable(sessions, func(i, j int) bool {
return sessions[i].Stats.InputTokens+sessions[i].Stats.OutputTokens >
sessions[j].Stats.InputTokens+sessions[j].Stats.OutputTokens
})
default:
sort.SliceStable(sessions, func(i, j int) bool {
return sessions[i].EndTime.After(sessions[j].EndTime)
Expand All @@ -95,10 +103,10 @@ func SortSessions(sessions []*parser.Session, sortBy SessionSort) {

func ValidateSessionSort(sortBy SessionSort) error {
switch sortBy {
case "", SortTime, SortMessages, SortPrompts:
case "", SortTime, SortMessages, SortPrompts, SortTokens:
return nil
default:
return fmt.Errorf("invalid sort %q (want time, messages, or prompts)", sortBy)
return fmt.Errorf("invalid sort %q (want time, messages, prompts, or tokens)", sortBy)
}
}

Expand Down
18 changes: 18 additions & 0 deletions internal/catalog/session_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,21 @@ func TestValidateSessionSortRejectsUnknownSort(t *testing.T) {
t.Fatal("ValidateSessionSort(bogus) returned nil, want error")
}
}

func TestSortSessionsTokensRanksByInputPlusOutput(t *testing.T) {
sessions := []*parser.Session{
{ID: "small", Stats: parser.SessionStats{InputTokens: 100, OutputTokens: 50}},
{ID: "big", Stats: parser.SessionStats{InputTokens: 5000, OutputTokens: 2000}},
{ID: "cache-heavy", Stats: parser.SessionStats{InputTokens: 10, OutputTokens: 5, CacheReadTokens: 999999}},
}

SortSessions(sessions, SortTokens)

if sessions[0].ID != "big" || sessions[1].ID != "small" || sessions[2].ID != "cache-heavy" {
t.Fatalf("token sort = [%s %s %s], want [big small cache-heavy]",
sessions[0].ID, sessions[1].ID, sessions[2].ID)
}
if err := ValidateSessionSort(SortTokens); err != nil {
t.Fatalf("ValidateSessionSort(tokens) = %v, want nil", err)
}
}
2 changes: 1 addition & 1 deletion internal/cmd/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var (
)

func init() {
sessionsCmd.Flags().StringVar(&sessionsSort, "sort", "time", "sort by: time, messages, prompts")
sessionsCmd.Flags().StringVar(&sessionsSort, "sort", "time", "sort by: time, messages, prompts, tokens")
sessionsCmd.Flags().IntVar(&sessionsLimit, "limit", 20, "limit number of sessions (0 = no limit)")
sessionsCmd.Flags().BoolVar(&sessionsJSON, "json", false, "output as JSON")
sessionsCmd.Flags().StringVarP(&sessionsProvider, "provider", "p", "", "filter by provider: cc, cx, gx, all")
Expand Down
9 changes: 8 additions & 1 deletion internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func Serve(addr string, backend provider.Backend) error {

// Pages
mux.HandleFunc("/", handleIndex)
mux.HandleFunc("/sessions", handleSessionsPage)
mux.HandleFunc("/project/", handleProject)
mux.HandleFunc("/session/", handleSession)
mux.HandleFunc("/settings", handleSettings)
Expand All @@ -96,6 +97,7 @@ func Serve(addr string, backend provider.Backend) error {

// API
mux.HandleFunc("/api/projects", handleAPIProjects)
mux.HandleFunc("/api/sessions", handleAPISessions)
mux.HandleFunc("/api/sessions/", handleAPISessions)
mux.HandleFunc("/api/session/", handleAPISession)
mux.HandleFunc("/api/stats", handleAPIStats)
Expand Down Expand Up @@ -702,7 +704,12 @@ func handleAPIProjects(w http.ResponseWriter, r *http.Request) {
}

func handleAPISessions(w http.ResponseWriter, r *http.Request) {
encodedName := strings.TrimPrefix(r.URL.Path, "/api/sessions/")
encodedName := strings.TrimPrefix(r.URL.Path, "/api/sessions")
encodedName = strings.TrimPrefix(encodedName, "/")
if encodedName == "" {
handleAPISessionsGlobal(w, r)
return
}
project, err := sessionProvider.FindProject(encodedName)
if err != nil || project == nil {
http.NotFound(w, r)
Expand Down
Loading
Loading