diff --git a/README.md b/README.md index 04bcbe6..ee4160b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ ccx -view config # start in config explorer ccx -view stats # start in global stats ccx -view plugins # start in plugin explorer ccx -group tree # start with tree grouping +ccx -group daily # start in the daily activity view ccx -preview stats # start with stats preview open ccx -search "is:live" # start filtered to live sessions ``` @@ -94,8 +95,8 @@ ccx sessions -pick -multi | jq '.sessions | length' | `-version`, `-v` | Print version and exit | | `-dir PATH` | Claude data directory (default: `~/.claude`) | | `-view MODE` | Initial view: `sessions`, `config`, `plugins`, `stats` | -| `-group MODE` | Initial grouping: `flat`, `proj`, `tree`, `chain`, `fork` | -| `-preview MODE` | Initial preview: `conv`, `stats`, `mem`, `tasks` | +| `-group MODE` | Initial grouping: `flat`, `proj`, `tree`, `chain`, `fork`, `repo`, `projects`, `daily` | +| `-preview MODE` | Initial preview: `conv`, `stats`, `mem`, `scratch`, `tasks`, `refs`, `outputs` | | `-search QUERY` | Start with session filter applied | | `-tmux` | Enable tmux integration (auto-detected) | | `-tmux-auto-live` | Auto-enter live session in same tmux window | @@ -117,8 +118,9 @@ Browse all Claude Code sessions across projects, sorted by recency. - **Tree** — team hierarchy with leader/teammate nesting - **Chain** — resume-chain grouping (parent → child) - **Fork** — agent-fork grouping + - **Daily** (`D`) — day → project → session tree (newest day first), previewing what each level produced - **Directory filter** (`g`) — scope to a single project directory -- **Preview pane** (`Tab` to cycle): conversation, stats, memory, tasks/plan, workflows, live +- **Preview pane** (`Tab` to cycle): conversation, stats, memory, tasks/plan, workflows, outputs, references, live - **Fleet notifications** — when a live session transitions into an attention state (→ `WAIT`/`DONE`/`STUCK`), a `(!)N` indicator appears in the status bar; press `n` to jump to the most recently notified session - **Multi-select** (`Space`) — bulk delete, copy paths, send input - **Actions menu** (`x`) — delete, move, resume, copy path, worktree, kill, input, jump, URLs, files @@ -221,6 +223,34 @@ Drill into any session to see one chronological spine containing conversation tu ![Kitty image preview](docs/gifs/08-kitty-image-preview.png) +#### Daily Activity View (`D`, or `:group:daily`) + +A date-first view for reviewing what got done rather than which project it happened in. Press `D` to flip into it from any grouping and `D` again to return — it is an axis you toggle while reading, not a mode you commit to. `D` works whether the list or the preview has focus, keeps the cursor on the same session across the swap, and remembers the grouping it returns to across restarts (so starting in the daily view still takes you back to *your* view, not the default). Each view keeps its own preview mode — the daily view opens on outputs, the project browser on the conversation — so a swap never lands you on the wrong pane. + +The list nests three tiers — **day → project → session** — each folding with `Enter`/`o` and aggregating exactly what its level needs: a date row rolls up the whole day, a project row rolls up that day's work in one repo, and sessions sit underneath. A busy day really can hold 250+ sessions across 30 projects, and the project tier is what keeps that readable. + +The preview always shows **what that scope produced** — PRs, Jira issues, artifacts and plans, one row each. Selecting a date row shows the day's outputs; selecting a project row narrows to that project on that day. The sessions themselves are not listed in the pane: they are one row below in the list. + +Every output row carries the session that produced it as an anchor (`a1b2c3 · ~/src/repo`). Focus the preview and press `Enter` on a row to land in that conversation **at the message where the output first appeared** — the digest tells you *what* came out, and the anchor is how you get to *how*. `o` opens the output itself (a PR, Jira issue or artifact in the browser), and `y` copies its URL or path. Outputs referenced from several sessions collapse to one row with a `+N` spread marker, anchored to the earliest session (where the work happened, not where it was later quoted) — and the jump lands in *that* session, at *its* first mention. + +Sessions are bucketed by the calendar day of their **last** activity. A session that spans midnight appears once, under the day it was last active — it is never duplicated across dates. + +**Known limitation — produced vs. referenced.** A reference counts as an output if the session's transcript contains its URL, which includes links that were merely read or quoted (a `kubernetes/kubernetes` PR consulted during debugging shows up next to the PR the session actually opened). Artifacts already avoid this — they are only counted from the `Published … at ` tool result — but PRs and Jira issues have no equivalent creation marker yet. Treat the Produced list as "references this day touched", weighted toward what it created. + +#### Outputs Digest (`p` → `o`, or `:preview:outputs`) + +The per-session counterpart of the daily view: what this session produced, not what it said. Rows are grouped as results first, then working material: + +| Section | Source | +|---------|--------| +| Pull Requests / Jira Issues / Artifacts | Links found in the transcript, with live status (shares the References pipeline and its cache) | +| Plans | `ExitPlanMode` writes plus the plan files recorded on the session | +| Memory | Writes under a `memory/` directory or `MEMORY.md`, titled with the note's frontmatter description | +| Files Changed | `Edit`/`Write`/`MultiEdit`/`NotebookEdit` targets, collapsed per path with a write count (`Read` does not count) | +| Scratchpad | Files in the session's scratchpad directory | + +With the preview focused, `↑↓` moves the cursor, `y` copies the row's URL or path, and `Enter` opens it: external references go to the browser, and everything else jumps into the conversation at the entry that produced it. + #### Subagent and Workflow Support Subagents and workflow agents are displayed inline at their exact spawn origin: @@ -371,8 +401,8 @@ Available from any view. Suggestions are context-aware — only relevant command | `view:config` | All | Open config explorer | | `view:config:hooks` | All | Config → hooks filter | | `view:plugins` | All | Open plugin explorer | -| `group:flat\|proj\|tree\|chain\|fork` | Sessions | Change grouping mode | -| `preview:conv\|stats\|mem\|tasks\|wf\|live` | Sessions | Change preview mode (`wf` = workflow runs) | +| `group:flat\|proj\|tree\|chain\|fork\|repo\|projects\|daily` | Sessions | Change grouping mode | +| `preview:conv\|stats\|mem\|tasks\|wf\|refs\|outputs\|live` | Sessions | Change preview mode (`wf` = workflow runs, `outputs` = what the session produced) | | `set:ratio N` | Sessions | Set split pane ratio (15-85) | | `page:memory\|hooks\|mcp\|skills\|keymaps\|shortcuts\|...` | Config | Filter config category | | `page:tools\|errors\|overview` | Stats | Switch stats page | diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e21829..0acdd34 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -346,7 +346,7 @@ func printHelp() { fmt.Fprintf(os.Stderr, "TUI flags:\n") fmt.Fprintf(os.Stderr, " -v, --version Print version\n") fmt.Fprintf(os.Stderr, " --search Start with session filter\n") - fmt.Fprintf(os.Stderr, " --group Group mode: flat|proj|tree|chain|fork\n") + fmt.Fprintf(os.Stderr, " --group Group mode: flat|proj|tree|chain|fork|repo|projects|daily\n") fmt.Fprintf(os.Stderr, " --view Initial view: sessions|config|plugins|stats\n") } diff --git a/internal/session/outputs.go b/internal/session/outputs.go new file mode 100644 index 0000000..b5d0fa0 --- /dev/null +++ b/internal/session/outputs.go @@ -0,0 +1,349 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// OutputKind classifies one thing a session produced. This is deliberately +// narrower than ArtifactKind: the flow index records every occurrence of +// everything a session *touched* (including reads), while an output is +// something that outlived the session — a plan, a memory note, a scratchpad +// file, an edited source file, a published artifact, a PR, a Jira issue. +type OutputKind string + +const ( + OutputPlan OutputKind = "plan" + OutputMemory OutputKind = "memory" + OutputScratchpad OutputKind = "scratchpad" + OutputChange OutputKind = "change" + OutputArtifact OutputKind = "artifact" + OutputPR OutputKind = "pr" + OutputJira OutputKind = "jira" +) + +// SessionOutput is one produced item, collapsed per identity (plan slug, file +// path, ref label) with its occurrence count and time span. +type SessionOutput struct { + Kind OutputKind + Title string // plan slug, memory note name, file basename, ref label + Detail string // description / relative path / status text + Path string // local filesystem path, when the output is a file + URL string // external URL, when the output is a ref + + First time.Time + Last time.Time + Count int // number of write occurrences (1 for non-write outputs) + + // MessageUUID is the transcript entry that produced this output, so the + // browser can jump from the digest row back into the conversation. Empty + // when the output was discovered on disk rather than in the transcript + // (scratchpad files, memory notes with no recorded write). + MessageUUID string + + // Ref carries PR/Jira/artifact status for ref-kinded outputs. Nil otherwise. + Ref *SessionRef +} + +// outputKindOrder ranks kinds for display: the durable, high-signal results +// (what shipped) come before the working material (what was edited on the way). +var outputKindOrder = map[OutputKind]int{ + OutputPR: 0, + OutputJira: 1, + OutputArtifact: 2, + OutputPlan: 3, + OutputMemory: 4, + OutputChange: 5, + OutputScratchpad: 6, +} + +// SortOutputs orders outputs by kind, then most-recent-first within a kind. +func SortOutputs(outs []SessionOutput) { + sort.SliceStable(outs, func(i, j int) bool { + ki, kj := outputKindOrder[outs[i].Kind], outputKindOrder[outs[j].Kind] + if ki != kj { + return ki < kj + } + if !outs[i].Last.Equal(outs[j].Last) { + return outs[i].Last.After(outs[j].Last) + } + return outs[i].Title < outs[j].Title + }) +} + +// CollectSessionOutputs returns everything the session produced *except* +// references (PR/Jira/artifact), which the caller composes from Session.Refs so +// the existing async extract/resolve pipeline keeps owning their status. +// +// It joins a transcript scan with on-disk state: plan files under +// ~/.claude/plans, the project's memory notes, and the session's scratchpad +// directory. Errors are absorbed: a session whose transcript cannot be read +// still reports whatever the filesystem knows about. +// +// This is I/O- and CPU-bound on large transcripts (multi-megabyte sessions are +// routine), so callers must run it off the UI thread. +func CollectSessionOutputs(sess Session, home string) []SessionOutput { + outs := collectTranscriptOutputs(sess.FilePath, home) + outs = append(outs, collectScratchpadOutputs(sess)...) + outs = mergeMemoryDescriptions(outs, sess, home) + SortOutputs(outs) + return outs +} + +// RefOutput converts a resolved reference into a SessionOutput so refs and +// on-disk outputs render as one list. Artifacts keep their description as the +// detail line; PR/Jira carry their resolved status text. +func RefOutput(r SessionRef) SessionOutput { + kind := OutputArtifact + switch r.Kind { + case RefPR: + kind = OutputPR + case RefJira: + kind = OutputJira + } + detail := RefStatusText(r) + title := r.Label + if r.Kind == RefArtifact && r.Title != "" { + // An artifact's UUID label says nothing; its description is the name a + // person would recognize, so it leads and the id becomes the detail. + title, detail = r.Title, r.Label + } + ref := r + return SessionOutput{ + Kind: kind, + Title: title, + Detail: detail, + URL: r.URL, + First: r.FirstSeen, + Last: r.FirstSeen, + Count: 1, + MessageUUID: r.FirstSeenUUID, + Ref: &ref, + } +} + +// collectTranscriptOutputs scans the transcript for plan writes and file +// changes. Like ExtractSessionRefsFromFile it deliberately avoids +// LoadMessages/ParseEntry: fully unmarshaling every entry of a multi-megabyte +// transcript to find a handful of tool_use blocks cost ~2.2s on a 100MB +// session. Instead we scan raw lines, skip any line without a write-tool +// marker, and only decode the survivors. +func collectTranscriptOutputs(filePath, home string) []SessionOutput { + f, err := os.Open(filePath) + if err != nil { + return nil + } + defer f.Close() + + byKey := make(map[string]*SessionOutput) + remember := func(kind OutputKind, key, title, detail, path string, ts time.Time, uuid string) { + k := string(kind) + "\x00" + key + o, ok := byKey[k] + if !ok { + o = &SessionOutput{ + Kind: kind, Title: title, Detail: detail, Path: path, + First: ts, Last: ts, MessageUUID: uuid, + } + byKey[k] = o + } + o.Count++ + if ts.IsZero() { + return + } + if o.First.IsZero() || ts.Before(o.First) { + o.First = ts + // The jump target is the *first* time the session produced this + // output — that is where the decision to write it was made. + o.MessageUUID = uuid + } + if ts.After(o.Last) { + o.Last = ts + } + } + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1024*1024), 10*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if !hasOutputToolMarker(line) { + continue + } + entry, err := ParseEntry(string(line)) + if err != nil { + continue + } + for _, b := range entry.Content { + if b.Type != "tool_use" || b.ToolInput == "" { + continue + } + if b.ToolName == "ExitPlanMode" { + var in struct { + PlanFilePath string `json:"planFilePath"` + } + if json.Unmarshal([]byte(b.ToolInput), &in) != nil || in.PlanFilePath == "" { + continue + } + slug := strings.TrimSuffix(baseName(in.PlanFilePath), ".md") + remember(OutputPlan, in.PlanFilePath, slug, ShortenPath(in.PlanFilePath, home), in.PlanFilePath, entry.Timestamp, entry.UUID) + continue + } + field, ok := changeTools[b.ToolName] + if !ok { + continue + } + path := jsonStringField(b.ToolInput, field) + if path == "" { + continue + } + kind := OutputChange + if isMemoryPath(path) { + kind = OutputMemory + } + remember(kind, path, baseName(path), ShortenPath(path, home), path, entry.Timestamp, entry.UUID) + } + } + + outs := make([]SessionOutput, 0, len(byKey)) + for _, o := range byKey { + outs = append(outs, *o) + } + return outs +} + +// outputToolMarkers are the JSON needles that make a raw line worth decoding: +// the write tools plus ExitPlanMode. A line without any of them cannot carry an +// output, so it never pays for a JSON unmarshal. +var outputToolMarkers = [][]byte{ + []byte(`"name":"Edit"`), + []byte(`"name":"MultiEdit"`), + []byte(`"name":"Write"`), + []byte(`"name":"NotebookEdit"`), + []byte(`"name":"ExitPlanMode"`), +} + +func hasOutputToolMarker(line []byte) bool { + for _, m := range outputToolMarkers { + if bytes.Contains(line, m) { + return true + } + } + return false +} + +// collectScratchpadOutputs lists the session's scratchpad files. Unlike the +// scratchpad preview we do not read bodies here — the digest only needs name, +// size and mtime, and a large scratchpad would otherwise cost megabytes per +// navigation. +func collectScratchpadOutputs(sess Session) []SessionOutput { + files := LoadScratchpadFiles(sess.ProjectPath, sess.ID) + outs := make([]SessionOutput, 0, len(files)) + for _, f := range files { + mt := time.Unix(f.ModTime, 0) + outs = append(outs, SessionOutput{ + Kind: OutputScratchpad, + Title: f.Name, + Detail: humanBytes(f.Size), + Path: f.Path, + First: mt, + Last: mt, + Count: 1, + }) + } + return outs +} + +// mergeMemoryDescriptions replaces a memory output's raw path detail with the +// note's frontmatter description, which is what makes a memory row readable. +// Notes the session wrote but that no longer exist on disk keep the path. +func mergeMemoryDescriptions(outs []SessionOutput, sess Session, home string) []SessionOutput { + hasMemory := false + for _, o := range outs { + if o.Kind == OutputMemory { + hasMemory = true + break + } + } + if !hasMemory { + return outs + } + notes := LoadMemoryNotes(sess.ProjectPath, home) + if len(notes) == 0 { + return outs + } + byFile := make(map[string]MemoryNote, len(notes)) + for _, n := range notes { + byFile[n.FileName] = n + } + for i := range outs { + if outs[i].Kind != OutputMemory { + continue + } + n, ok := byFile[outs[i].Title] + if !ok { + continue + } + if n.Name != "" { + outs[i].Title = n.Name + } + if n.Description != "" { + outs[i].Detail = n.Description + } + } + return outs +} + +// PlanFileOutputs lists plan files recorded on the session by the scanner +// (PlanSlugs) that the transcript walk did not already surface. A resumed +// session inherits its parent's plan slug without re-running ExitPlanMode, so +// without this the plan it is actually working from would be invisible. +func PlanFileOutputs(sess Session, home string, have []SessionOutput) []SessionOutput { + if len(sess.PlanSlugs) == 0 { + return nil + } + seen := make(map[string]bool, len(have)) + for _, o := range have { + if o.Kind == OutputPlan { + seen[strings.TrimSuffix(baseName(o.Path), ".md")] = true + } + } + var outs []SessionOutput + for _, slug := range sess.PlanSlugs { + if slug == "" || seen[slug] { + continue + } + path := filepath.Join(home, ".claude", "plans", slug+".md") + info, err := os.Stat(path) + if err != nil { + continue + } + outs = append(outs, SessionOutput{ + Kind: OutputPlan, + Title: slug, + Detail: ShortenPath(path, home), + Path: path, + First: info.ModTime(), + Last: info.ModTime(), + Count: 1, + }) + } + return outs +} + +// humanBytes formats a byte count compactly (e.g. "1.2 KB"). +func humanBytes(n int64) string { + switch { + case n < 1024: + return fmt.Sprintf("%d B", n) + case n < 1024*1024: + return fmt.Sprintf("%.1f KB", float64(n)/1024) + default: + return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024)) + } +} diff --git a/internal/session/refs.go b/internal/session/refs.go index 16c4b70..29dfc20 100644 --- a/internal/session/refs.go +++ b/internal/session/refs.go @@ -53,6 +53,10 @@ type SessionRef struct { Title string // human-readable title (artifact description/label; empty for PR/Jira) FirstSeen time.Time // timestamp of the entry where this ref first appeared + // FirstSeenUUID is the uuid of that same entry, so a digest row can jump + // back into the conversation at the moment the ref appeared rather than + // only opening the session. Empty when the transcript line carried no uuid. + FirstSeenUUID string State RefState // resolved lifecycle state (RefStateUnknown until fetched) @@ -130,6 +134,7 @@ func ExtractSessionRefsFromFile(filePath string) []SessionRef { continue } var ts time.Time + var uuid string tsParsed := false for _, raw := range locs { u := cleanRefURL(string(raw)) @@ -149,10 +154,12 @@ func ExtractSessionRefsFromFile(filePath string) []SessionRef { } if !tsParsed { ts = lineTimestamp(line) + uuid = lineUUID(line) tsParsed = true } seen[ref.Label] = true ref.FirstSeen = ts + ref.FirstSeenUUID = uuid refs = append(refs, ref) } } @@ -181,6 +188,26 @@ func lineTimestamp(line []byte) time.Time { var bTimestampKey = []byte(`"timestamp":"`) +// lineUUID pulls the entry's own "uuid":"<...>" value out of a raw JSONL line +// without a full JSON decode — the uuid counterpart of lineTimestamp. The +// entry's own uuid is the only "uuid" key Claude Code writes at any depth of a +// transcript line (verified across a 36k-line corpus), so the first match is +// the entry uuid. Returns "" when absent. +func lineUUID(line []byte) string { + i := bytes.Index(line, bUUIDKey) + if i < 0 { + return "" + } + rest := line[i+len(bUUIDKey):] + end := bytes.IndexByte(rest, '"') + if end < 0 { + return "" + } + return string(rest[:end]) +} + +var bUUIDKey = []byte(`"uuid":"`) + // ExtractSessionRefs scans a session's entries for PR and Jira URLs and returns // a deduplicated, ordered list (PRs first, then Jira). References are keyed by // their canonical Label (e.g. "sendbird/ccx#52"), so the same PR referenced via @@ -192,6 +219,7 @@ func ExtractSessionRefs(entries []Entry) []SessionRef { var refs []SessionRef for i := range entries { ts := entries[i].Timestamp + uuid := entries[i].UUID for _, b := range entries[i].Content { for _, text := range [2]string{b.Text, b.ToolInput} { if text == "" { @@ -213,6 +241,7 @@ func ExtractSessionRefs(entries []Entry) []SessionRef { } seen[ref.Label] = true ref.FirstSeen = ts + ref.FirstSeenUUID = uuid refs = append(refs, ref) } } @@ -548,8 +577,12 @@ func ResolveRef(ctx context.Context, r SessionRef) SessionRef { } if cached, ok := getCachedRef(r.URL); ok { // The cache is keyed by URL and stores only resolved status; keep this - // occurrence's FirstSeen/Label rather than the cached entry's. + // occurrence's first-appearance/Label rather than the cached entry's. + // FirstSeenUUID must travel with FirstSeen — it names an entry in THIS + // session's transcript, and a uuid from whichever session happened to + // populate the cache would not resolve here. cached.FirstSeen = r.FirstSeen + cached.FirstSeenUUID = r.FirstSeenUUID cached.Label = r.Label return cached } @@ -563,6 +596,7 @@ func ResolveRef(ctx context.Context, r SessionRef) SessionRef { // Another goroutine may have resolved this URL while we waited for a slot. if cached, ok := getCachedRef(r.URL); ok { cached.FirstSeen = r.FirstSeen + cached.FirstSeenUUID = r.FirstSeenUUID cached.Label = r.Label return cached } diff --git a/internal/session/refs_uuid_test.go b/internal/session/refs_uuid_test.go new file mode 100644 index 0000000..82c08e8 --- /dev/null +++ b/internal/session/refs_uuid_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "bytes" + "context" + "encoding/gob" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// The day pane's Enter jumps to the conversation entry where an output first +// appeared. That is only possible if a ref remembers WHICH entry it first +// appeared in — FirstSeen (a timestamp) cannot address a message. These tests +// guard that both extraction paths record the uuid, that dedup keeps the +// FIRST occurrence's uuid, and that the URL-keyed resolve cache does not +// substitute another session's uuid. + +// TestExtractSessionRefsFromFileRecordsFirstSeenUUID guards the raw-line +// scanner: the fast path must pull the entry uuid off the line the same way it +// pulls the timestamp, or every ref the day pane shows has nothing to jump to. +func TestExtractSessionRefsFromFileRecordsFirstSeenUUID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + fp := filepath.Join(t.TempDir(), "s.jsonl") + lines := []string{ + `{"type":"assistant","uuid":"entry-first","timestamp":"2026-07-01T10:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"opened https://github.com/sendbird/ccx/pull/52"}]}}`, + // The same PR again, later, with a different uuid: dedup must keep the + // FIRST entry's uuid — where the work happened, not where it was quoted. + `{"type":"assistant","uuid":"entry-later","timestamp":"2026-07-03T10:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"see https://github.com/sendbird/ccx/pull/52#discussion_r1"}]}}`, + `{"type":"assistant","uuid":"entry-jira","timestamp":"2026-07-02T10:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"ticket https://sendbird.atlassian.net/browse/CPLAT-1234"}]}}`, + } + if err := os.WriteFile(fp, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + byLabel := map[string]SessionRef{} + for _, r := range ExtractSessionRefsFromFile(fp) { + byLabel[r.Label] = r + } + if got := byLabel["sendbird/ccx#52"].FirstSeenUUID; got != "entry-first" { + t.Errorf("PR FirstSeenUUID = %q, want %q (the first occurrence's entry)", got, "entry-first") + } + if got := byLabel["CPLAT-1234"].FirstSeenUUID; got != "entry-jira" { + t.Errorf("Jira FirstSeenUUID = %q, want %q", got, "entry-jira") + } +} + +// TestExtractSessionRefsRecordsFirstSeenUUID guards the []Entry path, which the +// conversation-side callers use. +func TestExtractSessionRefsRecordsFirstSeenUUID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t0 := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + entries := []Entry{ + {UUID: "u-first", Timestamp: t0, Content: []ContentBlock{ + {Type: "text", Text: "opened https://github.com/sendbird/ccx/pull/52"}, + }}, + {UUID: "u-later", Timestamp: t0.Add(time.Hour), Content: []ContentBlock{ + {Type: "text", Text: "again https://github.com/sendbird/ccx/pull/52#discussion_r1"}, + }}, + } + refs := ExtractSessionRefs(entries) + if len(refs) != 1 { + t.Fatalf("want 1 deduped ref, got %d: %+v", len(refs), refs) + } + if refs[0].FirstSeenUUID != "u-first" { + t.Errorf("FirstSeenUUID = %q, want %q (dedup must keep the first occurrence)", refs[0].FirstSeenUUID, "u-first") + } +} + +// TestRefOutputCarriesFirstSeenUUID guards the hand-off: RefOutput is what the +// digests render, so a uuid recorded on the ref but dropped here leaves the +// jump target empty just the same. +func TestRefOutputCarriesFirstSeenUUID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + out := RefOutput(SessionRef{ + Kind: RefPR, Label: "sendbird/ccx#52", + URL: "https://github.com/sendbird/ccx/pull/52", + FirstSeenUUID: "u-first", + }) + if out.MessageUUID != "u-first" { + t.Errorf("SessionOutput.MessageUUID = %q, want %q", out.MessageUUID, "u-first") + } +} + +// TestResolveRefKeepsThisOccurrencesUUID guards the staleness trap: the resolve +// cache is keyed by URL and shared process-wide, so the SAME PR referenced from +// two sessions hits one cache entry. FirstSeenUUID names an entry in a specific +// transcript — serving session B's uuid to session A sends the jump to an entry +// that does not exist there ("Entry not found in transcript"). +func TestResolveRefKeepsThisOccurrencesUUID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + ClearRefCache() + t.Cleanup(ClearRefCache) + + url := "https://sendbird.atlassian.net/browse/CPLAT-9999" + // Prime the cache as if session A resolved this ref first. Seeding directly + // keeps the test hermetic — no gh/Jira network call. + setCachedRef(SessionRef{ + Kind: RefJira, URL: url, Label: "CPLAT-9999", + FirstSeen: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), FirstSeenUUID: "sessionA-entry", + JiraStatus: "In Progress", State: RefStateOpen, Resolved: true, + }) + + // Session B sees the same PR at its own entry and asks for a resolve. + got := ResolveRef(context.Background(), SessionRef{ + Kind: RefJira, URL: url, Label: "CPLAT-9999", + FirstSeen: time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), FirstSeenUUID: "sessionB-entry", + }) + + if got.FirstSeenUUID != "sessionB-entry" { + t.Errorf("FirstSeenUUID = %q, want %q — the cache leaked another session's jump target", + got.FirstSeenUUID, "sessionB-entry") + } + if got.JiraStatus != "In Progress" { + t.Errorf("resolved status lost: JiraStatus = %q", got.JiraStatus) + } +} + +// TestLineUUIDReadsTopLevelEntryUUID pins the raw-line extractor's contract: +// the value it returns is the entry's own uuid, and a line without one yields +// "" rather than a neighbouring field's value. +func TestLineUUIDReadsTopLevelEntryUUID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if got := lineUUID([]byte(`{"parentUuid":"p1","type":"assistant","uuid":"e1","timestamp":"2026-07-01T10:00:00.000Z"}`)); got != "e1" { + t.Errorf("lineUUID = %q, want %q", got, "e1") + } + if got := lineUUID([]byte(`{"type":"mode","mode":"normal","sessionId":"abc"}`)); got != "" { + t.Errorf("lineUUID on a uuid-less line = %q, want empty", got) + } +} + +// TestSessionRefGobRoundTripsWithoutUUID guards the on-disk session cache: the +// scan cache is gob-encoded, so a user upgrading ccx decodes yesterday's cache +// into today's struct. A stream written before FirstSeenUUID existed must still +// decode — gob ignores absent fields, and this pins that it stays true (a +// cache that failed to decode would silently force a full rescan). +func TestSessionRefGobRoundTripsWithoutUUID(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + + // Write a cache the way the scanner does, with refs on the session. + sc := &sessionCache{path: filepath.Join(dir, ".ccx-cache.gob"), entries: map[string]cachedSession{}} + mod := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + sc.store("/tmp/p/a.jsonl", mod, Session{ + ID: "a", MsgCount: 3, ModTime: mod, HasRefs: true, + Refs: []SessionRef{{ + Kind: RefPR, URL: "https://github.com/sendbird/ccx/pull/52", Label: "sendbird/ccx#52", + FirstSeen: mod, FirstSeenUUID: "u-first", + }}, + }) + sc.save() + + reloaded := loadCache(dir) + cached, ok := reloaded.lookup("/tmp/p/a.jsonl", mod) + if !ok { + t.Fatal("cache entry did not survive a gob round-trip") + } + if len(cached.Refs) != 1 || cached.Refs[0].FirstSeenUUID != "u-first" { + t.Errorf("FirstSeenUUID did not survive the gob round-trip: %+v", cached.Refs) + } +} + +// TestLegacySessionRefGobDecodes is the other half of the cache-compat story: +// a cache written by a ccx build that predates FirstSeenUUID. gob matches +// fields by name, so an absent field decodes as the zero value rather than an +// error — this pins that, because a decode error would blow away the whole +// cache map (loadCache returns an empty cache on any error) and cost every +// user a full rescan on upgrade. +func TestLegacySessionRefGobDecodes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // legacySessionRef mirrors SessionRef as it was before FirstSeenUUID. gob + // keys on field names, not on the struct's own name. + type legacySessionRef struct { + Kind RefKind + URL string + Label string + Title string + FirstSeen time.Time + State RefState + Resolved bool + } + mod := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode([]legacySessionRef{{ + Kind: RefPR, URL: "https://github.com/sendbird/ccx/pull/52", + Label: "sendbird/ccx#52", FirstSeen: mod, State: RefStateOpen, Resolved: true, + }}); err != nil { + t.Fatal(err) + } + + var got []SessionRef + if err := gob.NewDecoder(&buf).Decode(&got); err != nil { + t.Fatalf("a pre-FirstSeenUUID cache must still decode, got: %v", err) + } + if len(got) != 1 || got[0].Label != "sendbird/ccx#52" { + t.Fatalf("decoded refs = %+v", got) + } + if got[0].FirstSeenUUID != "" { + t.Errorf("FirstSeenUUID = %q, want empty for a legacy ref", got[0].FirstSeenUUID) + } +} diff --git a/internal/tmux/pane.go b/internal/tmux/pane.go index 75bac37..e956f7a 100644 --- a/internal/tmux/pane.go +++ b/internal/tmux/pane.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strconv" "strings" + "sync" + "time" "github.com/sendbird/ccx/internal/claudecmd" "github.com/sendbird/ccx/internal/clauderegistry" @@ -137,39 +139,54 @@ func HasClaudeSession(shellPID int, sessionID string) bool { // HasClaude checks if a pane's shell has a claude process anywhere in its // descendant tree — not just as a direct child. Claude is frequently launched -// behind a wrapper (ccproxy, tee, sudo, a shell function), so `pgrep -P` on the -// immediate children misses it. We first try the cheap direct-child check, then -// fall back to walking the full process subtree. This must stay consistent with +// behind a wrapper (ccproxy, tee, sudo, a shell function), so checking only the +// immediate children misses it. This must stay consistent with // MarkLiveSessions, which attributes live claudes to panes via a PPID walk; // otherwise a session shows [LIVE] but its live preview capture is rejected. +// The snapshot below makes this answer up to windowClaudesTTL stale relative to +// MarkLiveSessions' own walk, so the disagreement window is bounded by that +// TTL rather than being open-ended. func HasClaude(shellPID int) bool { if shellPID == 0 { return false } - // Fast path: direct child named claude. + // One `ps` snapshot answers both the direct-child and the subtree question, + // so there is no cheaper "fast path" to try first — spawning a pgrep per + // pane was the expensive part. Fall back to pgrep only if the snapshot is + // unavailable. + if tree, ok := loadProcTree(); ok { + return tree.hasClaudeUnder(shellPID) + } if out, err := exec.Command("pgrep", "-P", strconv.Itoa(shellPID), "-f", "claude").Output(); err == nil { - if len(strings.TrimSpace(string(out))) > 0 { - return true - } + return len(strings.TrimSpace(string(out))) > 0 } - // Fallback: any descendant process is (or is wrapping) claude. - return hasClaudeDescendant(shellPID) + return false +} + +// procTree is a snapshot of the process table: pid → command, plus the child +// index needed to walk a subtree. +type procTree struct { + cmd map[int]string + children map[int][]int } -// hasClaudeDescendant reports whether any process in shellPID's subtree has -// "claude" in its command line. Uses one `ps` snapshot (pid, ppid, command) -// and a BFS down the tree, bounded against cycles. -func hasClaudeDescendant(shellPID int) bool { +// loadProcTree reads and parses `ps -e` once. Building this costs ~100ms on a +// busy machine, and the old code paid it separately for every pane examined — +// scanning a window with two Claude panes read the whole process table twice. +// The snapshot is memoized for the same short window as the pane scan that +// drives it, so one startup pays for one `ps`. +func loadProcTree() (*procTree, bool) { + procTreeMu.Lock() + defer procTreeMu.Unlock() + if procTreeVal != nil && time.Since(procTreeAt) <= windowClaudesTTL { + return procTreeVal, true + } + out, err := exec.Command("ps", "-e", "-o", "pid=,ppid=,command=").Output() if err != nil { - return false + return nil, false } - type proc struct { - ppid int - cmd string - } - procs := make(map[int]proc) - children := make(map[int][]int) + t := &procTree{cmd: make(map[int]string), children: make(map[int][]int)} for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" { @@ -184,13 +201,23 @@ func hasClaudeDescendant(shellPID int) bool { if err1 != nil || err2 != nil { continue } - cmd := strings.Join(fields[2:], " ") - procs[pid] = proc{ppid: ppid, cmd: cmd} - children[ppid] = append(children[ppid], pid) + t.cmd[pid] = strings.Join(fields[2:], " ") + t.children[ppid] = append(t.children[ppid], pid) } + procTreeVal, procTreeAt = t, time.Now() + return t, true +} - // BFS from shellPID over descendants; look for "claude" in any command. - queue := append([]int(nil), children[shellPID]...) +var ( + procTreeMu sync.Mutex + procTreeVal *procTree + procTreeAt time.Time +) + +// hasClaudeUnder reports whether any descendant of shellPID is (or wraps) a +// claude process. BFS over the snapshot — no subprocesses. +func (t *procTree) hasClaudeUnder(shellPID int) bool { + queue := append([]int(nil), t.children[shellPID]...) visited := make(map[int]bool) for len(queue) > 0 { pid := queue[0] @@ -199,12 +226,10 @@ func hasClaudeDescendant(shellPID int) bool { continue } visited[pid] = true - if p, ok := procs[pid]; ok { - if strings.Contains(p.cmd, "claude") { - return true - } + if strings.Contains(t.cmd[pid], "claude") { + return true } - queue = append(queue, children[pid]...) + queue = append(queue, t.children[pid]...) } return false } @@ -431,6 +456,9 @@ func CurrentWindowClaudes() []string { if !InTmux() { return nil } + if paths, ok := cachedWindowClaudes(); ok { + return paths + } panes, err := ListPanes() if err != nil { @@ -445,7 +473,54 @@ func CurrentWindowClaudes() []string { mySession, myWindow = own.Session, own.Window } - return claudesInWindow(panes, mySession, myWindow) + paths := claudesInWindow(panes, mySession, myWindow) + storeWindowClaudes(paths) + return paths +} + +// The window's Claude panes are resolved by shelling out to tmux and then +// pgrep-ing every pane's process subtree — ~210ms on a busy window, measured. +// Startup calls this several times (auto-select, then its filter-cleared +// retry), and it is on the path to the first frame, so the result is memoized +// briefly. The TTL is short because the answer is genuinely live state: a pane +// the user opens in another window should show up on the next tick, not +// minutes later. +const windowClaudesTTL = 2 * time.Second + +var ( + windowClaudesMu sync.Mutex + windowClaudesAt time.Time + windowClaudesVal []string + windowClaudesOnce bool +) + +func cachedWindowClaudes() ([]string, bool) { + windowClaudesMu.Lock() + defer windowClaudesMu.Unlock() + if !windowClaudesOnce || time.Since(windowClaudesAt) > windowClaudesTTL { + return nil, false + } + return windowClaudesVal, true +} + +func storeWindowClaudes(paths []string) { + windowClaudesMu.Lock() + windowClaudesVal = paths + windowClaudesAt = time.Now() + windowClaudesOnce = true + windowClaudesMu.Unlock() +} + +// InvalidateWindowClaudes drops the memoized window scan so the next call +// re-reads live tmux state. Used by the refresh path, where the user is +// explicitly asking for current reality. +func InvalidateWindowClaudes() { + windowClaudesMu.Lock() + windowClaudesOnce = false + windowClaudesMu.Unlock() + procTreeMu.Lock() + procTreeVal = nil + procTreeMu.Unlock() } // claudesInWindow returns the absolute cwd of every pane in the named tmux diff --git a/internal/tmux/proccache_test.go b/internal/tmux/proccache_test.go new file mode 100644 index 0000000..402aa88 --- /dev/null +++ b/internal/tmux/proccache_test.go @@ -0,0 +1,87 @@ +package tmux + +import ( + "testing" + "time" +) + +func TestProcTreeFindsWrappedClaude(t *testing.T) { + // The snapshot must find claude anywhere in a pane's subtree, not just as a + // direct child — it is routinely launched behind a wrapper. + tree := &procTree{ + cmd: map[int]string{ + 10: "-fish", + 11: "ccproxy run", + 12: "node /usr/local/bin/claude --resume abc", + 20: "-fish", + 21: "vim", + }, + children: map[int][]int{ + 10: {11}, + 11: {12}, + 20: {21}, + }, + } + if !tree.hasClaudeUnder(10) { + t.Error("expected a wrapped claude two levels down to be found") + } + if tree.hasClaudeUnder(20) { + t.Error("expected a pane with no claude to report false") + } + if tree.hasClaudeUnder(999) { + t.Error("expected an unknown pid to report false") + } +} + +func TestProcTreeSurvivesCycles(t *testing.T) { + // A malformed ps snapshot must not hang the startup path. + tree := &procTree{ + cmd: map[int]string{1: "a", 2: "b"}, + children: map[int][]int{1: {2}, 2: {1}}, + } + done := make(chan bool, 1) + go func() { done <- tree.hasClaudeUnder(1) }() + select { + case got := <-done: + if got { + t.Error("expected no claude in this tree") + } + case <-time.After(2 * time.Second): + t.Fatal("hasClaudeUnder did not terminate on a cyclic tree") + } +} + +func TestInvalidateWindowClaudesDropsBothCaches(t *testing.T) { + // Refresh is the user asking for current reality, so neither the window + // scan nor the process snapshot may survive it. + storeWindowClaudes([]string{"/tmp/x"}) + procTreeMu.Lock() + procTreeVal = &procTree{cmd: map[int]string{}, children: map[int][]int{}} + procTreeAt = time.Now() + procTreeMu.Unlock() + + InvalidateWindowClaudes() + + if _, ok := cachedWindowClaudes(); ok { + t.Error("expected the window scan cache to be dropped") + } + procTreeMu.Lock() + stale := procTreeVal != nil + procTreeMu.Unlock() + if stale { + t.Error("expected the process snapshot to be dropped") + } +} + +func TestWindowClaudesCacheExpires(t *testing.T) { + storeWindowClaudes([]string{"/tmp/x"}) + if _, ok := cachedWindowClaudes(); !ok { + t.Fatal("expected a fresh store to be a cache hit") + } + windowClaudesMu.Lock() + windowClaudesAt = time.Now().Add(-windowClaudesTTL - time.Second) + windowClaudesMu.Unlock() + if _, ok := cachedWindowClaudes(); ok { + t.Error("expected an expired entry to miss so live state is re-read") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 0069847..2f1d84f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -299,17 +299,31 @@ type App struct { sessRefsCacheID string // session ID the refs cursor currently tracks sessWorkflowsCache string sessWorkflowsCacheKey string - sessWfRuns []session.WorkflowRun // parsed runs for the selected session - sessWfAgents []session.Subagent // workflow-nested agents (label-joined), drill-down targets - sessWfCursor int // cursor within the workflow agent list - sessPreviewAgents []session.Subagent // agents shown in Tasks/Plan preview - sessAgentCursor int // cursor within agents list - sessPreviewRefs []session.SessionRef // ordered refs shown in the References preview (open PRs first) - sessRefsCursor int // cursor within the References preview list - sessRefsSelected map[string]bool // selected ref URLs for multi-open/copy (keyed by SessionRef.URL) - sessRefsResolved bool // whether the currently-previewed session's refs have been resolved - refsInFlight map[string]bool // session IDs with a resolve pass currently running (prevents re-targeting every tick) - openURL func(string) error // opens a URL in the browser; overridable in tests (defaults to `open`) + sessWfRuns []session.WorkflowRun // parsed runs for the selected session + sessWfAgents []session.Subagent // workflow-nested agents (label-joined), drill-down targets + sessWfCursor int // cursor within the workflow agent list + sessPreviewAgents []session.Subagent // agents shown in Tasks/Plan preview + sessAgentCursor int // cursor within agents list + sessPreviewRefs []session.SessionRef // ordered refs shown in the References preview (open PRs first) + sessRefsCursor int // cursor within the References preview list + sessRefsSelected map[string]bool // selected ref URLs for multi-open/copy (keyed by SessionRef.URL) + sessRefsResolved bool // whether the currently-previewed session's refs have been resolved + refsInFlight map[string]bool // session IDs with a resolve pass currently running (prevents re-targeting every tick) + sessOutputs []session.SessionOutput // transcript/disk-derived outputs (refs merged in at render time) + sessOutputsRows []session.SessionOutput // what the digest currently shows, in cursor order + sessOutputsCache string + sessOutputsCacheKey string + sessOutputsCacheID string // session ID the outputs digest currently tracks + sessOutputsCollected string // dataKey of the collection in sessOutputs ("" = not collected yet) + sessOutputsCursor int // cursor within the Outputs digest list + outputsInFlight map[string]bool // session IDs with a collection currently running + dayOutputRows []dayOutputRow // outputs shown in the daily view's day pane, in cursor order + dayOutputsCursor int // cursor within the day pane's output list + dayOutputsCacheID string // day key the cursor currently tracks + preDailyGroupMode int // grouping to restore when the daily view is toggled back off + dailyPreviewMode sessPreview // preview mode remembered for the daily view + browserPreviewMode sessPreview // preview mode remembered for every other grouping + openURL func(string) error // opens a URL in the browser; overridable in tests (defaults to `open`) // Conversation preview state sessConvEntries []mergedMsg // merged conversation messages @@ -573,6 +587,9 @@ func (a *App) selectedSession() (session.Session, bool) { if pi, ok := sel.(projectItem); ok && len(pi.sessions) > 0 { return pi.sessions[0], true } + if di, ok := sel.(dayItem); ok && len(di.sessions) > 0 { + return di.sessions[0], true + } return session.Session{}, false } @@ -582,9 +599,18 @@ func (a *App) selectedProject() (projectItem, bool) { return pi, ok } +// selectedDay returns the date row at the cursor, if any (daily view). +func (a *App) selectedDay() (dayItem, bool) { + di, ok := a.sessionList.SelectedItem().(dayItem) + return di, ok +} + func (a *App) selectedSessionListItemKey() string { if pi, ok := a.selectedProject(); ok { - return "project:" + pi.basePath + return "project:" + projectFoldKey(pi) + } + if di, ok := a.selectedDay(); ok { + return "day:" + di.dayKey } if sess, ok := a.selectedSession(); ok { return "session:" + sess.ID @@ -600,7 +626,12 @@ func (a *App) restoreSessionListSelection(key string) { for i, item := range a.sessionList.VisibleItems() { switch v := item.(type) { case projectItem: - if key == "project:"+v.basePath { + if key == "project:"+projectFoldKey(v) { + a.sessionList.Select(i) + return + } + case dayItem: + if key == "day:"+v.dayKey { a.sessionList.Select(i) return } @@ -667,7 +698,7 @@ func (a *App) visibleProjectBrowserItems() int { n := 0 for _, item := range a.sessionList.VisibleItems() { switch item.(type) { - case projectItem, sessionItem: + case projectItem, dayItem, sessionItem: n++ } } @@ -814,9 +845,10 @@ const ( sessPreviewShells sessPreviewContexts sessPreviewRefs // PR / Jira references with resolved status + sessPreviewOutputs // digest of everything the session produced sessPreviewLive // tmux pane capture sessPreviewRemote // remote session status/stream - numSessPreviewModes = 12 + numSessPreviewModes = 13 ) // Config holds application configuration from CLI flags. @@ -863,6 +895,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { selectedSet: make(map[string]bool), hiddenBadges: make(map[string]bool), refsInFlight: make(map[string]bool), + outputsInFlight: make(map[string]bool), sessRefsSelected: make(map[string]bool), notifyPrev: make(map[string]session.LifecycleState), sessionRowCache: newSessionRowCache(1024), @@ -874,6 +907,15 @@ func NewApp(sessions []session.Session, cfg Config) *App { // header. CLI flags or persisted preferences below can still // override this. sessGroupMode: groupProjectCentric, + // Where `D` returns to when the daily view was the startup grouping (a + // persisted preference or -group daily): the browser's default, not the + // zero value, which would drop the user into flat. + preDailyGroupMode: groupProjectCentric, + // Each view remembers its own preview. The daily view exists to show + // results, so it opens on the outputs digest rather than a wall of + // conversation text; the project browser keeps the conversation preview. + dailyPreviewMode: sessPreviewOutputs, + browserPreviewMode: sessPreviewConversation, } // Restore persisted view state (CLI flags override in the apply block below) @@ -901,8 +943,11 @@ func NewApp(sessions []session.Session, cfg Config) *App { a.autoStateFilter = true } - // Cleanup stale remote sessions, then restore remaining as virtual items - cleanupStaleRemoteSessions() + // Restore saved remote sessions. The staleness check that used to run here + // pings every saved remote synchronously — a single unreachable SSH host + // costs the full ConnectTimeout (~2.5s) before ccx paints anything. It now + // runs as a command from Init() instead; a stale row lingering for one + // round-trip is a far smaller cost than a blocked first frame. a.sessions = append(loadSavedRemoteSessions(), a.sessions...) a.sessSplit = SplitPane{List: &a.sessionList, ItemHeight: 2} a.conv.split = SplitPane{List: &a.convList, Show: true, Folds: &FoldState{}, ItemHeight: 1} @@ -923,18 +968,35 @@ func NewApp(sessions []session.Session, cfg Config) *App { // (struct literal above) is groupProjectCentric; an explicit choice here // overrides it. if a.config.GroupMode != "" { - modeMap := map[string]int{"flat": groupFlat, "proj": groupProject, "tree": groupTree, "chain": groupChain, "fork": groupFork, "repo": groupBaseProject, "projects": groupProjectCentric} - if m, ok := modeMap[a.config.GroupMode]; ok { + if m, ok := groupModeFromString(a.config.GroupMode); ok { a.sessGroupMode = m + // Remember a non-daily startup grouping as `D`'s return target, so + // toggling out of the daily view lands back where the user started. + if m != groupDaily { + a.preDailyGroupMode = m + } } } if a.config.PreviewMode != "" { - modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "scratch": sessPreviewScratchpad, "scratchpad": sessPreviewScratchpad, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "wf": sessPreviewWorkflows, "workflows": sessPreviewWorkflows, "shells": sessPreviewShells, "contexts": sessPreviewContexts, "ctx": sessPreviewContexts, "refs": sessPreviewRefs, "pr": sessPreviewRefs, "live": sessPreviewLive} + modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "scratch": sessPreviewScratchpad, "scratchpad": sessPreviewScratchpad, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "wf": sessPreviewWorkflows, "workflows": sessPreviewWorkflows, "shells": sessPreviewShells, "contexts": sessPreviewContexts, "ctx": sessPreviewContexts, "refs": sessPreviewRefs, "pr": sessPreviewRefs, "out": sessPreviewOutputs, "outputs": sessPreviewOutputs, "live": sessPreviewLive} if m, ok := modeMap[a.config.PreviewMode]; ok { a.sessPreviewMode = m a.sessSplit.Show = true + // The persisted preview belongs to whichever view is starting; the + // other keeps its own default so a swap does not inherit a pane that + // makes no sense there. + if a.sessGroupMode == groupDaily { + a.dailyPreviewMode = m + } else { + a.browserPreviewMode = m + } } } + // Starting in the daily view with no explicit -preview: open on its own + // default (outputs) rather than the browser's conversation preview. + if a.sessGroupMode == groupDaily && a.config.PreviewMode == "" { + a.sessPreviewMode = a.dailyPreviewMode + } if a.config.ViewMode != "" { modeMap := map[string]viewState{ "sessions": viewSessions, "projects": viewSessions, "config": viewConfig, @@ -964,6 +1026,11 @@ func (a *App) Init() tea.Cmd { return sessionsScannedMsg{sessions: sessions, err: err} }) } + // Sweep dead remotes concurrently with the scan — batched commands run in + // parallel, so an unreachable host's connect timeout costs nothing on screen. + if a.hasRemoteSessions() { + cmds = append(cmds, cleanupStaleRemotesCmd()) + } return tea.Batch(cmds...) } @@ -1187,6 +1254,27 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case remotesCleanedMsg: + // A remote that no longer exists was dropped from the saved set; drop + // its virtual row too. Nothing changed is the common case — leave the + // list (and the cursor) alone. + if msg.changed { + live := make(map[string]bool) + for _, s := range loadSavedRemoteSessions() { + live[s.RemotePodName] = true + } + kept := a.sessions[:0] + for _, s := range a.sessions { + if s.IsRemote && !live[s.RemotePodName] { + continue + } + kept = append(kept, s) + } + a.sessions = kept + a.rebuildSessionList() + } + return a, nil + case sessionsScannedMsg: // Full scan complete — replace partial live sessions with full list a.sessionsLoading = false @@ -1277,6 +1365,19 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } break } + // Push the extracted list onto the list widget's snapshot copies. The + // refs themselves — not their resolved status — are what a parent row's + // rollup counts, so a date row's PR/Jira/artifact badges (and its + // Produced list) fill in from the extract alone. Without this they wait + // on refStatusMsg, which never arrives for a session whose links all + // resolve from cache, and a day with 40 PRs reports zero. + a.syncSessionRefsToList(msg.id) + if a.state == viewSessions && a.sessSplit.Show { + if di, ok := a.selectedDay(); ok { + a.sessSplit.CacheKey = "" + a.updateDayPreview(di) + } + } if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewRefs { if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { a.sessRefsCacheKey = "" @@ -1284,8 +1385,35 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(previewCmd, statusCmd) } } + // The Outputs digest lists refs alongside plans/memory/files, so it has + // the same stake in the extract landing. + if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewOutputs { + if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { + a.sessOutputsCacheKey = "" + previewCmd := a.updateSessionOutputsPreview(sess) + return a, tea.Batch(previewCmd, statusCmd) + } + } return a, statusCmd + case outputsCollectedMsg: + // A transcript scan landed. Ignore it when the user has since moved to a + // different session — the digest state tracks exactly one session, and + // adopting a stale result would show another session's outputs. + delete(a.outputsInFlight, msg.id) + if a.sessOutputsCacheID != msg.id { + return a, nil + } + a.sessOutputs = msg.outputs + a.sessOutputsCollected = msg.dataKey + a.sessOutputsCacheKey = "" + if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewOutputs { + if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { + return a, a.updateSessionOutputsPreview(sess) + } + } + return a, nil + case refStatusMsg: // One ref's status landed: merge it into the session (matched by URL) and, // if that session's refs preview is open, re-render so it fills in live. @@ -1312,12 +1440,26 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reflect the newly-resolved status onto the list row so the open-PR/Jira // badge fills in live, whether or not the References preview is open. a.syncSessionRefsToList(msg.id) + // A date row's pane is built from its children's refs, so a landing + // status adds rows to the day's "Produced" list. + if a.state == viewSessions && a.sessSplit.Show { + if di, ok := a.selectedDay(); ok { + a.sessSplit.CacheKey = "" + a.updateDayPreview(di) + } + } if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewRefs { if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { a.sessRefsCacheKey = "" // force re-render with the newly-resolved ref return a, a.updateSessionRefsPreview(sess) } } + if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewOutputs { + if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { + a.sessOutputsCacheKey = "" + return a, a.updateSessionOutputsPreview(sess) + } + } // Re-render the conversation view so the Session Refs & URLs flow row // picks up the newly-cached status (metaRefsEntries reads the cache). if a.state == viewConversation && a.conv.sess.ID == msg.id { @@ -1899,6 +2041,8 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { items = a.selectedSessions() } else if pi, ok := a.selectedProject(); ok { items = append(items, pi.sessions...) + } else if di, ok := a.selectedDay(); ok { + items = append(items, di.sessions...) } else if sess, ok := a.selectedSession(); ok { items = []session.Session{sess} } @@ -1946,10 +2090,25 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.toggleProjectFold(pi) return a, nil } + // Date rows (daily view) behave the same, but only while the list has + // focus — with the preview focused, Enter belongs to the preview's own + // cursor (e.g. opening the output under it). + if di, ok := a.sessionList.SelectedItem().(dayItem); ok && !sp.Focus { + a.toggleDayFold(di) + return a, nil + } // Remote sessions: attach interactively if sess, ok := a.selectedSession(); ok && sess.IsRemote { return a.attachToRemoteSession(sess) } + // If the day pane is focused, Enter anchors to the session that produced + // the output under the cursor. This must come before the preview-mode + // checks below: a date row's pane is the day's outputs regardless of the + // selected mode, so those would otherwise act on a stale session's rows. + if sp.Focus && sp.Show && a.selectedOwnsDayPane() && len(a.dayOutputRows) > 0 { + m, cmd, _ := a.openSelectedDayOutput() + return m, cmd + } // If conversation preview is focused, jump to the selected message if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewConversation && len(a.sessConvEntries) > 0 { return a.jumpToConvMessage() @@ -1971,6 +2130,12 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m, cmd, _ := a.openSelectedRefs() return m, cmd } + // If the Outputs digest is focused, open the output under the cursor + // (URL in the browser, otherwise jump to the producing entry). + if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewOutputs && len(a.sessOutputsRows) > 0 { + m, cmd, _ := a.openSelectedOutput() + return m, cmd + } // If the Session Context tree is focused, drill into the node under the // cursor (config / plugin explorer) instead of opening the conversation. if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewContexts && len(a.sessCtxNodes) > 0 { @@ -2002,6 +2167,16 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return a, nil } + if di, ok := a.selectedDay(); ok { + for _, s := range di.sessions { + if a.selectedSet[s.ID] { + delete(a.selectedSet, s.ID) + } else { + a.selectedSet[s.ID] = true + } + } + return a, nil + } sess, ok := a.selectedSession() if !ok { return a, nil @@ -2080,6 +2255,9 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // ref state), then force the open preview to re-read: clear the process // -wide ref TTL cache and invalidate the current mode's caches so // updateSessionPreview re-fetches instead of short-circuiting. + // The tmux window scan is memoized too — an explicit refresh is the user + // asking for current reality, so drop it as well. + tmux.InvalidateWindowClaudes() cmd := a.doRefresh() // Live/remote previews are driven by their own streams (doRefresh already // calls refreshSessionPreviewLive); re-running updateSessionPreview for @@ -2152,6 +2330,16 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + // Toggle the daily view in place. It is an axis you flip while reading — + // date-first vs project-first — so it gets a key rather than only + // `:group:daily`, and it works with the preview focused too: which grouping + // the list uses is independent of which pane has the cursor. Returning to + // the previous grouping restores whatever the user was in (persisted across + // restarts), not a hardcoded default. + if key == "D" { + return a, a.toggleDailyView() + } + // Sessions list vim-style jumps: gg = top, G = end. // Handle these before TranslateNav so user-configured navigation aliases // cannot reinterpret the first `g` as Home. @@ -2261,8 +2449,10 @@ func (a *App) skipHeaderInDirection(oldIdx, newIdx int) { if _, ok := visible[cur].(sessionItem); ok { return } - // projectItem rows are selectable (cursor can land on a project header). - if _, ok := visible[cur].(projectItem); ok { + // projectItem / dayItem rows are selectable (the cursor can land on a + // project or date header). + switch visible[cur].(type) { + case projectItem, dayItem: return } dir := 1 @@ -2272,7 +2462,7 @@ func (a *App) skipHeaderInDirection(oldIdx, newIdx int) { idx := cur + dir for idx >= 0 && idx < len(visible) { switch visible[idx].(type) { - case sessionItem, projectItem: + case sessionItem, projectItem, dayItem: a.sessionList.Select(idx) return } @@ -2282,7 +2472,7 @@ func (a *App) skipHeaderInDirection(oldIdx, newIdx int) { idx = cur - dir for idx >= 0 && idx < len(visible) { switch visible[idx].(type) { - case sessionItem, projectItem: + case sessionItem, projectItem, dayItem: a.sessionList.Select(idx) return } @@ -2315,6 +2505,14 @@ func (a *App) liveNewlineCmd() tea.Cmd { // handleFocusedPreviewKeys handles keys when the session preview pane is focused. // Returns (model, cmd, handled). If handled is false, the caller should continue processing. func (a *App) handleFocusedPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cmd, bool) { + // A date row — or a project row inside the daily tree — owns the preview + // regardless of the selected preview mode: its pane is that scope's outputs, + // not a session's. Guarded on the row alone, so a scope with nothing + // produced yet still does not fall through to a previously-previewed + // session's handler. + if a.selectedOwnsDayPane() { + return a.handleDayPreviewKeys(sp, key) + } if a.sessPreviewMode == sessPreviewConversation && len(a.sessConvEntries) > 0 { return a.handleConvPreviewKeys(sp, key) } @@ -2327,6 +2525,9 @@ func (a *App) handleFocusedPreviewKeys(sp *SplitPane, key string) (tea.Model, te if a.sessPreviewMode == sessPreviewRefs && len(a.sessPreviewRefs) > 0 { return a.handleRefsPreviewKeys(sp, key) } + if a.sessPreviewMode == sessPreviewOutputs && len(a.sessOutputsRows) > 0 { + return a.handleOutputsPreviewKeys(sp, key) + } if a.sessPreviewMode == sessPreviewContexts && len(a.sessCtxNodes) > 0 { return a.handleContextsPreviewKeys(sp, key) } @@ -2944,6 +3145,8 @@ func (a *App) handleSessPageMenu(key string) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewContexts) case "r": return a, a.setSessPreviewMode(sessPreviewRefs) + case "o": + return a, a.setSessPreviewMode(sessPreviewOutputs) case "l": if sess, ok := a.selectedSession(); ok { if sess.IsRemote { @@ -2963,7 +3166,8 @@ func (a *App) renderSessPageHintBox() string { line2 := hl.Render("m") + d.Render(":mem") + sp + hl.Render("x") + d.Render(":scratch") + sp + hl.Render("t") + d.Render(":tasks") line3 := hl.Render("a") + d.Render(":agents") + sp + hl.Render("l") + d.Render(":live") line4 := hl.Render("w") + d.Render(":workflows") + sp + hl.Render("c") + d.Render(":contexts") - body := strings.Join([]string{line1, line2, line3, line4, d.Render("esc:cancel")}, "\n") + line5 := hl.Render("r") + d.Render(":refs") + sp + hl.Render("o") + d.Render(":outputs") + body := strings.Join([]string{line1, line2, line3, line4, line5, d.Render("esc:cancel")}, "\n") boxStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(colorDim). @@ -4730,6 +4934,19 @@ func (a *App) invalidateOpenPreviewCaches() { a.sessWorkflowsCacheKey = "" case sessPreviewRefs: a.invalidateSelectedSessionRefs() + case sessPreviewOutputs: + // The digest is refs plus transcript/disk state, so both halves reset. + // Dropping the in-flight latch is the escape hatch: if a collection was + // ever armed without a command reaching the runtime, refresh is the only + // thing that can re-dispatch it. + a.invalidateSelectedSessionRefs() + if sess, ok := a.selectedSession(); ok { + delete(a.outputsInFlight, sess.ID) + } + a.sessOutputsCacheKey = "" + a.sessOutputsCacheID = "" + a.sessOutputsCollected = "" + a.sessOutputs = nil case sessPreviewAgents: // No per-session memo; the shared CacheKey reset below suffices. } @@ -4956,6 +5173,14 @@ func (a *App) refreshActivePreview() tea.Cmd { // --- Session split pane --- +// previewDispatchesCmd reports whether a preview mode's update returns a +// tea.Cmd that must be dispatched from an Update path. View() cannot dispatch +// commands, so the render path must not drive these modes — it would drop the +// extract/resolve and strand the pane on its placeholder. +func previewDispatchesCmd(mode sessPreview) bool { + return mode == sessPreviewLive || mode == sessPreviewRefs || mode == sessPreviewOutputs +} + func (a *App) renderSessionSplit() string { if a.sessionList.Width() == 0 { return "" @@ -4976,11 +5201,12 @@ func (a *App) renderSessionSplit() string { } // Don't call updateSessionPreview from the render path for modes whose - // update returns an async cmd (live, refs) — View() cannot dispatch a cmd, so - // it would be lost. Those modes are initialized and their cmds dispatched from - // Update paths (setSessPreviewMode, the navigation debounce, resizeAll). View - // only re-renders their already-populated content (the resize block below). - if a.sessPreviewMode != sessPreviewLive && a.sessPreviewMode != sessPreviewRefs { + // update returns an async cmd (live, refs, outputs) — View() cannot dispatch a + // cmd, so it would be lost. Those modes are initialized and their cmds + // dispatched from Update paths (setSessPreviewMode, the navigation debounce, + // resizeAll). View only re-renders their already-populated content (the + // resize block below). + if !previewDispatchesCmd(a.sessPreviewMode) { _ = a.updateSessionPreview() } @@ -4998,6 +5224,19 @@ func (a *App) renderSessionSplit() string { } if a.sessPreviewMode == sessPreviewConversation && len(a.sessConvEntries) > 0 && !isRemoteSetup { a.refreshConvPreview() + } else if a.sessPreviewMode == sessPreviewOutputs && !isRemoteSetup { + // Re-render the digest at the new width from already-collected rows. + // A day row keeps its own summary pane (selectedSession would hand us + // an arbitrary child), and this path must never dispatch — View + // cannot deliver a cmd, so arming a latch here would strand the pane. + if _, isDay := a.selectedDay(); !isDay { + a.sessOutputsCacheKey = "" + if sess, ok := a.selectedSession(); ok { + a.refreshOutputsPreviewLayout(sess) + } + } else { + a.sessSplit.CacheKey = "" // let the next Update redraw the day pane + } } else if a.sessPreviewMode != sessPreviewLive && !isRemoteSetup { a.sessSplit.CacheKey = "" _ = a.updateSessionPreview() @@ -5100,20 +5339,53 @@ func (a *App) updateSessionPreview() tea.Cmd { if !a.sessSplit.Show { return nil } + if di, ok := a.selectedDay(); ok { + // A date row previews the day itself — which sessions ran and what each + // produced — rather than an arbitrary child's detail. Drilling into a + // child session is what opens the per-session Outputs digest. + // The pane is the day's outputs regardless of preview mode, so the mode + // is not part of the key; the cursor and focus are, so moving the + // highlight re-renders. + cacheKey := fmt.Sprintf("day:%s:%d:%d:%t", di.dayKey, len(di.sessions), a.dayOutputsCursor, a.sessSplit.Focus) + if cacheKey == a.sessSplit.CacheKey { + return nil + } + a.sessSplit.CacheKey = cacheKey + a.sessPreviewPinned = false + a.updateDayPreview(di) + return nil + } if pi, ok := a.selectedProject(); ok { - // In refs mode a project head row previews its representative session's - // refs (selectedSession returns pi.sessions[0] for a projectItem). The - // project-summary preview has no refs, so route refs mode through the + // In the daily view a project row is the middle tier: it aggregates one + // day's work in one project, so its pane is that slice's outputs — not a + // representative session's, and not the generic project summary. + if pi.dayKey != "" { + cacheKey := fmt.Sprintf("dayproj:%s:%s:%d:%d:%t", pi.dayKey, pi.basePath, + len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus) + if cacheKey == a.sessSplit.CacheKey { + return nil + } + a.sessSplit.CacheKey = cacheKey + a.sessPreviewPinned = false + a.updateDayProjectPreview(pi) + return nil + } + // In refs/outputs mode a project head row previews its representative + // session (selectedSession returns pi.sessions[0] for a projectItem). The + // project-summary preview has neither, so route those modes through the // session path instead — otherwise the extract is never dispatched and // the preview sticks on "Resolving…" (projectCentric is the default group // mode, so this is the common case, not an edge case). - if a.sessPreviewMode == sessPreviewRefs && len(pi.sessions) > 0 { + if len(pi.sessions) > 0 && previewDispatchesCmd(a.sessPreviewMode) && a.sessPreviewMode != sessPreviewLive { cacheKey := fmt.Sprintf("%d:%s", a.sessPreviewMode, pi.sessions[0].ID) if cacheKey == a.sessSplit.CacheKey { return nil } a.sessSplit.CacheKey = cacheKey a.sessPreviewPinned = false + if a.sessPreviewMode == sessPreviewOutputs { + return a.updateSessionOutputsPreview(pi.sessions[0]) + } return a.updateSessionRefsPreview(pi.sessions[0]) } cacheKey := fmt.Sprintf("project:%d:%s", a.sessPreviewMode, pi.basePath) @@ -5171,6 +5443,8 @@ func (a *App) updateSessionPreview() tea.Cmd { a.updateSessionContextsPreview(sess) case sessPreviewRefs: return a.updateSessionRefsPreview(sess) + case sessPreviewOutputs: + return a.updateSessionOutputsPreview(sess) case sessPreviewLive: if sess.IsLive { a.sessSplit.Preview.SetContent(dimStyle.Render("(connecting…)")) @@ -5622,28 +5896,57 @@ func (a *App) handleJumpFromPicker() (tea.Model, tea.Cmd) { // Navigate to the target entry UUID if targetUUID != "" { - items := a.convList.VisibleItems() - for j, li := range items { - ci, ok := li.(convItem) - if !ok || ci.kind != convMsg { - continue - } - for idx := ci.merged.startIdx; idx <= ci.merged.endIdx && idx < len(a.conv.messages); idx++ { - if a.conv.messages[idx].UUID == targetUUID { - a.selectConvBody(j) - a.liveTail = false - a.conv.split.BottomAlign = false - a.updateConvPreview() - return a, cmd - } - } - } + a.selectConvEntryByUUID(targetUUID) } return a, cmd } return a, nil } +// selectConvEntryByUUID moves the conversation cursor to the row containing the +// entry with the given UUID and pins the preview there (no live-tail snap). +// Reports whether a matching row was found. The conversation must already be +// open — callers pair this with openConversation. +func (a *App) selectConvEntryByUUID(uuid string) bool { + if uuid == "" { + return false + } + for j, li := range a.convList.VisibleItems() { + ci, ok := li.(convItem) + if !ok || ci.kind != convMsg { + continue + } + for idx := ci.merged.startIdx; idx <= ci.merged.endIdx && idx < len(a.conv.messages); idx++ { + if a.conv.messages[idx].UUID != uuid { + continue + } + a.selectConvBody(j) + a.liveTail = false + a.conv.split.BottomAlign = false + a.updateConvPreview() + return true + } + } + return false +} + +// jumpToSessionEntry opens the given session's conversation and navigates to +// the entry that produced something (an output, a decision). When the UUID no +// longer resolves — a compacted or truncated transcript — the conversation +// still opens at its default position rather than the jump failing silently. +func (a *App) jumpToSessionEntry(sessID, uuid string) (tea.Model, tea.Cmd) { + sess, ok := a.sessionByIDFromStore(sessID) + if !ok { + return a, nil + } + a.currentSess = sess + cmd := a.openConversation(sess) + if !a.selectConvEntryByUUID(uuid) { + a.copiedMsg = "Entry not found in transcript; opened conversation" + } + return a, cmd +} + func (a *App) updateSessionStatsPreview(sess session.Session) { // Use cached stats if available for this session if a.sessStatsCacheKey != sess.ID || a.sessStatsCache == nil { @@ -6162,6 +6465,38 @@ func (a *App) syncSessionRefsToList(id string) bool { items[i] = v setListItemsPreservingFilter(&a.sessionList, items) return true + case dayItem: + // Date rows carry the same rollup: update the embedded session and + // re-sum the day's ref counts so the PR/Jira/artifact badges fill in. + found := false + for j := range v.sessions { + if v.sessions[j].ID == id { + v.sessions[j].Refs = fresh.Refs + v.sessions[j].RefsResolved = fresh.RefsResolved + found = true + } + } + if !found { + continue + } + v.openPRs, v.prs, v.jiras, v.artifacts = 0, 0, 0, 0 + for j := range v.sessions { + n, _ := v.sessions[j].OpenRefCounts() + v.openPRs += n + for _, r := range v.sessions[j].Refs { + switch r.Kind { + case session.RefPR: + v.prs++ + case session.RefJira: + v.jiras++ + case session.RefArtifact: + v.artifacts++ + } + } + } + items[i] = v + setListItemsPreservingFilter(&a.sessionList, items) + return true } } return false @@ -6179,6 +6514,24 @@ func (a *App) syncSessionRefsToList(id string) bool { // ~10ms offline line scan, and the follow-on status resolve is already capped by // resolveSem (4 concurrent). refsInFlight dedups so a row in view across many // ticks is only worked once. +// nextUnextracted returns up to n of the given sessions whose refs have not +// been extracted yet, in order. Used to spread a parent row's fan-out across +// successive passes instead of paying for all of its children at once. +func (a *App) nextUnextracted(sessions []session.Session, n int) []session.Session { + out := make([]session.Session, 0, n) + for _, s := range sessions { + if len(out) >= n { + break + } + cur, ok := a.sessionByIDFromStore(s.ID) + if !ok || !cur.HasRefs || cur.RefsResolved || len(cur.Refs) > 0 || a.refsInFlight[cur.ID] { + continue + } + out = append(out, cur) + } + return out +} + func (a *App) resolveVisibleRefsCmd() tea.Cmd { if a.state != viewSessions { return nil @@ -6189,12 +6542,35 @@ func (a *App) resolveVisibleRefsCmd() tea.Cmd { } start, end := a.sessionList.Paginator.GetSliceBounds(len(items)) var cmds []tea.Cmd + // Parent rows carry their children's refs in a rollup badge, so a folded + // day (or project) still needs its sessions extracted — otherwise the daily + // view's whole point, the per-day PR/Jira/artifact counts, stays at zero. + // + // A date row can own hundreds of sessions, though, so expanding every one of + // them turns a page of ~13 extracts into ~54 (measured on a real store) and + // costs ~250ms the moment the view opens. Instead each pass takes only the + // next slice of a parent's unextracted children: the first frame stays cheap + // and the rollup fills in over the following ticks, converging on the exact + // count rather than settling for an approximation. refsInFlight and the + // already-extracted check below make each session cost exactly one pass, so + // this walks forward and terminates. + const parentFanoutPerPass = 4 + var visible []session.Session for _, item := range items[start:end] { - si, ok := item.(sessionItem) - if !ok { - continue + switch v := item.(type) { + case sessionItem: + visible = append(visible, v.sess) + case dayItem: + visible = append(visible, a.nextUnextracted(v.sessions, parentFanoutPerPass)...) } - s, ok := a.sessionByIDFromStore(si.sess.ID) + } + seen := make(map[string]bool, len(visible)) + for _, vs := range visible { + if seen[vs.ID] { + continue // an expanded day lists its children twice + } + seen[vs.ID] = true + s, ok := a.sessionByIDFromStore(vs.ID) if !ok || !s.HasRefs || s.RefsResolved || a.refsInFlight[s.ID] || len(s.Refs) > 0 { continue } @@ -7850,7 +8226,7 @@ func (a *App) bumpPastHeader(start, dir int) { idx := start for idx >= 0 && idx < len(visible) { switch visible[idx].(type) { - case sessionItem, projectItem: + case sessionItem, projectItem, dayItem: a.sessionList.Select(idx) return } @@ -7956,10 +8332,10 @@ func (a *App) resizeAll() tea.Cmd { } func (a *App) rebuildSessionList() { - selectedID := "" - if sess, ok := a.selectedSession(); ok { - selectedID = sess.ID - } + // What the cursor was on, in terms that survive a regrouping. See + // cursorAnchor: aggregate rows carry their own identity (day key, project + // path) so the live tick minting a newer session can't move the anchor. + anchor := a.sessionListAnchor() // Preserve active filter var filterTerm string @@ -7994,15 +8370,13 @@ func (a *App) rebuildSessionList() { a.applyStartupFilter() } - // Restore cursor to previously selected session. + // Restore cursor to the previously selected row. // Use VisibleItems() because Select() operates on the visible (filtered) index space. - if selectedID != "" { - for i, item := range a.sessionList.VisibleItems() { - if si, ok := item.(sessionItem); ok && si.sess.ID == selectedID { - a.sessionList.Select(i) - return - } - } + // Select() also sets the paginator page, so the restored row is scrolled + // into view rather than merely selected off-screen. + if i := anchor.findIn(a.sessionList.VisibleItems()); i >= 0 { + a.sessionList.Select(i) + return } // Default: ensure cursor isn't parked on a header. a.bumpPastHeader(0, +1) @@ -8017,6 +8391,10 @@ func (a *App) toggleSessGroupFoldAtCursor() { a.toggleProjectFold(pi) return } + if di, ok := a.sessionList.SelectedItem().(dayItem); ok { + a.toggleDayFold(di) + return + } si, ok := a.sessionList.SelectedItem().(sessionItem) if !ok { return @@ -8084,7 +8462,14 @@ func (a *App) setAllSessGroupsFolded(folded bool) { delete(a.sessFolded, v.groupKey) } case projectItem: - key := "repo:" + v.basePath + key := projectFoldKey(v) + if folded { + a.sessFolded[key] = true + } else { + delete(a.sessFolded, key) + } + case dayItem: + key := dayFoldKey(v.dayKey) if folded { a.sessFolded[key] = true } else { @@ -8111,11 +8496,38 @@ func (a *App) toggleProjectFold(pi projectItem) { if a.sessFolded == nil { a.sessFolded = make(map[string]bool) } - key := "repo:" + pi.basePath + key := projectFoldKey(pi) + a.sessFolded[key] = !a.sessFolded[key] + a.rebuildSessionList() + for i, item := range a.sessionList.VisibleItems() { + if p, ok := item.(projectItem); ok && projectFoldKey(p) == key { + a.sessionList.Select(i) + break + } + } +} + +// projectFoldKey returns the sessFolded key for a project row. In the daily +// view the key is scoped to the date, since the same project appears under +// every day it was worked on and one shared key would fold them all at once. +func projectFoldKey(pi projectItem) string { + if pi.dayKey != "" { + return dayProjectFoldKey(pi.dayKey, pi.basePath) + } + return "repo:" + pi.basePath +} + +// toggleDayFold flips the fold state of a date row (daily view), keeping the +// cursor on that same date after the rebuild — the projectItem convention. +func (a *App) toggleDayFold(di dayItem) { + if a.sessFolded == nil { + a.sessFolded = make(map[string]bool) + } + key := dayFoldKey(di.dayKey) a.sessFolded[key] = !a.sessFolded[key] a.rebuildSessionList() for i, item := range a.sessionList.VisibleItems() { - if p, ok := item.(projectItem); ok && p.basePath == pi.basePath { + if d, ok := item.(dayItem); ok && d.dayKey == di.dayKey { a.sessionList.Select(i) break } @@ -8155,6 +8567,15 @@ func (a *App) renderBreadcrumb() string { switch a.state { case viewSessions: crumbs = []crumb{{" Projects", viewSessions}} + if a.sessGroupMode == groupDaily { + // The daily view organizes by date, not by project, so the crumb + // names the day at the cursor rather than claiming a project root. + crumbs = []crumb{{" Daily", viewSessions}} + if di, ok := a.selectedDay(); ok { + crumbs = append(crumbs, crumb{dayLabel(di.day, time.Now()), viewSessions}) + break + } + } // Show selected project name in breadcrumb if sess, ok := a.selectedSession(); ok && a.sessionList.Width() > 0 { proj := sess.ProjectName @@ -8355,10 +8776,16 @@ func (a *App) breadcrumbRightStatus() string { var parts []string // Main browser badge: always present it as PROJECTS in the UI even if - // alternate legacy grouping modes still exist internally. + // alternate legacy grouping modes still exist internally. The daily view is + // the one exception — it is a distinct organizing axis the user chose, not + // a legacy variant of the project browser. if a.state == viewSessions { modeStyle := lipgloss.NewStyle().Foreground(colorPurple).Bold(true) - parts = append(parts, modeStyle.Render("PROJECTS")) + label := "PROJECTS" + if a.sessGroupMode == groupDaily { + label = "DAILY" + } + parts = append(parts, modeStyle.Render(label)) if badge := a.stateFilterBadge(); badge != "" { filterMode := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) parts = append(parts, filterMode.Render(badge)) diff --git a/internal/tui/cmdmode.go b/internal/tui/cmdmode.go index 16da3ad..af31ede 100644 --- a/internal/tui/cmdmode.go +++ b/internal/tui/cmdmode.go @@ -69,6 +69,12 @@ func buildCmdRegistry() []cmdEntry { a.rebuildSessionList() return a, nil }}, + {name: "group:daily", aliases: []string{"g:daily", "daily", "g:day"}, desc: "daily activity view", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { + a.sessGroupMode = groupDaily + a.rebuildSessionList() + return a, nil + }}, // Conversation detail levels {name: "detail:compact", aliases: []string{"d:compact"}, desc: "text only", views: cmdConv, @@ -101,6 +107,8 @@ func buildCmdRegistry() []cmdEntry { action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewShells) }}, {name: "preview:refs", aliases: []string{"p:refs", "refs", "preview:pr", "p:pr"}, desc: "PR/Jira references preview", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewRefs) }}, + {name: "preview:outputs", aliases: []string{"p:outputs", "p:out", "outputs"}, desc: "outputs digest (plans, memory, files, refs)", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewOutputs) }}, {name: "preview:live", aliases: []string{"p:live"}, desc: "live preview", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { sess, ok := a.selectedSession() @@ -543,8 +551,8 @@ func (a *App) updateCmdSuggestions() { switch a.state { case viewSessions: a.cmdSuggestions = append(a.cmdSuggestions, - cmdEntry{name: "group:", desc: "flat proj tree chain fork repo"}, - cmdEntry{name: "preview:", desc: "conv stats mem scratch tasks live"}, + cmdEntry{name: "group:", desc: "flat proj tree chain fork repo projects daily"}, + cmdEntry{name: "preview:", desc: "conv stats mem scratch tasks outputs refs live"}, cmdEntry{name: "share:ref", desc: "share @path to another live session"}, cmdEntry{name: "set:ratio", desc: "N (15-85)"}, cmdEntry{name: "refresh", desc: "reload sessions"}) diff --git a/internal/tui/daily.go b/internal/tui/daily.go new file mode 100644 index 0000000..9f59dfe --- /dev/null +++ b/internal/tui/daily.go @@ -0,0 +1,583 @@ +package tui + +import ( + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sendbird/ccx/internal/session" +) + +// dayItem is a date row in the daily view: a folder-like parent whose children +// are the sessions active on that day. Like projectItem it is not a session — +// Enter toggles its fold state rather than opening anything. +type dayItem struct { + dayKey string // "2006-01-02", the stable fold/identity key + day time.Time + sessions []session.Session // that day's sessions, most-recent-first + expanded bool + + totalMsgs int + projects int // distinct project roots touched that day + liveCount int + openPRs int + // Output rollup — what the day actually produced. + prs int + jiras int + artifacts int + plans int +} + +func (d dayItem) FilterValue() string { + // Mirror projectItem: a date row stays visible whenever any session under + // it matches, so filtering inside the daily view keeps its date headers. + parts := make([]string, 0, len(d.sessions)+3) + parts = append(parts, d.dayKey, d.day.Format("Mon Jan 2 2006"), "is:day") + for _, s := range d.sessions { + parts = append(parts, session.FilterValueFor(s, nil)) + } + return strings.Join(parts, " ") +} + +// dayFoldKey is the sessFolded key for a date row. +func dayFoldKey(dayKey string) string { return "day:" + dayKey } + +// buildDailyItems groups sessions by the calendar day of their last activity +// (ModTime, local time), newest day first. A session that spanned several days +// appears once, under the day it was last active — duplicating a row across +// days would break multi-select (keyed by session ID) and fold bookkeeping. +func buildDailyItems(sessions []session.Session, folded map[string]bool) []list.Item { + groups := make(map[string]*dayItem) + for i := range sessions { + s := sessions[i] + y, m, d := s.ModTime.Date() + day := time.Date(y, m, d, 0, 0, 0, 0, s.ModTime.Location()) + key := day.Format("2006-01-02") + g, ok := groups[key] + if !ok { + g = &dayItem{dayKey: key, day: day} + groups[key] = g + } + g.sessions = append(g.sessions, s) + } + + days := make([]*dayItem, 0, len(groups)) + for _, g := range groups { + sort.Slice(g.sessions, func(i, j int) bool { + return g.sessions[i].ModTime.After(g.sessions[j].ModTime) + }) + projects := make(map[string]bool, len(g.sessions)) + for _, s := range g.sessions { + g.totalMsgs += s.MsgCount + projects[s.ProjectPath] = true + if s.IsLive { + g.liveCount++ + } + openPRs, _ := s.OpenRefCounts() + g.openPRs += openPRs + for _, r := range s.Refs { + switch r.Kind { + case session.RefPR: + g.prs++ + case session.RefJira: + g.jiras++ + case session.RefArtifact: + g.artifacts++ + } + } + g.plans += len(s.PlanSlugs) + } + g.projects = len(projects) + g.expanded = !folded[dayFoldKey(g.dayKey)] + days = append(days, g) + } + sort.Slice(days, func(i, j int) bool { return days[i].day.After(days[j].day) }) + + items := make([]list.Item, 0, len(days)*4) + for _, g := range days { + items = append(items, *g) + if !g.expanded { + continue + } + items = append(items, dayProjectItems(g, folded)...) + } + return items +} + +// dayProjectRows groups one day's sessions by project, most-active-first. The +// middle tier of the day → project → session tree: a heavy day spans 30 +// projects, and reading it as one flat run of sessions loses the thing you +// actually want to know — which work areas the day went into. +func dayProjectRows(g *dayItem) []projectItem { + byPath := map[string]*projectItem{} + var order []string + for _, s := range g.sessions { + key := s.ProjectPath + p, ok := byPath[key] + if !ok { + name := s.ProjectName + if name == "" { + name = filepathBase(s.ProjectPath) + } + p = &projectItem{basePath: key, displayName: name, branch: s.GitBranch} + byPath[key] = p + order = append(order, key) + } + p.sessions = append(p.sessions, s) + } + + rows := make([]projectItem, 0, len(order)) + for _, key := range order { + p := byPath[key] + sort.SliceStable(p.sessions, func(i, j int) bool { + return p.sessions[i].ModTime.After(p.sessions[j].ModTime) + }) + // Aggregate exactly what a project row needs to stand on its own: the + // lifecycle counts its badges render, plus the day-scoped output rollup. + for _, s := range p.sessions { + p.totalMsgs += s.MsgCount + if s.ModTime.After(p.bestTime) { + p.bestTime = s.ModTime + } + if s.IsWorktree { + p.worktrees++ + } + if s.IsLive { + p.liveSessions++ + } + if s.IsCurrentWindow { + p.hereCount++ + } + if s.IsLive && s.ActiveMonitorCount() > 0 { + p.monSessions++ + } + if s.IsLive && s.AwaitingInput { + p.inputSessions++ + } + if openPRs, _ := s.OpenRefCounts(); openPRs > 0 { + p.openPRs += openPRs + } + switch s.Lifecycle() { + case session.LifecycleBusy: + p.busyCount++ + case session.LifecycleBG: + p.bgSessions++ + case session.LifecycleStuck: + p.stuckCount++ + case session.LifecycleWait: + p.waitCount++ + case session.LifecycleDone: + p.doneCount++ + } + } + switch { + case p.busyCount > 0: + p.lifecycle = session.LifecycleBusy + case p.bgSessions > 0: + p.lifecycle = session.LifecycleBG + case p.stuckCount > 0: + p.lifecycle = session.LifecycleStuck + case p.waitCount > 0: + p.lifecycle = session.LifecycleWait + case p.doneCount > 0: + p.lifecycle = session.LifecycleDone + default: + p.lifecycle = session.LifecycleNone + } + rows = append(rows, *p) + } + // Most-recent project first, matching how the days themselves are ordered. + sort.SliceStable(rows, func(i, j int) bool { return rows[i].bestTime.After(rows[j].bestTime) }) + return rows +} + +// dayProjectItems renders one day's project tier plus the sessions under each +// expanded project. +func dayProjectItems(g *dayItem, folded map[string]bool) []list.Item { + projects := dayProjectRows(g) + items := make([]list.Item, 0, len(projects)*2) + for pi, p := range projects { + // Fold keys are day-scoped: the same project appears under many days, + // and one shared key would fold it everywhere at once. + key := dayProjectFoldKey(g.dayKey, p.basePath) + p.expanded = !folded[key] + p.dayKey = g.dayKey + p.treeDepth = 1 + p.treeLast = pi == len(projects)-1 + items = append(items, p) + if !p.expanded { + continue + } + for ci, ch := range p.sessions { + items = append(items, sessionItem{ + sess: ch, + treeDepth: 2, + treeLast: ci == len(p.sessions)-1, + }) + } + } + return items +} + +// dayProjectFoldKey is the sessFolded key for a project row inside a day. +func dayProjectFoldKey(dayKey, basePath string) string { + return "day:" + dayKey + "|repo:" + basePath +} + +// dayLabel renders a date row's headline: "Today", "Yesterday", or a weekday + +// date. Relative labels are computed against now so the top of the list reads +// as a journal rather than as a table of ISO strings. +func dayLabel(day, now time.Time) string { + y, m, d := now.Date() + today := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) + switch { + case day.Equal(today): + return "Today" + case day.Equal(today.AddDate(0, 0, -1)): + return "Yesterday" + default: + return day.Format("Mon Jan 2") + } +} + +// dayCacheKey builds the row-cache key for a date row. The rendered relative +// label ("Today"/"Yesterday") is part of the key, not just the date: a session +// left open across midnight would otherwise keep serving a cached "Today" for +// what is now yesterday. +func (d sessionDelegate) dayCacheKey(m list.Model, index int, di dayItem, selected bool) string { + return fmt.Sprintf("d|%d|%d|%t|%s|%t|%s|%s|%d|%d|%d|%d|%d|%d|%d|%d", + m.Width(), index, selected, listFilterTerm(m), di.expanded, di.dayKey, + dayLabel(di.day, time.Now()), + len(di.sessions), di.totalMsgs, di.projects, di.liveCount, + di.prs, di.jiras, di.artifacts, di.plans) +} + +// renderDay draws a date row: calendar-style folder + relative day label + +// what that day produced. Always 2 rows tall so cursor math stays consistent +// with sessionItem and projectItem rendering. +func (d sessionDelegate) renderDay(w io.Writer, m list.Model, index int, di dayItem) { + selected := index == m.Index() + cacheKey := d.dayCacheKey(m, index, di, selected) + if cached, ok := d.rowCache.Get(cacheKey); ok { + fmt.Fprint(w, cached) + return + } + width := m.Width() + clamp := lipgloss.NewStyle().MaxWidth(width) + + cursor := " " + if selected { + cursor = "> " + } + folderIcon := iconFolder + if di.expanded { + folderIcon = iconFolderOpen + } + + nameStyle := lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) + metaStyle := dimStyle + if selected { + nameStyle = nameStyle.Foreground(colorPurple) + metaStyle = lipgloss.NewStyle().Foreground(colorHelp) + } + + dot := " " + if di.liveCount > 0 { + dot = liveDotStyle.Render(iconStatusDot) + " " + } + + label := nameStyle.Render(dayLabel(di.day, time.Now())) + // The ISO date disambiguates "Today"/"Yesterday" and dates older than a + // week that render as "Mon Jan 2" without a year. It is the first thing to + // go under width pressure — the label already names the day. + head := fmt.Sprintf("%s%s%s %s", cursor, dot, dimStyle.Render(folderIcon), label) + if iso := metaStyle.Render(" " + di.dayKey); lipgloss.Width(head)+lipgloss.Width(iso) <= width-12 { + head += iso + } + budget := width - lipgloss.Width(head) - 2 + + badges := "" + badgesW := 0 + // Output rollup badges — the point of the daily view is what came out of + // the day, so PR/Jira/artifact/plan counts lead over session mechanics. + // They are appended most-important-first and dropped wholesale once the row + // runs out of width: a half-rendered badge reads as data corruption, and a + // mid-word truncation of "[PLAN×2]" is worse than showing nothing. + appendIfFits := func(style lipgloss.Style, text string) { + if badgesW+lipgloss.Width(text)+1 > budget { + return + } + badges = appendBadge(badges, &badgesW, style, text) + } + if di.prs > 0 && !d.hiddenBadges["PR"] { + appendIfFits(prBadgeStyle, badgeLabel(iconBadgePR, fmt.Sprintf("PR×%d", di.prs))) + } + if di.jiras > 0 { + appendIfFits(jiraBadgeStyle, fmt.Sprintf("[JIRA×%d]", di.jiras)) + } + if di.artifacts > 0 { + appendIfFits(artifactBadgeStyle, fmt.Sprintf("[ART×%d]", di.artifacts)) + } + if di.plans > 0 { + appendIfFits(planBadge, fmt.Sprintf("[PLAN×%d]", di.plans)) + } + + line1 := head + " " + badges + + summary := " " + plural(len(di.sessions), "session") + if di.projects > 0 { + summary += ", " + plural(di.projects, "project") + } + if di.totalMsgs > 0 { + summary += fmt.Sprintf(", %dm", di.totalMsgs) + } + line2 := " " + metaStyle.Render(summary) + + if selected { + line1 = padSelectedRow(line1, width) + line2 = padSelectedRow(line2, width) + } + _ = badgesW // date rows use loose columns, same as project rows + + rendered := fmt.Sprintf("%s\n%s", clamp.Render(line1), clamp.Render(line2)) + d.rowCache.Set(cacheKey, rendered) + fmt.Fprint(w, rendered) +} + +// padSelectedRow pads a row to the full list width and applies the selected-row +// background so the highlight spans the pane. +func padSelectedRow(line string, width int) string { + if bare := lipgloss.Width(line); bare < width { + line += strings.Repeat(" ", width-bare) + } + return selectedRowStyle.Render(line) +} + +// plural renders "1 session" / "2 sessions" for the day summary's counts. +func plural(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("1 %s", noun) + } + return fmt.Sprintf("%d %ss", n, noun) +} + +// toggleDailyView flips the browser between the daily view and whatever +// grouping the user was in before. Being able to ask "what came out today?" +// mid-read — and get back to where you were — is the point; routing that +// through the command palette would make it a mode switch instead of a glance. +// +// The two views keep separate preview modes: the daily view is about results, +// the project browser about sessions, and forcing one preview choice on both +// means every swap lands on the wrong pane. What IS shared is the cursor — +// rebuildSessionList re-anchors it on the same session, so flipping the axis +// re-sorts what is on screen instead of jumping somewhere else. +func (a *App) toggleDailyView() tea.Cmd { + // Captured before the mode flips: the anchor is read off the row the cursor + // is on right now, in the grouping it currently lives in. + anchor := a.sessionListAnchor() + if a.sessGroupMode == groupDaily { + a.dailyPreviewMode = a.sessPreviewMode + a.sessGroupMode = a.preDailyGroupMode + a.sessPreviewMode = a.browserPreviewMode + a.copiedMsg = "Grouping: " + groupModeString(a.sessGroupMode) + } else { + a.browserPreviewMode = a.sessPreviewMode + a.preDailyGroupMode = a.sessGroupMode + a.sessGroupMode = groupDaily + a.sessPreviewMode = a.dailyPreviewMode + a.copiedMsg = "Daily view" + } + a.revealAnchor(anchor, a.sessGroupMode) + a.closePaneProxy() + a.rebuildSessionList() + a.sessSplit.CacheKey = "" + return a.updateSessionPreview() +} + +// cursorAnchor is what the cursor was sitting on, expressed in terms that +// survive a regrouping: an aggregate row's own identity (date, project path) +// plus the session under the cursor as a fallback. +// +// Aggregate rows are anchored on their identity rather than on their +// representative session because the rep is the group's newest session and the +// live tick keeps minting newer ones — matching on it would lose the row every +// few seconds on an active day. +type cursorAnchor struct { + sessionID string + // isParentRow: the cursor was on a day/project row, not a session. The + // restore prefers landing on the same *kind* of row: dropping a project-head + // cursor onto one of that project's children reads as "it lost my place" + // even when the session matches. + isParentRow bool + isDayRow bool + projectPath string + dayKey string +} + +func (c cursorAnchor) empty() bool { return c.sessionID == "" && !c.isParentRow } + +// sessionListAnchor captures the cursor's row identity from the live list. +func (a *App) sessionListAnchor() cursorAnchor { + var c cursorAnchor + if sess, ok := a.selectedSession(); ok { + c.sessionID = sess.ID + } + switch v := a.sessionList.SelectedItem().(type) { + case projectItem: + c.isParentRow, c.projectPath, c.dayKey = true, v.basePath, v.dayKey + case dayItem: + c.isParentRow, c.isDayRow, c.dayKey = true, true, v.dayKey + } + return c +} + +// findIn returns the index of the anchor's row in items, or -1. Both the +// post-rebuild restore and the pre-rebuild auto-expand run through this so the +// row we unfold for and the row the cursor lands on cannot drift apart. +func (c cursorAnchor) findIn(items []list.Item) int { + if c.isParentRow { + for i, item := range items { + switch v := item.(type) { + case dayItem: + if c.isDayRow && v.dayKey == c.dayKey { + return i + } + case projectItem: + // Same project, whichever grouping it now lives in. Matching on + // the path (not the representative session) keeps the cursor on + // the project even when the two views pick different reps — + // the browser's rep spans all days, the daily one just today's. + // A blank dayKey on either side means one of the two rows is a + // flat browser row, where a project appears exactly once and the + // path is the whole identity. + if !c.isDayRow && v.basePath == c.projectPath && + (c.dayKey == "" || v.dayKey == "" || v.dayKey == c.dayKey) { + return i + } + } + } + } + if c.sessionID == "" { + return -1 + } + fallback := -1 + for i, item := range items { + switch v := item.(type) { + case projectItem: + if c.isParentRow && len(v.sessions) > 0 && v.sessions[0].ID == c.sessionID { + return i + } + case dayItem: + if c.isParentRow && len(v.sessions) > 0 && v.sessions[0].ID == c.sessionID { + return i + } + case sessionItem: + if v.sess.ID == c.sessionID { + if !c.isParentRow { + return i + } + if fallback < 0 { + fallback = i + } + } + } + } + return fallback +} + +// rowDepth is a row's nesting level in the built item list: 0 for a top-level +// row, 1 for its children, 2 for a session under a day's project. -1 marks a +// row that is not part of the tree (a section header). +func rowDepth(item list.Item) int { + switch v := item.(type) { + case dayItem: + return 0 + case projectItem: + return v.treeDepth + case sessionItem: + return v.treeDepth + } + return -1 +} + +// rowFoldKey is the sessFolded key that hides a row's children, or "" when the +// row folds nothing (a session with no group, a project row in a flat mode). +func rowFoldKey(item list.Item) string { + switch v := item.(type) { + case dayItem: + return dayFoldKey(v.dayKey) + case projectItem: + return projectFoldKey(v) + case sessionItem: + return v.groupKey + } + return "" +} + +// revealAnchor unfolds exactly the ancestors that would hide the anchor's row +// in destMode, so a regrouping cannot strand the cursor. +// +// Each grouping nests differently and keys its folds differently (day: → +// day:|repo: in the daily view, repo: in the project browser, proj:/team:/ +// chain:/fork: elsewhere), so rather than re-deriving those key shapes by hand +// per mode, build the destination once fully expanded, locate the target row, +// and walk back up its actual parents. Whatever the builders nest, the walk +// follows — a new grouping mode gets this for free. +// +// Only the anchor's own ancestor chain is cleared. Expanding everything would +// be the easy fix and the wrong one: folding is how a 250-session day stays +// readable, and the fold map is persisted (capturePreferences → +// folded_groups), so a blanket expand would follow the user into the next +// launch. Clearing the ancestors is likewise persisted, but that matches what +// is on screen after the swap — re-folding them behind the cursor would hide +// the row we just went to the trouble of revealing. +// +// Deliberately called from the view swap rather than from rebuildSessionList: +// the live tick rebuilds every few seconds, and `f` (fold all) rebuilds right +// after collapsing everything — auto-expanding there would undo the user's +// fold the instant they pressed the key. +func (a *App) revealAnchor(anchor cursorAnchor, destMode int) { + if len(a.sessFolded) == 0 || anchor.empty() { + return + } + // nil fold map: the destination as it would look with nothing collapsed, so + // rows hidden by the user's folds are still there to be found. + expanded := buildGroupedItems(a.sessions, destMode, nil, a.config.WorktreeDir) + idx := anchor.findIn(expanded) + if idx < 0 { + return + } + depth := rowDepth(expanded[idx]) + for i := idx - 1; i >= 0 && depth > 0; i-- { + d := rowDepth(expanded[i]) + if d < 0 { + // A section header — every ancestor of this row lies below it. + break + } + if d >= depth { + continue + } + depth = d + if key := rowFoldKey(expanded[i]); key != "" { + delete(a.sessFolded, key) + } + } +} + +// selectedOwnsDayPane reports whether the row under the cursor renders the +// day-outputs pane: a date row, or a project row nested inside one. Both +// aggregate a scope rather than being a session, so both drive that pane. +func (a *App) selectedOwnsDayPane() bool { + switch v := a.sessionList.SelectedItem().(type) { + case dayItem: + return true + case projectItem: + return v.dayKey != "" + } + return false +} diff --git a/internal/tui/daily_test.go b/internal/tui/daily_test.go new file mode 100644 index 0000000..d54b487 --- /dev/null +++ b/internal/tui/daily_test.go @@ -0,0 +1,1379 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +// dayOf builds a timestamp at 12:00 on the given offset from today, so tests +// never straddle a midnight boundary while running. +func dayOf(daysAgo int) time.Time { + now := time.Now() + y, m, d := now.AddDate(0, 0, -daysAgo).Date() + return time.Date(y, m, d, 12, 0, 0, 0, now.Location()) +} + +func TestBuildDailyItemsGroupsByLastActivityDay(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), MsgCount: 5}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-2 * time.Hour), MsgCount: 3}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(1), MsgCount: 7}, + } + items := buildDailyItems(sessions, nil) + + var days []dayItem + for _, it := range items { + if di, ok := it.(dayItem); ok { + days = append(days, di) + } + } + if len(days) != 2 { + t.Fatalf("expected 2 day rows, got %d", len(days)) + } + // Newest day first. + if !days[0].day.After(days[1].day) { + t.Fatalf("expected days newest-first, got %s then %s", days[0].dayKey, days[1].dayKey) + } + if len(days[0].sessions) != 2 { + t.Fatalf("expected today to hold 2 sessions, got %d", len(days[0].sessions)) + } + if days[0].projects != 2 { + t.Fatalf("expected today to span 2 projects, got %d", days[0].projects) + } + if days[0].totalMsgs != 8 { + t.Fatalf("expected today to total 8 messages, got %d", days[0].totalMsgs) + } + // Within a day, most-recent-first. + if days[0].sessions[0].ID != "a1" { + t.Fatalf("expected a1 first within the day, got %s", days[0].sessions[0].ID) + } +} + +func TestBuildDailyItemsEmitsEachSessionOnce(t *testing.T) { + // A session in the current tmux window must NOT also appear under a + // duplicated "Current Window" date row — two rows would share one fold key. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), IsCurrentWindow: true}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0).Add(-time.Hour)}, + } + items := buildGroupedItems(sessions, groupDaily, nil) + + seen := map[string]int{} + dayRows := map[string]int{} + for _, it := range items { + switch v := it.(type) { + case sessionItem: + seen[v.sess.ID]++ + case dayItem: + dayRows[v.dayKey]++ + case headerItem: + t.Fatalf("daily view should not emit section headers, got %q", v.label) + } + } + for id, n := range seen { + if n != 1 { + t.Fatalf("session %s appeared %d times, want 1", id, n) + } + } + for key, n := range dayRows { + if n != 1 { + t.Fatalf("day %s appeared %d times, want 1", key, n) + } + } +} + +func TestBuildDailyItemsRespectsFold(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0)}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0).Add(-time.Hour)}, + } + key := dayOf(0).Format("2006-01-02") + items := buildDailyItems(sessions, map[string]bool{dayFoldKey(key): true}) + + if len(items) != 1 { + t.Fatalf("expected only the folded day row, got %d items", len(items)) + } + di, ok := items[0].(dayItem) + if !ok { + t.Fatalf("expected a dayItem, got %T", items[0]) + } + if di.expanded { + t.Fatal("expected the day row to render as folded") + } +} + +func TestDailyDayRowRollsUpOutputs(t *testing.T) { + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), + PlanSlugs: []string{"some-plan"}, + Refs: []session.SessionRef{ + {Kind: session.RefPR, Label: "sendbird/ccx#1", URL: "https://github.com/sendbird/ccx/pull/1", State: session.RefStateOpen, Resolved: true}, + {Kind: session.RefJira, Label: "CPLAT-1", URL: "https://sendbird.atlassian.net/browse/CPLAT-1", Resolved: true}, + {Kind: session.RefArtifact, Label: "artifact:abcd1234", URL: "https://claude.ai/code/artifact/abcd1234", Resolved: true}, + }, + }} + items := buildDailyItems(sessions, nil) + di, ok := items[0].(dayItem) + if !ok { + t.Fatalf("expected a dayItem, got %T", items[0]) + } + if di.prs != 1 || di.jiras != 1 || di.artifacts != 1 || di.plans != 1 { + t.Fatalf("unexpected rollup: prs=%d jiras=%d artifacts=%d plans=%d", + di.prs, di.jiras, di.artifacts, di.plans) + } +} + +func TestDayLabelUsesRelativeNames(t *testing.T) { + now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.Local) + today := time.Date(2026, 8, 13, 0, 0, 0, 0, time.Local) + if got := dayLabel(today, now); got != "Today" { + t.Fatalf("dayLabel(today) = %q, want Today", got) + } + if got := dayLabel(today.AddDate(0, 0, -1), now); got != "Yesterday" { + t.Fatalf("dayLabel(yesterday) = %q, want Yesterday", got) + } + older := today.AddDate(0, 0, -5) + if got, want := dayLabel(older, now), older.Format("Mon Jan 2"); got != want { + t.Fatalf("dayLabel(-5d) = %q, want %q", got, want) + } +} + +func TestDailyPreviewShowsDaySummary(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), MsgCount: 5, FirstPrompt: "add the daily view", + Refs: []session.SessionRef{{Kind: session.RefPR, Label: "sendbird/ccx#7", URL: "https://github.com/sendbird/ccx/pull/7", State: session.RefStateOpen, Resolved: true}}}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-time.Hour), MsgCount: 2}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + + items := app.sessionList.VisibleItems() + if len(items) == 0 { + t.Fatal("expected visible items") + } + if _, ok := items[0].(dayItem); !ok { + t.Fatalf("expected first row to be a dayItem, got %T", items[0]) + } + app.sessionList.Select(0) + app.sessSplit.Show = true + if cmd := app.updateSessionPreview(); cmd != nil { + t.Fatal("expected the day preview to be synchronous (no transcript reads)") + } + content := app.sessSplit.Preview.View() + // The pane is the day's outputs with a session anchor on each — not a + // session listing, and no project breakdown either (the list itself nests + // day → project → session, so repeating it here would say it twice). + for _, want := range []string{"Today", "Produced (1)", "sendbird/ccx#7", "a1 · repo-a"} { + if !strings.Contains(content, want) { + t.Fatalf("expected day preview to contain %q, got:\n%s", want, content) + } + } + for _, unwanted := range []string{"add the daily view", "Where the time went"} { + if strings.Contains(content, unwanted) { + t.Fatalf("expected the day preview to omit %q, got:\n%s", unwanted, content) + } + } +} + +func TestDailyEnterTogglesDayFold(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0)}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0).Add(-time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.sessionList.Select(0) + + before := len(app.sessionList.VisibleItems()) + di, ok := app.selectedDay() + if !ok { + t.Fatal("expected the cursor to be on a day row") + } + app.toggleDayFold(di) + if got := len(app.sessionList.VisibleItems()); got >= before { + t.Fatalf("expected folding to hide children: before=%d after=%d", before, got) + } + if _, ok := app.selectedDay(); !ok { + t.Fatal("expected the cursor to stay on the day row after folding") + } + app.toggleDayFold(di) + if got := len(app.sessionList.VisibleItems()); got != before { + t.Fatalf("expected unfolding to restore children: want %d, got %d", before, got) + } +} + +func TestDailyFilterKeepsDayRowForMatchingChild(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.setSessionListFilter("repo-a") + + sawDay, sawMatch, sawOther := false, false, false + for _, it := range app.sessionList.VisibleItems() { + switch v := it.(type) { + case dayItem: + sawDay = true + case sessionItem: + if v.sess.ID == "a1" { + sawMatch = true + } + if v.sess.ID == "b1" { + sawOther = true + } + } + } + if !sawDay { + t.Fatal("expected the day row to stay visible for its matching child") + } + if !sawMatch { + t.Fatal("expected the matching session to stay visible") + } + if sawOther { + t.Fatal("expected the non-matching session to be filtered out") + } +} + +// --- Outputs digest ------------------------------------------------------- + +// writeTranscript writes a minimal JSONL transcript with the given raw lines +// and returns its path. +func writeTranscript(t *testing.T, lines ...string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sess.jsonl") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatalf("write transcript: %v", err) + } + return path +} + +func toolUseLine(uuid, ts, tool, input string) string { + return `{"type":"assistant","uuid":"` + uuid + `","timestamp":"` + ts + `","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"` + tool + `","input":` + input + `}]}}` +} + +func TestCollectSessionOutputsSeparatesMemoryPlansAndChanges(t *testing.T) { + home := t.TempDir() + path := writeTranscript(t, + toolUseLine("u1", "2026-08-13T01:00:00Z", "Write", `{"file_path":"/repo/internal/tui/daily.go","content":"x"}`), + toolUseLine("u2", "2026-08-13T02:00:00Z", "Edit", `{"file_path":"/repo/internal/tui/daily.go","old_string":"a","new_string":"b"}`), + toolUseLine("u3", "2026-08-13T03:00:00Z", "Write", `{"file_path":"`+home+`/.claude/projects/p/memory/note.md","content":"y"}`), + toolUseLine("u4", "2026-08-13T04:00:00Z", "ExitPlanMode", `{"planFilePath":"`+home+`/.claude/plans/daily-view.md","plan":"do it"}`), + toolUseLine("u5", "2026-08-13T05:00:00Z", "Read", `{"file_path":"/repo/README.md"}`), + ) + outs := session.CollectSessionOutputs(session.Session{ID: "s1", FilePath: path}, home) + + byKind := map[session.OutputKind][]session.SessionOutput{} + for _, o := range outs { + byKind[o.Kind] = append(byKind[o.Kind], o) + } + if n := len(byKind[session.OutputChange]); n != 1 { + t.Fatalf("expected 1 changed file (Read must not count), got %d", n) + } + change := byKind[session.OutputChange][0] + if change.Title != "daily.go" { + t.Fatalf("expected the change titled daily.go, got %q", change.Title) + } + if change.Count != 2 { + t.Fatalf("expected 2 write occurrences collapsed into one row, got %d", change.Count) + } + if change.MessageUUID != "u1" { + t.Fatalf("expected the jump target to be the FIRST write (u1), got %q", change.MessageUUID) + } + if n := len(byKind[session.OutputMemory]); n != 1 { + t.Fatalf("expected 1 memory note, got %d", n) + } + if n := len(byKind[session.OutputPlan]); n != 1 { + t.Fatalf("expected 1 plan, got %d", n) + } + if got := byKind[session.OutputPlan][0].Title; got != "daily-view" { + t.Fatalf("expected the plan slug daily-view, got %q", got) + } +} + +func TestSortOutputsPutsResultsBeforeWorkingMaterial(t *testing.T) { + outs := []session.SessionOutput{ + {Kind: session.OutputScratchpad, Title: "scratch.txt"}, + {Kind: session.OutputChange, Title: "app.go"}, + {Kind: session.OutputPlan, Title: "plan"}, + {Kind: session.OutputPR, Title: "sendbird/ccx#1"}, + } + session.SortOutputs(outs) + want := []session.OutputKind{session.OutputPR, session.OutputPlan, session.OutputChange, session.OutputScratchpad} + for i, k := range want { + if outs[i].Kind != k { + t.Fatalf("position %d: got %s, want %s", i, outs[i].Kind, k) + } + } +} + +func TestOutputsPreviewRendersSectionsAndRefs(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + path := writeTranscript(t, + toolUseLine("u1", "2026-08-13T01:00:00Z", "Write", `{"file_path":"/repo/internal/tui/daily.go","content":"x"}`), + ) + sessions := []session.Session{{ + ID: "s1", ShortID: "s1", FilePath: path, ProjectPath: "/repo", ProjectName: "repo", + ModTime: dayOf(0), RefsResolved: true, HasRefs: true, + Refs: []session.SessionRef{ + {Kind: session.RefPR, Label: "sendbird/ccx#9", URL: "https://github.com/sendbird/ccx/pull/9", State: session.RefStateOpen, Resolved: true}, + }, + }} + app := newTestApp(sessions) + app.sessGroupMode = groupFlat + app.rebuildSessionList() + app.sessionList.Select(0) + app.sessSplit.Show = true + app.sessPreviewMode = sessPreviewOutputs + + // The transcript scan is async: the first update dispatches it, and the + // digest only fills in once outputsCollectedMsg lands. tea.Batch collapses + // to the single command when there is only one, so handle both shapes. + cmd := app.updateSessionOutputsPreview(sessions[0]) + if cmd == nil { + t.Fatal("expected an async collection command") + } + msgs := []tea.Msg{cmd()} + if batch, ok := msgs[0].(tea.BatchMsg); ok { + msgs = nil + for _, c := range batch { + msgs = append(msgs, c()) + } + } + delivered := false + for _, msg := range msgs { + if collected, ok := msg.(outputsCollectedMsg); ok { + m, _ := app.Update(collected) + app = m.(*App) + delivered = true + } + } + if !delivered { + t.Fatalf("expected an outputsCollectedMsg, got %#v", msgs) + } + + content := app.sessSplit.Preview.View() + for _, want := range []string{"Outputs", "Pull Requests", "sendbird/ccx#9", "Files Changed", "daily.go"} { + if !strings.Contains(content, want) { + t.Fatalf("expected the outputs digest to contain %q, got:\n%s", want, content) + } + } +} + +func TestOutputsPreviewIgnoresStaleCollection(t *testing.T) { + // A collection that lands after the user moved to another session must not + // overwrite the digest — it belongs to a session no longer on screen. + app := newTestApp(nil) + app.sessPreviewMode = sessPreviewOutputs + app.sessOutputsCacheID = "current" + app.sessOutputs = []session.SessionOutput{{Kind: session.OutputChange, Title: "kept.go"}} + app.sessOutputsCollected = "current:1" + + m, _ := app.Update(outputsCollectedMsg{ + id: "other", + dataKey: "other:1", + outputs: []session.SessionOutput{{Kind: session.OutputChange, Title: "stale.go"}}, + }) + app = m.(*App) + + if len(app.sessOutputs) != 1 || app.sessOutputs[0].Title != "kept.go" { + t.Fatalf("expected the stale collection to be ignored, got %+v", app.sessOutputs) + } + if app.outputsInFlight["other"] { + t.Fatal("expected the in-flight latch to clear even for a stale result") + } +} + +func TestOutputsPreviewKeysIgnoreUnrelatedKeys(t *testing.T) { + // Adding the digest's keys must not swallow navigation keys it does not + // own — the caller still needs to see them as unhandled. + app := newTestApp(nil) + app.sessPreviewMode = sessPreviewOutputs + app.sessOutputsRows = []session.SessionOutput{ + {Kind: session.OutputChange, Title: "a.go", Path: "/repo/a.go", MessageUUID: "u1"}, + {Kind: session.OutputChange, Title: "b.go", Path: "/repo/b.go", MessageUUID: "u2"}, + } + sp := &app.sessSplit + + for _, key := range []string{"x", "R", "tab", "esc"} { + if _, _, handled := app.handleOutputsPreviewKeys(sp, key); handled { + t.Fatalf("expected the outputs digest to leave %q unhandled", key) + } + } + if _, _, handled := app.handleOutputsPreviewKeys(sp, "down"); !handled { + t.Fatal("expected the outputs digest to handle down") + } + if app.sessOutputsCursor != 1 { + t.Fatalf("expected the cursor to move to 1, got %d", app.sessOutputsCursor) + } +} + +func TestOutputsCopyPutsURLBeforePath(t *testing.T) { + app := newTestApp(nil) + app.sessPreviewMode = sessPreviewOutputs + app.sessOutputsRows = []session.SessionOutput{ + {Kind: session.OutputPR, Title: "sendbird/ccx#3", URL: "https://github.com/sendbird/ccx/pull/3"}, + } + if _, _, handled := app.copySelectedOutput(); !handled { + t.Fatal("expected copy to be handled") + } + if !strings.Contains(app.copiedMsg, "sendbird/ccx#3") { + t.Fatalf("expected a copy confirmation naming the ref, got %q", app.copiedMsg) + } +} + +func TestOutputsLayoutRefreshNeverArmsInFlight(t *testing.T) { + // The render path cannot dispatch a tea.Cmd, so it must not arm the + // collection latch — a latch with no command in flight would strand the + // digest on "scanning transcript…" with nothing left to clear it. + path := writeTranscript(t, + toolUseLine("u1", "2026-08-13T01:00:00Z", "Write", `{"file_path":"/repo/a.go","content":"x"}`), + ) + sess := session.Session{ID: "s1", ShortID: "s1", FilePath: path, ProjectPath: "/repo", ModTime: dayOf(0)} + app := newTestApp([]session.Session{sess}) + app.sessSplit.Show = true + app.sessPreviewMode = sessPreviewOutputs + + app.refreshOutputsPreviewLayout(sess) + if app.outputsInFlight[sess.ID] { + t.Fatal("layout refresh must not arm the collection latch") + } + if app.refsInFlight[sess.ID] { + t.Fatal("layout refresh must not arm the refs latch") + } + // The dispatching variant still does its job. + if cmd := app.updateSessionOutputsPreview(sess); cmd == nil { + t.Fatal("expected the dispatching variant to return work") + } + if !app.outputsInFlight[sess.ID] { + t.Fatal("expected the dispatching variant to arm the latch") + } +} + +func TestInvalidateOutputsClearsInFlightLatch(t *testing.T) { + sess := session.Session{ID: "s1", ShortID: "s1", ProjectPath: "/repo", ModTime: dayOf(0)} + app := newTestApp([]session.Session{sess}) + app.sessSplit.Show = true + app.sessPreviewMode = sessPreviewOutputs + app.sessionList.Select(0) + app.outputsInFlight[sess.ID] = true + app.sessOutputsCollected = "stale" + + app.invalidateOpenPreviewCaches() + + if app.outputsInFlight[sess.ID] { + t.Fatal("expected refresh to drop the in-flight latch so a collection can re-dispatch") + } + if app.sessOutputsCollected != "" { + t.Fatal("expected refresh to drop the collected marker") + } +} + +func TestResolveVisibleRefsCoversSessionsUnderDayRows(t *testing.T) { + // The day rollup badges are the daily view's headline, and they are summed + // from child refs — so a folded day must still get its sessions extracted. + path := writeTranscript(t, + `{"type":"assistant","uuid":"u1","timestamp":"2026-08-13T01:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"https://github.com/sendbird/ccx/pull/1"}]}}`, + ) + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", FilePath: path, ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), HasRefs: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.sessFolded = map[string]bool{dayFoldKey(dayOf(0).Format("2006-01-02")): true} + app.rebuildSessionList() + + for _, it := range app.sessionList.VisibleItems() { + if _, ok := it.(sessionItem); ok { + t.Fatal("expected the day to be folded (no session rows visible)") + } + } + if cmd := app.resolveVisibleRefsCmd(); cmd == nil { + t.Fatal("expected an extract to be dispatched for the folded day's sessions") + } + if !app.refsInFlight["a1"] { + t.Fatal("expected the child session's extract to be armed") + } +} + +func TestDayPreviewCollapsesRepeatedOutputs(t *testing.T) { + // The same PR gets referenced from several of a day's sessions. The row is + // the PR, not each mention — otherwise a busy day's other results are buried. + pr := session.SessionRef{Kind: session.RefPR, Label: "sendbird/ccx#154", URL: "https://github.com/sendbird/ccx/pull/154", State: session.RefStateOpen, Resolved: true} + sessions := []session.Session{ + {ID: "first", ShortID: "first", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-3 * time.Hour), Refs: []session.SessionRef{pr}}, + {ID: "later", ShortID: "later", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-time.Hour), Refs: []session.SessionRef{pr}}, + } + di := buildDailyItems(sessions, nil)[0].(dayItem) + rows := buildDayOutputRows(di) + + if len(rows) != 1 { + t.Fatalf("expected the repeated PR to collapse to one row, got %d", len(rows)) + } + if rows[0].sessions != 2 { + t.Fatalf("expected the row to record 2 touching sessions, got %d", rows[0].sessions) + } + // The anchor is the EARLIEST session — where the work happened, not where it + // was later quoted. + if rows[0].sessID != "first" { + t.Fatalf("expected the earliest session as the anchor, got %q", rows[0].sessID) + } +} + +func TestDayPreviewOrdersResultsBeforePlans(t *testing.T) { + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), + PlanSlugs: []string{"a-plan"}, + Refs: []session.SessionRef{ + {Kind: session.RefArtifact, Label: "artifact:abcd1234", Title: "Report", URL: "https://claude.ai/code/artifact/abcd1234", Resolved: true}, + {Kind: session.RefPR, Label: "sendbird/ccx#1", URL: "https://github.com/sendbird/ccx/pull/1", Resolved: true}, + }, + }} + di := buildDailyItems(sessions, nil)[0].(dayItem) + rows := buildDayOutputRows(di) + + want := []session.OutputKind{session.OutputPR, session.OutputArtifact, session.OutputPlan} + if len(rows) != len(want) { + t.Fatalf("expected %d rows, got %d", len(want), len(rows)) + } + for i, k := range want { + if rows[i].out.Kind != k { + t.Fatalf("row %d: got %s, want %s", i, rows[i].out.Kind, k) + } + } +} + +func TestDayPreviewEnterOpensProducingSession(t *testing.T) { + // Two sessions, and the one that produced the output is NOT the day's + // representative (selectedSession returns di.sessions[0], the most recent). + // Without the day-pane branch in the Open handler, Enter would open the + // representative instead of the row's anchor and the test would pass by + // coincidence. + makerPath := writeTranscript(t, + `{"type":"user","uuid":"u1","timestamp":"2026-08-13T01:00:00Z","message":{"role":"user","content":"make the PR"}}`, + ) + sessions := []session.Session{ + {ID: "newest", ShortID: "newest", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0)}, + {ID: "maker", ShortID: "maker", FilePath: makerPath, ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-2 * time.Hour), + Refs: []session.SessionRef{{Kind: session.RefPR, Label: "sendbird/ccx#5", URL: "https://github.com/sendbird/ccx/pull/5", Resolved: true}}}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.sessionList.Select(0) + app.sessSplit.Show = true + app.sessSplit.Focus = true + _ = app.updateSessionPreview() + + if len(app.dayOutputRows) != 1 { + t.Fatalf("expected 1 output row, got %d", len(app.dayOutputRows)) + } + if app.dayOutputRows[0].sessID != "maker" { + t.Fatalf("expected the row anchored to the producing session, got %q", app.dayOutputRows[0].sessID) + } + // Drive the real key dispatch, not the handler directly: Enter is + // km.Session.Open and is consumed by the top-level switch before the + // focused-preview handlers ever run. + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = m.(*App) + if app.currentSess.ID != "maker" { + t.Fatalf("expected the producing session to be opened, got %q", app.currentSess.ID) + } + if app.state != viewConversation { + t.Fatalf("expected Enter to enter the conversation view, got state %d", app.state) + } +} + +func TestDayPreviewCursorKeepsScrollPosition(t *testing.T) { + // A real day can hold hundreds of outputs. Recreating the viewport on every + // cursor move would snap the pane back to the top, making everything below + // the fold unreachable. + var refs []session.SessionRef + for i := 0; i < 60; i++ { + refs = append(refs, session.SessionRef{ + Kind: session.RefPR, + Label: fmt.Sprintf("sendbird/ccx#%d", i), + URL: fmt.Sprintf("https://github.com/sendbird/ccx/pull/%d", i), + }) + } + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), Refs: refs}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.sessionList.Select(0) + app.sessSplit.Show = true + app.sessSplit.Focus = true + _ = app.updateSessionPreview() + + app.sessSplit.Preview.SetYOffset(20) + before := app.sessSplit.Preview.YOffset + if before == 0 { + t.Fatal("expected the pane to be scrollable for this fixture") + } + if _, _, handled := app.handleDayPreviewKeys(&app.sessSplit, "down"); !handled { + t.Fatal("expected the day pane to handle down") + } + if got := app.sessSplit.Preview.YOffset; got < before { + t.Fatalf("expected the cursor move to keep (or advance) the scroll position: %d → %d", before, got) + } +} + +func TestDailyViewTogglesWithSingleKey(t *testing.T) { + // The daily view is an axis you flip while reading, so it must round-trip + // without the command palette — and land back where the user was. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + app.rebuildSessionList() + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + app = m.(*App) + if app.sessGroupMode != groupDaily { + t.Fatalf("expected D to enter the daily view, got mode %d", app.sessGroupMode) + } + if _, ok := app.sessionList.VisibleItems()[0].(dayItem); !ok { + t.Fatal("expected a date row after toggling into the daily view") + } + + m, _ = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + app = m.(*App) + if app.sessGroupMode != groupProjectCentric { + t.Fatalf("expected D to restore the previous grouping, got mode %d", app.sessGroupMode) + } +} + +func TestDayRollupFillsFromExtractAlone(t *testing.T) { + // A date row's badges and Produced list are built from the refs themselves, + // so they must fill in when the offline extract lands — waiting for + // refStatusMsg leaves a day with many PRs reporting zero. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), HasRefs: true}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.sessionList.Select(0) + app.sessSplit.Show = true + _ = app.updateSessionPreview() + + if di, _ := app.selectedDay(); di.prs != 0 { + t.Fatalf("expected no PRs before the extract lands, got %d", di.prs) + } + + m, _ := app.Update(refsExtractedMsg{id: "a1", refs: []session.SessionRef{ + {Kind: session.RefPR, Label: "sendbird/ccx#154", URL: "https://github.com/sendbird/ccx/pull/154"}, + }}) + app = m.(*App) + + di, ok := app.selectedDay() + if !ok { + t.Fatal("expected the cursor to still be on the day row") + } + if di.prs != 1 { + t.Fatalf("expected the day rollup to count the extracted PR, got %d", di.prs) + } + if len(app.dayOutputRows) != 1 { + t.Fatalf("expected the Produced list to show the PR, got %d rows", len(app.dayOutputRows)) + } +} + +// A day with hundreds of outputs re-renders on every cursor keypress. Measure +// that a keypress stays well inside a frame budget. +func TestDayCursorMoveCost(t *testing.T) { + var refs []session.SessionRef + for i := 0; i < 500; i++ { + refs = append(refs, session.SessionRef{ + Kind: session.RefPR, Label: fmt.Sprintf("sendbird/ccx#%d", i), + URL: fmt.Sprintf("https://github.com/sendbird/ccx/pull/%d", i), + }) + } + var sessions []session.Session + for i := 0; i < 250; i++ { + s := session.Session{ID: fmt.Sprintf("s%d", i), ShortID: fmt.Sprintf("s%d", i), + ProjectPath: "/tmp/repo", ProjectName: "repo", ModTime: dayOf(0).Add(-time.Duration(i) * time.Minute)} + if i == 0 { + s.Refs = refs + } + sessions = append(sessions, s) + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.sessionList.Select(0) + app.sessSplit.Show = true + app.sessSplit.Focus = true + _ = app.updateSessionPreview() + t.Logf("rows=%d", len(app.dayOutputRows)) + + t0 := time.Now() + const n = 100 + for i := 0; i < n; i++ { + app.handleDayPreviewKeys(&app.sessSplit, "down") + } + per := time.Since(t0) / n + t.Logf("per cursor move: %s", per.Round(time.Microsecond)) + if per > 16*time.Millisecond { + t.Errorf("cursor move costs %s — above a 60fps frame budget", per) + } +} + +func TestVisibleRefsSpreadsParentFanoutAndConverges(t *testing.T) { + // A date row can own hundreds of sessions. Extracting all of them the + // moment the view opens is what made the daily view feel slow, so each pass + // takes only the next slice — but the rollup must still reach the exact + // count, not settle for an approximation. + var sessions []session.Session + for i := 0; i < 30; i++ { + path := writeTranscript(t, + `{"type":"assistant","uuid":"u1","timestamp":"2026-08-13T01:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"https://github.com/sendbird/ccx/pull/`+fmt.Sprint(100+i)+`"}]}}`, + ) + sessions = append(sessions, session.Session{ + ID: fmt.Sprintf("s%02d", i), ShortID: fmt.Sprintf("s%02d", i), FilePath: path, + ProjectPath: "/tmp/repo", ProjectName: "repo", + ModTime: dayOf(0).Add(-time.Duration(i) * time.Minute), HasRefs: true, + }) + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.sessFolded = map[string]bool{dayFoldKey(dayOf(0).Format("2006-01-02")): true} + app.rebuildSessionList() + app.sessionList.Select(0) + + first := 0 + passes := 0 + for passes < 25 { + cmd := app.resolveVisibleRefsCmd() + if cmd == nil { + break + } + n := 0 + if b, ok := cmd().(tea.BatchMsg); ok { + n = len(b) + for _, c := range b { + m, _ := app.Update(c()) + app = m.(*App) + } + } else { + n = 1 + m, _ := app.Update(cmd()) + app = m.(*App) + } + if passes == 0 { + first = n + } + passes++ + } + + if first == 0 || first >= len(sessions) { + t.Fatalf("expected the first pass to take a slice, not everything: got %d of %d", first, len(sessions)) + } + // Converged: every session's refs extracted, so the rollup is exact. + di, ok := app.selectedDay() + if !ok { + t.Fatal("expected the cursor to be on the day row") + } + if di.prs != len(sessions) { + t.Fatalf("expected the rollup to converge to %d PRs, got %d after %d passes", len(sessions), di.prs, passes) + } +} + +func TestDailyToggleWorksWithPreviewFocused(t *testing.T) { + // Which grouping the list uses is independent of which pane holds the + // cursor, so D must work with the preview focused too. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + app.rebuildSessionList() + app.sessSplit.Show = true + app.sessSplit.Focus = true + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'D'}}) + app = m.(*App) + if app.sessGroupMode != groupDaily { + t.Fatalf("expected D to work with the preview focused, got mode %d", app.sessGroupMode) + } +} + +func TestPrevGroupModePersists(t *testing.T) { + // A user whose saved grouping is daily must still get their own view back + // on toggle-out, not the built-in default. + app := newTestApp(nil) + app.sessGroupMode = groupDaily + app.preDailyGroupMode = groupTree + + prefs := app.capturePreferences() + if prefs.PrevGroupMode != "tree" { + t.Fatalf("expected prev_group_mode to persist as tree, got %q", prefs.PrevGroupMode) + } + + restored := newTestApp(nil) + restored.preDailyGroupMode = groupProjectCentric + restored.applyPreferences(Preferences{GroupMode: "daily", PrevGroupMode: "tree"}) + if restored.preDailyGroupMode != groupTree { + t.Fatalf("expected the restored return target to be tree, got %d", restored.preDailyGroupMode) + } +} + +func TestDailyBuildsDayProjectSessionTree(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), MsgCount: 5}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-time.Hour), MsgCount: 3}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-2 * time.Hour), MsgCount: 7}, + } + items := buildDailyItems(sessions, nil) + + var kinds []string + for _, it := range items { + switch v := it.(type) { + case dayItem: + kinds = append(kinds, "day") + case projectItem: + if v.dayKey == "" { + t.Fatal("expected a project row in the daily tree to carry its day key") + } + if v.treeDepth != 1 { + t.Fatalf("expected project depth 1, got %d", v.treeDepth) + } + kinds = append(kinds, "proj:"+v.displayName) + case sessionItem: + if v.treeDepth != 2 { + t.Fatalf("expected session depth 2 under a project, got %d", v.treeDepth) + } + kinds = append(kinds, "sess:"+v.sess.ID) + } + } + want := []string{"day", "proj:repo-a", "sess:a1", "sess:a2", "proj:repo-b", "sess:b1"} + if len(kinds) != len(want) { + t.Fatalf("expected tree %v, got %v", want, kinds) + } + for i := range want { + if kinds[i] != want[i] { + t.Fatalf("position %d: got %q, want %q (full: %v)", i, kinds[i], want[i], kinds) + } + } +} + +func TestDayProjectRowAggregatesThatDayOnly(t *testing.T) { + // A project row is the middle tier: it must aggregate only the slice of + // work that happened in that project on that day. + sessions := []session.Session{ + {ID: "today1", ShortID: "today1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), MsgCount: 5, + Refs: []session.SessionRef{{Kind: session.RefPR, Label: "x/y#1", URL: "https://github.com/x/y/pull/1"}}}, + {ID: "today2", ShortID: "today2", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-time.Hour), MsgCount: 3}, + {ID: "yday", ShortID: "yday", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(1), MsgCount: 99, + Refs: []session.SessionRef{{Kind: session.RefPR, Label: "x/y#2", URL: "https://github.com/x/y/pull/2"}}}, + } + items := buildDailyItems(sessions, nil) + + var today projectItem + for _, it := range items { + if p, ok := it.(projectItem); ok && p.dayKey == dayOf(0).Format("2006-01-02") { + today = p + break + } + } + if len(today.sessions) != 2 { + t.Fatalf("expected today's project row to hold 2 sessions, got %d", len(today.sessions)) + } + if today.totalMsgs != 8 { + t.Fatalf("expected 8 messages for today only, got %d", today.totalMsgs) + } +} + +func TestDayProjectFoldIsScopedToItsDay(t *testing.T) { + // The same project appears under every day it was worked on; one shared + // fold key would collapse it everywhere at once. + sessions := []session.Session{ + {ID: "t", ShortID: "t", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "y", ShortID: "y", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(1)}, + } + folded := map[string]bool{dayProjectFoldKey(dayOf(0).Format("2006-01-02"), "/tmp/repo-a"): true} + items := buildDailyItems(sessions, folded) + + var visibleSessions []string + for _, it := range items { + if si, ok := it.(sessionItem); ok { + visibleSessions = append(visibleSessions, si.sess.ID) + } + } + if len(visibleSessions) != 1 || visibleSessions[0] != "y" { + t.Fatalf("expected only yesterday's session visible, got %v", visibleSessions) + } +} + +func TestDailyAndBrowserKeepSeparatePreviewModes(t *testing.T) { + // The daily view is about results and the project browser about sessions; + // sharing one preview choice means every swap lands on the wrong pane. + app := newTestApp(nil) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + app.sessPreviewMode = sessPreviewConversation + app.browserPreviewMode = sessPreviewConversation + app.dailyPreviewMode = sessPreviewOutputs + + app.toggleDailyView() + if app.sessPreviewMode != sessPreviewOutputs { + t.Fatalf("expected the daily view to open on its own preview, got %d", app.sessPreviewMode) + } + // Change the mode while in the daily view, then swap back and forth. + app.sessPreviewMode = sessPreviewRefs + app.toggleDailyView() + if app.sessPreviewMode != sessPreviewConversation { + t.Fatalf("expected the browser to restore its own preview, got %d", app.sessPreviewMode) + } + app.toggleDailyView() + if app.sessPreviewMode != sessPreviewRefs { + t.Fatalf("expected the daily view to remember refs, got %d", app.sessPreviewMode) + } +} + +func TestToggleKeepsCursorOnSameRowKind(t *testing.T) { + // Landing on the same session is not enough: if the cursor was on a project + // head and the swap drops it onto one of that project's children, the + // toggle reads as "it lost my place". + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-time.Hour)}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-2 * time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + app.rebuildSessionList() + + // Park on a project head row in the browser. + var wantPath string + for i, it := range app.sessionList.VisibleItems() { + if p, ok := it.(projectItem); ok { + app.sessionList.Select(i) + wantPath = p.basePath + break + } + } + if wantPath == "" { + t.Fatal("expected a project row in the project browser") + } + + app.toggleDailyView() + p, ok := app.sessionList.SelectedItem().(projectItem) + if !ok { + t.Fatalf("expected to land on a project row in the daily view, got %T", app.sessionList.SelectedItem()) + } + if p.basePath != wantPath { + t.Fatalf("expected the same project (%s), got %s", wantPath, p.basePath) + } + + app.toggleDailyView() + back, ok := app.sessionList.SelectedItem().(projectItem) + if !ok { + t.Fatalf("expected to land back on a project row, got %T", app.sessionList.SelectedItem()) + } + if back.basePath != wantPath { + t.Fatalf("expected the same project on the way back (%s), got %s", wantPath, back.basePath) + } +} + +func TestToggleKeepsCursorOnSession(t *testing.T) { + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-time.Hour)}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-2 * time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + app.rebuildSessionList() + + var want string + for i, it := range app.sessionList.VisibleItems() { + if si, ok := it.(sessionItem); ok && si.sess.ID == "b1" { + app.sessionList.Select(i) + want = si.sess.ID + break + } + } + if want == "" { + t.Fatal("expected session b1 to be visible") + } + + app.toggleDailyView() + si, ok := app.sessionList.SelectedItem().(sessionItem) + if !ok { + t.Fatalf("expected to land on a session row, got %T", app.sessionList.SelectedItem()) + } + if si.sess.ID != want { + t.Fatalf("expected session %s, got %s", want, si.sess.ID) + } +} + +func TestRebuildKeepsDayCursorWhenNewerSessionArrives(t *testing.T) { + // The live tick rebuilds the list every few seconds. A day row used to be + // re-found via its newest session, so a fresh session landing on that day + // moved the anchor and dropped the cursor onto a child row. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "y1", ShortID: "y1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(1)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + + wantDay := "" + for i, it := range app.sessionList.VisibleItems() { + if di, ok := it.(dayItem); ok && di.dayKey == dayOf(1).Format("2006-01-02") { + app.sessionList.Select(i) + wantDay = di.dayKey + break + } + } + if wantDay == "" { + t.Fatal("expected yesterday's day row to be visible") + } + + // A newer session appears on that same day — the day's representative + // session changes, its identity does not. + app.sessions = append(app.sessions, session.Session{ + ID: "y2", ShortID: "y2", ProjectPath: "/tmp/repo-c", ProjectName: "repo-c", + ModTime: dayOf(1).Add(2 * time.Hour), + }) + app.rebuildSessionList() + + di, ok := app.sessionList.SelectedItem().(dayItem) + if !ok { + t.Fatalf("expected to stay on a day row, got %T", app.sessionList.SelectedItem()) + } + if di.dayKey != wantDay { + t.Fatalf("expected to stay on day %s, got %s", wantDay, di.dayKey) + } +} + +func TestRebuildKeepsProjectCursorOnItsOwnDay(t *testing.T) { + // The same repo appears under every day it was touched. Matching on the + // path alone walked the newest-day-first list and snapped the cursor to + // today's copy of the row. + sessions := []session.Session{ + {ID: "t1", ShortID: "t1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "y1", ShortID: "y1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(1)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + + wantDay := dayOf(1).Format("2006-01-02") + found := false + for i, it := range app.sessionList.VisibleItems() { + if p, ok := it.(projectItem); ok && p.dayKey == wantDay { + app.sessionList.Select(i) + found = true + break + } + } + if !found { + t.Fatalf("expected repo-a to appear under %s", wantDay) + } + + app.rebuildSessionList() + + p, ok := app.sessionList.SelectedItem().(projectItem) + if !ok { + t.Fatalf("expected to stay on a project row, got %T", app.sessionList.SelectedItem()) + } + if p.dayKey != wantDay { + t.Fatalf("expected to stay under day %s, got %s", wantDay, p.dayKey) + } + if p.basePath != "/tmp/repo-a" { + t.Fatalf("expected repo-a, got %s", p.basePath) + } +} + +// foldedKeys returns the currently collapsed fold keys, sorted, for assertions +// that the auto-expand touched only the cursor's own ancestors. +func foldedKeys(app *App) []string { + var keys []string + for k, v := range app.sessFolded { + if v { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} + +// selectedIsOnScreen reports whether the cursor's row is on the list's current +// page — Select() sets the paginator page, so a restore that only sets the +// index without scrolling would leave the user staring at an unrelated screen. +func selectedIsOnScreen(app *App) bool { + idx := app.sessionList.Index() + per := app.sessionList.Paginator.PerPage + if per <= 0 { + return false + } + return app.sessionList.Paginator.Page == idx/per +} + +func TestToggleIntoDailyExpandsAncestorsOfSession(t *testing.T) { + // The defect: with the destination day (and the project under it) folded, + // the target session is not in VisibleItems() at all, so the restore fell + // through to its last-resort branch and the cursor landed somewhere else. + sessions := []session.Session{ + {ID: "t1", ShortID: "t1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "y1", ShortID: "y1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(2)}, + {ID: "y2", ShortID: "y2", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(2).Add(-time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + yesterKey := dayOf(2).Format("2006-01-02") + todayKey := dayOf(0).Format("2006-01-02") + // Everything on the destination side is collapsed, including one fold the + // cursor has no business in (today's date row). + app.sessFolded = map[string]bool{ + dayFoldKey(yesterKey): true, + dayProjectFoldKey(yesterKey, "/tmp/repo-b"): true, + dayFoldKey(todayKey): true, + dayProjectFoldKey(todayKey, "/tmp/repo-a"): true, + } + app.rebuildSessionList() + + found := false + for i, it := range app.sessionList.VisibleItems() { + if si, ok := it.(sessionItem); ok && si.sess.ID == "y2" { + app.sessionList.Select(i) + found = true + break + } + } + if !found { + t.Fatal("expected session y2 in the project browser") + } + + app.toggleDailyView() + + si, ok := app.sessionList.SelectedItem().(sessionItem) + if !ok { + t.Fatalf("expected to land on a session row, got %T", app.sessionList.SelectedItem()) + } + if si.sess.ID != "y2" { + t.Fatalf("expected session y2, got %s", si.sess.ID) + } + if !selectedIsOnScreen(app) { + t.Fatal("expected the restored cursor to be scrolled onto the visible page") + } + // Only the cursor's own ancestors were opened; today's folds are untouched. + want := []string{dayProjectFoldKey(todayKey, "/tmp/repo-a"), dayFoldKey(todayKey)} + sort.Strings(want) + if got := foldedKeys(app); !slices.Equal(got, want) { + t.Fatalf("expected only the target's ancestors to be expanded\n got: %v\nwant: %v", got, want) + } +} + +func TestToggleOutOfDailyExpandsFoldedProject(t *testing.T) { + // Same defect in the other direction: the project browser folds by + // "repo:", so a session under a collapsed project row is invisible + // on the way back out of the daily view. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-time.Hour)}, + {ID: "b2", ShortID: "b2", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(0).Add(-2 * time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.preDailyGroupMode = groupProjectCentric + app.sessFolded = map[string]bool{ + "repo:/tmp/repo-b": true, + "repo:/tmp/repo-a": true, // unrelated: must survive the swap + } + app.rebuildSessionList() + + found := false + for i, it := range app.sessionList.VisibleItems() { + if si, ok := it.(sessionItem); ok && si.sess.ID == "b2" { + app.sessionList.Select(i) + found = true + break + } + } + if !found { + t.Fatal("expected session b2 under its day in the daily view") + } + + app.toggleDailyView() + + si, ok := app.sessionList.SelectedItem().(sessionItem) + if !ok { + t.Fatalf("expected to land on a session row, got %T", app.sessionList.SelectedItem()) + } + if si.sess.ID != "b2" { + t.Fatalf("expected session b2, got %s", si.sess.ID) + } + if !selectedIsOnScreen(app) { + t.Fatal("expected the restored cursor to be scrolled onto the visible page") + } + if app.sessFolded["repo:/tmp/repo-b"] { + t.Fatal("expected the cursor's own project to be expanded") + } + if !app.sessFolded["repo:/tmp/repo-a"] { + t.Fatal("expected an unrelated project to stay folded") + } +} + +func TestToggleIntoDailyExpandsDayForProjectRow(t *testing.T) { + // A project row's only ancestor in the daily view is its date row — the + // day-scoped project fold belongs to the row itself and must NOT be cleared, + // or the swap silently unfolds that project's whole session list. + sessions := []session.Session{ + {ID: "y1", ShortID: "y1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(1)}, + {ID: "y2", ShortID: "y2", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: dayOf(1).Add(-time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + dayKey := dayOf(1).Format("2006-01-02") + app.sessFolded = map[string]bool{ + dayFoldKey(dayKey): true, + dayProjectFoldKey(dayKey, "/tmp/repo-b"): true, + } + app.rebuildSessionList() + + found := false + for i, it := range app.sessionList.VisibleItems() { + if p, ok := it.(projectItem); ok && p.basePath == "/tmp/repo-b" { + app.sessionList.Select(i) + found = true + break + } + } + if !found { + t.Fatal("expected a repo-b project row in the project browser") + } + + app.toggleDailyView() + + p, ok := app.sessionList.SelectedItem().(projectItem) + if !ok { + t.Fatalf("expected to land on a project row, got %T", app.sessionList.SelectedItem()) + } + if p.basePath != "/tmp/repo-b" || p.dayKey != dayKey { + t.Fatalf("expected repo-b under %s, got %s under %s", dayKey, p.basePath, p.dayKey) + } + if app.sessFolded[dayFoldKey(dayKey)] { + t.Fatal("expected the date row to be expanded so its project row is visible") + } + if !app.sessFolded[dayProjectFoldKey(dayKey, "/tmp/repo-b")] { + t.Fatal("expected the project row's own fold to be left alone — it is the target, not an ancestor") + } +} + +func TestToggleOutOfDailyExpandsGroupedSessionHeader(t *testing.T) { + // Non-daily groupings fold via sessionItem.groupKey ("repo:" here) rather + // than a projectItem, so the ancestor walk has to handle a session row + // heading a folded group too. + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0)}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0).Add(-time.Hour)}, + } + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.preDailyGroupMode = groupBaseProject + app.sessFolded = map[string]bool{"repo:/tmp/repo-a": true} + app.rebuildSessionList() + + found := false + for i, it := range app.sessionList.VisibleItems() { + if si, ok := it.(sessionItem); ok && si.sess.ID == "a2" { + app.sessionList.Select(i) + found = true + break + } + } + if !found { + t.Fatal("expected session a2 in the daily view") + } + + app.toggleDailyView() + + if app.sessGroupMode != groupBaseProject { + t.Fatalf("expected to return to base-project grouping, got %d", app.sessGroupMode) + } + si, ok := app.sessionList.SelectedItem().(sessionItem) + if !ok { + t.Fatalf("expected to land on a session row, got %T", app.sessionList.SelectedItem()) + } + if si.sess.ID != "a2" { + t.Fatalf("expected session a2, got %s", si.sess.ID) + } + if app.sessFolded["repo:/tmp/repo-a"] { + t.Fatal("expected the folded base-repo group to be expanded for the cursor") + } +} + +func TestToggleScrollsRevealedRowIntoView(t *testing.T) { + // Revealing the row is only half the fix: on a day with enough projects the + // target sits pages below the top, and a restore that set the index without + // paging would leave the user looking at an unrelated screen. + var sessions []session.Session + for i := 0; i < 40; i++ { + sessions = append(sessions, session.Session{ + ID: fmt.Sprintf("s%02d", i), + ShortID: fmt.Sprintf("s%02d", i), + // One project per session so the daily view emits a project row for + // each, pushing the last one well past the first page. + ProjectPath: fmt.Sprintf("/tmp/repo-%02d", i), + ProjectName: fmt.Sprintf("repo-%02d", i), + ModTime: dayOf(0).Add(-time.Duration(i) * time.Minute), + }) + } + app := newTestApp(sessions) + app.sessGroupMode = groupProjectCentric + app.preDailyGroupMode = groupProjectCentric + dayKey := dayOf(0).Format("2006-01-02") + app.sessFolded = map[string]bool{ + dayFoldKey(dayKey): true, + dayProjectFoldKey(dayKey, "/tmp/repo-39"): true, + } + app.rebuildSessionList() + + for i, it := range app.sessionList.VisibleItems() { + if si, ok := it.(sessionItem); ok && si.sess.ID == "s39" { + app.sessionList.Select(i) + break + } + } + + app.toggleDailyView() + + si, ok := app.sessionList.SelectedItem().(sessionItem) + if !ok || si.sess.ID != "s39" { + t.Fatalf("expected to land on session s39, got %T", app.sessionList.SelectedItem()) + } + idx := app.sessionList.Index() + per := app.sessionList.Paginator.PerPage + if per <= 0 || idx < per { + t.Fatalf("test setup no longer places the target off the first page: idx=%d perPage=%d", idx, per) + } + if app.sessionList.Paginator.Page != idx/per { + t.Fatalf("expected the list scrolled to page %d, got %d", idx/per, app.sessionList.Paginator.Page) + } +} diff --git a/internal/tui/daypane.go b/internal/tui/daypane.go new file mode 100644 index 0000000..8034b63 --- /dev/null +++ b/internal/tui/daypane.go @@ -0,0 +1,381 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sendbird/ccx/internal/session" +) + +// The day pane answers "what came out of this day?". Every row is an output +// with the session that produced it as an anchor — the digest tells you what, +// and Enter on a row is how you get to how. + +// dayOutputRow is one line in the day preview: an output the day produced, plus +// the session that produced it. The session is the anchor — Enter on any row +// opens that conversation, which is the only path from "what came out" back to +// "how it happened". +type dayOutputRow struct { + out session.SessionOutput + sessID string + shortID string + project string + // sessions counts how many of the day's sessions touched this same output. + // A PR gets discussed across several sessions; the row is the PR, not each + // mention, and this says how widely it spread. + sessions int +} + +// buildDayOutputRows collects the day's outputs from state the scan and the ref +// pipeline already loaded — no transcripts, so moving across dates stays +// instant. Refs and plan slugs are what a session records without being parsed; +// the per-session Outputs digest is what pays the parse cost, on demand. +// +// Identical outputs are collapsed: on a busy day the same PR is referenced from +// half a dozen sessions, and listing each mention buries the other results. The +// row keeps its FIRST session as the anchor — where the work actually happened, +// as opposed to wherever it was later quoted. +// +// Rows are chronological: a day reads as a journal, the opposite of the list +// rows (which lead with the most recent). +func buildDayOutputRows(di dayItem) []dayOutputRow { + var rows []dayOutputRow + byKey := map[string]int{} // identity → index into rows + add := func(o session.SessionOutput, s session.Session) { + key := string(o.Kind) + "\x00" + o.Title + if i, ok := byKey[key]; ok { + rows[i].sessions++ + // Keep the earliest occurrence's timestamp as the row's time. ONLY + // the timestamp: the rest of the output — crucially MessageUUID — + // must stay with the anchor session, since a uuid from a later + // session does not exist in the anchor's transcript and the jump + // would silently miss. + if !o.Last.IsZero() && (rows[i].out.Last.IsZero() || o.Last.Before(rows[i].out.Last)) { + rows[i].out.Last = o.Last + } + return + } + byKey[key] = len(rows) + rows = append(rows, dayOutputRow{ + out: o, sessID: s.ID, shortID: s.ShortID, project: s.ProjectName, sessions: 1, + }) + } + for _, s := range chronological(di.sessions) { + for _, r := range s.Refs { + add(session.RefOutput(r), s) + } + for _, slug := range s.PlanSlugs { + add(session.SessionOutput{ + Kind: session.OutputPlan, Title: slug, Last: s.ModTime, Count: 1, + }, s) + } + } + // Group by kind (results before working material), preserving the + // chronological order established above within each kind. + sort.SliceStable(rows, func(i, j int) bool { + return outputKindRank(rows[i].out.Kind) < outputKindRank(rows[j].out.Kind) + }) + return rows +} + +// outputKindRank mirrors session.SortOutputs' kind ordering for row structs, +// which carry more than a SessionOutput and so cannot use it directly. +func outputKindRank(k session.OutputKind) int { + switch k { + case session.OutputPR: + return 0 + case session.OutputJira: + return 1 + case session.OutputArtifact: + return 2 + case session.OutputPlan: + return 3 + case session.OutputMemory: + return 4 + case session.OutputChange: + return 5 + } + return 6 +} + +// updateDayPreview renders the day pane: what the day produced, one row per +// output, each anchored to the session that made it. Sessions themselves are +// deliberately NOT listed — they are one row away in the list pane, and a busy +// day really can hold 250+ of them, which would bury the outputs entirely. +func (a *App) updateDayPreview(di dayItem) { + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + + // Reset the cursor when the day changes so it never points past a shorter + // day's row list. + if a.dayOutputsCacheID != di.dayKey { + a.dayOutputsCursor = 0 + a.dayOutputsCacheID = di.dayKey + } + rows := buildDayOutputRows(di) + if a.dayOutputsCursor >= len(rows) { + a.dayOutputsCursor = 0 + } + a.dayOutputRows = rows + + // Recreate the viewport only on a size change. Rebuilding it every call + // would reset YOffset to 0, and since cursor movement re-renders, every + // keypress would snap a long day (462 rows is a real measurement) back to + // the top — making everything below the fold unreachable. + if a.sessSplit.Preview.Width != previewW || a.sessSplit.Preview.Height != contentH { + a.sessSplit.Preview = viewport.New(previewW, contentH) + } + title := dayLabel(di.day, time.Now()) + subtitle := di.day.Format("Mon, Jan 2 2006") + summary := fmt.Sprintf("%s across %s · %d messages", + plural(len(di.sessions), "session"), plural(di.projects, "project"), di.totalMsgs) + a.sessSplit.Preview.SetContent(a.renderOutputsPane(title, subtitle, summary, rows, previewW)) +} + +// updateDayProjectPreview renders the middle tier of the daily tree: one day's +// work in one project. Same pane as the day view, scoped down — the project +// breakdown is dropped because at this level there is only one project. +func (a *App) updateDayProjectPreview(pi projectItem) { + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + + cacheID := pi.dayKey + "|" + pi.basePath + if a.dayOutputsCacheID != cacheID { + a.dayOutputsCursor = 0 + a.dayOutputsCacheID = cacheID + } + rows := buildDayOutputRows(dayItem{sessions: pi.sessions}) + if a.dayOutputsCursor >= len(rows) { + a.dayOutputsCursor = 0 + } + a.dayOutputRows = rows + + if a.sessSplit.Preview.Width != previewW || a.sessSplit.Preview.Height != contentH { + a.sessSplit.Preview = viewport.New(previewW, contentH) + } + subtitle := pi.basePath + if pi.branch != "" { + subtitle += " (" + pi.branch + ")" + } + summary := fmt.Sprintf("%s · %d messages on this day", + plural(len(pi.sessions), "session"), pi.totalMsgs) + a.sessSplit.Preview.SetContent(a.renderOutputsPane(pi.displayName, subtitle, summary, rows, previewW)) +} + +// renderOutputsPane draws a "what this produced" pane for any scope in the +// daily tree: a whole day, or one project within it. Only the header text +// differs — the outputs list is the same shape at every level. +func (a *App) renderOutputsPane(title, subtitle, summary string, rows []dayOutputRow, width int) string { + section := lipgloss.NewStyle().Bold(true).Foreground(colorAccent) + var sb strings.Builder + + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(title)) + if subtitle != "" { + sb.WriteString(dimStyle.Render(" " + subtitle)) + } + sb.WriteString("\n") + sb.WriteString(dimStyle.Render(summary)) + sb.WriteString("\n\n") + + heading := fmt.Sprintf("Produced (%d)", len(rows)) + if len(rows) > 0 && a.sessSplit.Focus { + heading += " ↵:jump to first mention o:open y:copy" + } + sb.WriteString(section.Render(heading) + "\n") + + if len(rows) == 0 { + // Refs resolve lazily, so "nothing yet" is the honest phrasing: rows fill + // in as the background extract lands rather than this being a verdict. + sb.WriteString(dimStyle.Render(" no references or plans recorded yet") + "\n\n") + } else { + lastKind := session.OutputKind("") + for i, r := range rows { + if r.out.Kind != lastKind { + if lastKind != "" { + sb.WriteString("\n") + } + sb.WriteString(dimStyle.Bold(true).Render(" "+outputSection(r.out.Kind)) + "\n") + lastKind = r.out.Kind + } + sb.WriteString(dayOutputLine(r, width, i == a.dayOutputsCursor && a.sessSplit.Focus) + "\n") + } + sb.WriteString("\n") + } + + // No project breakdown here: the list itself now nests day → project → + // session, so the projects are one row below and repeating them in the pane + // would say the same thing twice. + // + // The footer describes whichever pane owns the keys. Unfocused, Enter still + // belongs to the list (it folds the row); focused, the keys are this pane's, + // and saying otherwise sent people to the wrong action. + if a.sessSplit.Focus { + sb.WriteString(dimStyle.Render("↵ jumps to where it first appeared • o opens it • y copies • ↑↓ moves between outputs")) + } else { + sb.WriteString(dimStyle.Render("↵/o folds this row • tab focuses this pane")) + } + return sb.String() +} + +// dayOutputLine renders one output row: cursor, kind glyph, title, then the +// producing session as a dimmed anchor. +func dayOutputLine(r dayOutputRow, width int, selected bool) string { + cursor := " " + titleStyle := lipgloss.NewStyle().Bold(true) + if selected { + cursor = lipgloss.NewStyle().Foreground(colorBorderFocused).Bold(true).Render("> ") + titleStyle = titleStyle.Foreground(colorBorderFocused) + } + head := cursor + outputGlyph(r.out) + " " + titleStyle.Render(r.out.Title) + + anchor := dimStyle.Render(" " + r.shortID) + if r.sessions > 1 { + anchor = dimStyle.Render(fmt.Sprintf(" %s +%d", r.shortID, r.sessions-1)) + } + if r.project != "" { + anchor += dimStyle.Render(" · " + r.project) + } + + detail := r.out.Detail + if detail == "" { + return head + anchor + } + avail := width - lipgloss.Width(head) - lipgloss.Width(anchor) - 2 + if avail < 8 { + return head + anchor + } + return head + " " + dimStyle.Render(truncate(detail, avail)) + anchor +} + +// handleDayPreviewKeys drives the day pane when the preview has focus: the +// cursor moves between outputs, Enter jumps into the conversation at the moment +// the output first appeared, and `o` opens the output itself (a PR/Jira URL) in +// the browser. The two are deliberately different questions — "how did this +// happen" vs "take me to the thing" — so they are no longer aliases. +func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cmd, bool) { + switch key { + case "enter": + return a.openSelectedDayOutput() + case "o": + return a.openSelectedDayOutputTarget() + case a.keymap.Actions.CopyPath, "y": + return a.copySelectedDayOutput() + case "/": + sp.Focus = false + return a, startListSearch(&a.sessionList), true + } + switch HandleFlatCursorNav(&a.dayOutputsCursor, len(a.dayOutputRows), key) { + case NavCursorMoved: + a.sessSplit.CacheKey = "" // force the day pane to re-render with the new highlight + // Re-render whichever scope owns the pane. A day-scoped PROJECT row owns + // it too (selectedOwnsDayPane), and rendering only the day case left the + // highlight frozen on those rows. + if di, ok := a.selectedDay(); ok { + a.updateDayPreview(di) + } else if pi, ok := a.selectedProject(); ok && pi.dayKey != "" { + a.updateDayProjectPreview(pi) + } + // Nudge the viewport so the cursor stays in view as it walks past the + // fold (the tasks/agents preview does the same). + switch key { + case "up", "k": + sp.Preview.LineUp(1) + case "down", "j": + sp.Preview.LineDown(1) + } + return a, nil, true + case NavBoundaryDown, NavBoundaryUp: + return a, nil, true + } + if scrollViewport(&sp.Preview, key) { + return a, nil, true + } + return a, nil, false +} + +func (a *App) selectedDayOutput() (dayOutputRow, bool) { + if a.dayOutputsCursor < 0 || a.dayOutputsCursor >= len(a.dayOutputRows) { + return dayOutputRow{}, false + } + return a.dayOutputRows[a.dayOutputsCursor], true +} + +// openSelectedDayOutput jumps to the conversation entry where the output under +// the cursor FIRST appeared. The digest says what came out; this is how you get +// to how — and the interesting moment is the first mention, not the session as +// a whole. The uuid comes from the row's anchor session (buildDayOutputRows +// keeps the earliest occurrence), so it resolves inside the transcript we open. +// Outputs with no recorded entry (plan slugs carried over from a parent +// session) still open the conversation. +func (a *App) openSelectedDayOutput() (tea.Model, tea.Cmd, bool) { + r, ok := a.selectedDayOutput() + if !ok { + return a, nil, true + } + if r.out.MessageUUID != "" { + m, cmd := a.jumpToSessionEntry(r.sessID, r.out.MessageUUID) + return m, cmd, true + } + sess, ok := a.sessionByIDFromStore(r.sessID) + if !ok { + return a, nil, true + } + a.currentSess = sess + return a, a.openConversation(sess), true +} + +// openSelectedDayOutputTarget opens the output itself: a PR/Jira/artifact URL +// goes to the browser, a file-backed output surfaces its path, and anything +// with neither falls back to the producing conversation. +func (a *App) openSelectedDayOutputTarget() (tea.Model, tea.Cmd, bool) { + r, ok := a.selectedDayOutput() + if !ok { + return a, nil, true + } + if r.out.URL != "" { + if err := a.openInBrowser(r.out.URL); err != nil { + a.copiedMsg = "Open failed: " + err.Error() + } else { + a.copiedMsg = "Opened " + r.out.Title + } + return a, nil, true + } + if r.out.Path != "" { + a.copiedMsg = r.out.Path + return a, nil, true + } + return a.openSelectedDayOutput() +} + +// copySelectedDayOutput copies the output's URL, or its path when it is a file. +func (a *App) copySelectedDayOutput() (tea.Model, tea.Cmd, bool) { + r, ok := a.selectedDayOutput() + if !ok { + return a, nil, true + } + target := r.out.URL + if target == "" { + target = r.out.Path + } + if target == "" { + a.copiedMsg = "No URL for this output" + return a, nil, true + } + copyToClipboard(target) + a.copiedMsg = "Copied " + r.out.Title + return a, nil, true +} + +// chronological returns the day's sessions oldest-first — how a journal reads, +// the opposite of the list rows (which lead with the most recent). +func chronological(sessions []session.Session) []session.Session { + out := make([]session.Session, len(sessions)) + copy(out, sessions) + sort.SliceStable(out, func(i, j int) bool { return out[i].ModTime.Before(out[j].ModTime) }) + return out +} diff --git a/internal/tui/daypane_test.go b/internal/tui/daypane_test.go new file mode 100644 index 0000000..04f02d7 --- /dev/null +++ b/internal/tui/daypane_test.go @@ -0,0 +1,300 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/sendbird/ccx/internal/session" +) + +// The day pane's "Produced" digest answers what a day produced; these tests +// guard the two ways out of a row — Enter into the conversation at the moment +// the output first appeared, and `o` to the output itself. + +// dayPaneApp builds a daily-view App with the day row selected and the preview +// focused, which is the state every key assertion below starts from. +func dayPaneApp(t *testing.T, sessions []session.Session) *App { + t.Helper() + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + app.sessionList.Select(0) + app.sessSplit.Show = true + app.sessSplit.Focus = true + _ = app.updateSessionPreview() + return app +} + +// TestDayPreviewOpenSendsPRToBrowser guards the reported "PR open is broken" +// bug: openSelectedDayOutput ignored o.URL entirely, so a PR row in the day +// digest had no path to the browser at all — `o` opened the conversation just +// like Enter did. The refs pane and the per-session digest both open the URL; +// the day pane must too. +func TestDayPreviewOpenSendsPRToBrowser(t *testing.T) { + sessions := []session.Session{{ + ID: "maker", ShortID: "maker", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0), + Refs: []session.SessionRef{{ + Kind: session.RefPR, Label: "sendbird/ccx#5", + URL: "https://github.com/sendbird/ccx/pull/5", Resolved: true, + }}, + }} + app := dayPaneApp(t, sessions) + + var opened string + app.openURL = func(u string) error { opened = u; return nil } + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}}) + got := m.(*App) + + if opened != "https://github.com/sendbird/ccx/pull/5" { + t.Errorf("expected the PR URL to open in the browser, got %q", opened) + } + if got.state != viewSessions { + t.Errorf("o should stay in the sessions view, got state %v", got.state) + } +} + +// TestDayPreviewEnterJumpsToFirstMention guards the second reported bug: Enter +// dumped you at the top of the session instead of the message where the output +// first appeared. The row's uuid comes from the ref, which now records the +// entry it was first seen in. +func TestDayPreviewEnterJumpsToFirstMention(t *testing.T) { + // Three entries; the PR is first mentioned in the middle one. + path := writeTranscript(t, + `{"type":"user","uuid":"u1","timestamp":"2026-08-13T01:00:00Z","message":{"role":"user","content":"open a PR for this"}}`, + `{"type":"assistant","uuid":"u2","timestamp":"2026-08-13T01:01:00Z","message":{"role":"assistant","content":[{"type":"text","text":"opened https://github.com/sendbird/ccx/pull/5"}]}}`, + `{"type":"user","uuid":"u3","timestamp":"2026-08-13T01:02:00Z","message":{"role":"user","content":"thanks"}}`, + ) + sessions := []session.Session{{ + ID: "maker", ShortID: "maker", FilePath: path, + ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), + Refs: []session.SessionRef{{ + Kind: session.RefPR, Label: "sendbird/ccx#5", + URL: "https://github.com/sendbird/ccx/pull/5", Resolved: true, + FirstSeen: time.Date(2026, 8, 13, 1, 1, 0, 0, time.UTC), FirstSeenUUID: "u2", + }}, + }} + app := dayPaneApp(t, sessions) + + if len(app.dayOutputRows) != 1 { + t.Fatalf("expected 1 output row, got %d", len(app.dayOutputRows)) + } + if app.dayOutputRows[0].out.MessageUUID != "u2" { + t.Fatalf("row lost the ref's first-seen uuid: %q", app.dayOutputRows[0].out.MessageUUID) + } + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := m.(*App) + + if got.state != viewConversation { + t.Fatalf("expected Enter to enter the conversation view, got state %v", got.state) + } + if got.currentSess.ID != "maker" { + t.Fatalf("expected the producing session, got %q", got.currentSess.ID) + } + // The jump landed if the selected conversation row covers entry u2 — and + // jumpToSessionEntry says so out loud when it does not. + if strings.Contains(got.copiedMsg, "not found") { + t.Fatalf("jump missed the entry: %q", got.copiedMsg) + } + if uuid := selectedConvEntryUUID(got); uuid != "u2" { + t.Errorf("cursor landed on entry %q, want the first mention u2", uuid) + } +} + +// TestDayPreviewEnterFallsBackWithoutUUID pins the fallback: outputs with no +// recorded entry (a plan slug inherited from a parent session) must still open +// the conversation rather than doing nothing. +func TestDayPreviewEnterFallsBackWithoutUUID(t *testing.T) { + path := writeTranscript(t, + `{"type":"user","uuid":"u1","timestamp":"2026-08-13T01:00:00Z","message":{"role":"user","content":"work from the plan"}}`, + ) + sessions := []session.Session{{ + ID: "planner", ShortID: "planner", FilePath: path, + ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: dayOf(0), + PlanSlugs: []string{"some-plan"}, + }} + app := dayPaneApp(t, sessions) + + if len(app.dayOutputRows) != 1 || app.dayOutputRows[0].out.MessageUUID != "" { + t.Fatalf("fixture should give one uuid-less row, got %+v", app.dayOutputRows) + } + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := m.(*App) + + if got.state != viewConversation || got.currentSess.ID != "planner" { + t.Fatalf("expected the conversation to open anyway, got state %v session %q", got.state, got.currentSess.ID) + } +} + +// TestDayPreviewUUIDBelongsToAnchorSession guards the collapse trap: the same +// PR referenced from two sessions renders as ONE row anchored to the earliest +// session. The uuid must come from that anchor — a uuid from the later session +// names an entry that does not exist in the anchor's transcript, so the jump +// would silently miss. +func TestDayPreviewUUIDBelongsToAnchorSession(t *testing.T) { + pr := session.SessionRef{ + Kind: session.RefPR, Label: "sendbird/ccx#5", + URL: "https://github.com/sendbird/ccx/pull/5", Resolved: true, + } + first, later := pr, pr + first.FirstSeenUUID = "first-entry" + later.FirstSeenUUID = "later-entry" + + sessions := []session.Session{ + {ID: "first", ShortID: "first", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0).Add(-3 * time.Hour), Refs: []session.SessionRef{first}}, + {ID: "later", ShortID: "later", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", + ModTime: dayOf(0).Add(-time.Hour), Refs: []session.SessionRef{later}}, + } + di := buildDailyItems(sessions, nil)[0].(dayItem) + rows := buildDayOutputRows(di) + + if len(rows) != 1 { + t.Fatalf("expected the repeated PR to collapse to one row, got %d", len(rows)) + } + if rows[0].sessID != "first" { + t.Fatalf("expected the earliest session as the anchor, got %q", rows[0].sessID) + } + if rows[0].out.MessageUUID != "first-entry" { + t.Errorf("jump uuid = %q, want the anchor session's %q — a later session's uuid does not exist in the anchor's transcript", + rows[0].out.MessageUUID, "first-entry") + } +} + +// TestDayPreviewCursorMovesOnProjectRow guards a bug found while fixing the +// above: the cursor-move branch only re-rendered via selectedDay(), so on a +// day-scoped PROJECT row (which owns the same pane — see selectedOwnsDayPane) +// the highlight never moved, making the pane look frozen. +func TestDayPreviewCursorMovesOnProjectRow(t *testing.T) { + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0), + Refs: []session.SessionRef{ + {Kind: session.RefPR, Label: "sendbird/ccx#1", URL: "https://github.com/sendbird/ccx/pull/1", Resolved: true}, + {Kind: session.RefPR, Label: "sendbird/ccx#2", URL: "https://github.com/sendbird/ccx/pull/2", Resolved: true}, + }, + }} + app := newTestApp(sessions) + app.sessGroupMode = groupDaily + app.rebuildSessionList() + + // Walk to the day-scoped project row. + idx := -1 + for i, item := range app.sessionList.VisibleItems() { + if pi, ok := item.(projectItem); ok && pi.dayKey != "" { + idx = i + break + } + } + if idx < 0 { + t.Fatal("expected a day-scoped project row in the daily tree") + } + app.sessionList.Select(idx) + app.sessSplit.Show = true + app.sessSplit.Focus = true + _ = app.updateSessionPreview() + + if len(app.dayOutputRows) < 2 { + t.Fatalf("expected the project row's pane to list both PRs, got %d rows", len(app.dayOutputRows)) + } + before := app.sessSplit.Preview.View() + + if _, _, handled := app.handleDayPreviewKeys(&app.sessSplit, "down"); !handled { + t.Fatal("expected the day pane to handle down on a project row") + } + if app.dayOutputsCursor != 1 { + t.Fatalf("cursor = %d, want 1", app.dayOutputsCursor) + } + if after := app.sessSplit.Preview.View(); after == before { + t.Error("pane content did not change after the cursor moved — the highlight is frozen on day-scoped project rows") + } +} + +// TestDayPreviewHintsMatchTheFocusedKeys guards the hint line: with the pane +// focused, Enter and `o` belong to the pane (jump / open), not to the list's +// fold. The old footer advertised "↵/o folds this row" in both states, which +// pointed at an action the focused pane does not perform. +func TestDayPreviewHintsMatchTheFocusedKeys(t *testing.T) { + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0), + Refs: []session.SessionRef{{ + Kind: session.RefPR, Label: "sendbird/ccx#1", + URL: "https://github.com/sendbird/ccx/pull/1", Resolved: true, + }}, + }} + app := dayPaneApp(t, sessions) + + focused := app.sessSplit.Preview.View() + if strings.Contains(focused, "folds this row") { + t.Errorf("focused pane still advertises the list's fold action:\n%s", focused) + } + for _, want := range []string{"first appeared", "opens it"} { + if !strings.Contains(focused, want) { + t.Errorf("focused hint missing %q, got:\n%s", want, focused) + } + } + + app.sessSplit.Focus = false + app.sessSplit.CacheKey = "" + _ = app.updateSessionPreview() + if unfocused := app.sessSplit.Preview.View(); !strings.Contains(unfocused, "folds this row") { + t.Errorf("unfocused pane should still describe the list's Enter, got:\n%s", unfocused) + } +} + +// TestDayPreviewCopyFallsBackToPath mirrors the per-session digest: `y` copies +// the URL when there is one and the path otherwise. Previously a path-only row +// (a plan file) reported "No URL for this output" and copied nothing. +func TestDayPreviewCopyFallsBackToPath(t *testing.T) { + app := newTestApp(nil) + app.dayOutputRows = []dayOutputRow{{ + out: session.SessionOutput{Kind: session.OutputPlan, Title: "some-plan", Path: "/tmp/plans/some-plan.md"}, + sessID: "a1", + }} + app.dayOutputsCursor = 0 + + m, _, _ := app.copySelectedDayOutput() + if got := m.(*App).copiedMsg; !strings.Contains(got, "some-plan") { + t.Errorf("copiedMsg = %q, want the plan copied via its path", got) + } +} + +// TestDayPreviewKeysIgnoreUnrelatedKeys pins key ownership: the day pane must +// not swallow keys it has no business handling (the list still owns them). +func TestDayPreviewKeysIgnoreUnrelatedKeys(t *testing.T) { + app := newTestApp(nil) + app.dayOutputRows = []dayOutputRow{{ + out: session.SessionOutput{Kind: session.OutputPR, Title: "sendbird/ccx#1"}, + sessID: "a1", + }} + for _, key := range []string{"D", "r", "x", "tab"} { + if _, _, handled := app.handleDayPreviewKeys(&app.sessSplit, key); handled { + t.Errorf("day pane swallowed %q", key) + } + } +} + +// selectedConvEntryUUID returns the uuid of the first transcript entry covered +// by the conversation row under the cursor. +func selectedConvEntryUUID(a *App) string { + items := a.convList.VisibleItems() + idx := a.convList.Index() + if idx < 0 || idx >= len(items) { + return "" + } + ci, ok := items[idx].(convItem) + if !ok || ci.kind != convMsg { + return "" + } + if ci.merged.startIdx < 0 || ci.merged.startIdx >= len(a.conv.messages) { + return "" + } + return a.conv.messages[ci.merged.startIdx].UUID +} diff --git a/internal/tui/live_preview_test.go b/internal/tui/live_preview_test.go index c9c8d56..1518206 100644 --- a/internal/tui/live_preview_test.go +++ b/internal/tui/live_preview_test.go @@ -404,6 +404,7 @@ func TestPreviewModeConstants(t *testing.T) { sessPreviewShells, sessPreviewContexts, sessPreviewRefs, + sessPreviewOutputs, sessPreviewLive, sessPreviewRemote, } diff --git a/internal/tui/outputs.go b/internal/tui/outputs.go new file mode 100644 index 0000000..e71469e --- /dev/null +++ b/internal/tui/outputs.go @@ -0,0 +1,338 @@ +package tui + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sendbird/ccx/internal/session" +) + +// The Outputs digest answers "what came out of this session?" — the durable +// results (PRs, Jira issues, published artifacts, plans, memory notes) followed +// by the working material (edited files, scratchpad). It is deliberately not a +// second conversation view: every row is a thing that outlived the session, and +// Enter takes you either to the thing itself (a URL) or back to the moment in +// the transcript that produced it. + +// updateSessionOutputsPreview renders the Outputs digest for a session. Refs +// come from the session store (the async extract/resolve pipeline owns their +// status) and everything else is collected from the transcript plus disk. +// +// Collecting is off the UI thread: scanning a large transcript takes hundreds +// of milliseconds (a 100MB session measured ~790ms), and doing that inline +// would freeze navigation on every arrow key. The first visit renders a +// placeholder and dispatches outputsCollectedMsg; subsequent visits hit the +// per-session memo. Refs that have not been extracted yet get the same offline +// extract the References preview uses. +func (a *App) updateSessionOutputsPreview(sess session.Session) tea.Cmd { + return a.buildOutputsPreview(sess, true) +} + +// refreshOutputsPreviewLayout re-renders the digest at the current pane size +// WITHOUT dispatching any work. The render path (View) cannot dispatch a +// tea.Cmd, so calling the dispatching variant there would arm an in-flight +// latch whose command is then dropped — stranding the pane on "scanning +// transcript…" with nothing left to clear the latch. Resize only needs the +// layout recomputed from already-collected rows. +func (a *App) refreshOutputsPreviewLayout(sess session.Session) { + _ = a.buildOutputsPreview(sess, false) +} + +// buildOutputsPreview renders the digest. When dispatch is false it never arms +// a latch and never returns work; see refreshOutputsPreviewLayout. +func (a *App) buildOutputsPreview(sess session.Session, dispatch bool) tea.Cmd { + // The list widget's copy is a snapshot from the last rebuild; the store is + // the source of truth for lazily-resolved refs (see updateSessionRefsPreview). + if fresh, ok := a.sessionByIDFromStore(sess.ID); ok { + sess = fresh + } + + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + + if a.sessOutputsCacheID != sess.ID { + a.sessOutputsCursor = 0 + a.sessOutputsCacheID = sess.ID + a.sessOutputs = nil + a.sessOutputsCollected = "" + } + + var cmds []tea.Cmd + + // Live sessions grow after the scan set HasRefs, so treat them as + // possibly-having-refs and extract off the file (the refs-preview rationale). + mayHaveRefs := sess.HasRefs || sess.IsLive + if dispatch && len(sess.Refs) == 0 && mayHaveRefs && !sess.RefsResolved && !a.refsInFlight[sess.ID] { + if cmd := a.extractSessionRefsCmd(sess.ID, sess.FilePath); cmd != nil { + a.refsInFlight[sess.ID] = true + cmds = append(cmds, cmd) + } + } + + // A transcript that grew invalidates the collection; the ref count is not + // part of the key because refs are merged in at render time, not collected. + dataKey := fmt.Sprintf("%s:%d", sess.ID, sess.ModTime.UnixNano()) + if dispatch && a.sessOutputsCollected != dataKey && !a.outputsInFlight[sess.ID] { + a.outputsInFlight[sess.ID] = true + cmds = append(cmds, collectOutputsCmd(sess, dataKey)) + } + + collecting := a.sessOutputsCollected != dataKey + rows := a.mergeRefOutputs(a.sessOutputs, sess.Refs) + if a.sessOutputsCursor >= len(rows) { + a.sessOutputsCursor = 0 + } + a.sessOutputsRows = rows + + renderKey := fmt.Sprintf("%s:%d:%d:%d:%t:%t", dataKey, len(rows), previewW, a.sessOutputsCursor, a.sessSplit.Focus, collecting) + if a.sessOutputsCacheKey != renderKey { + a.sessOutputsCache = a.renderOutputs(rows, previewW, collecting, mayHaveRefs && !sess.RefsResolved) + a.sessOutputsCacheKey = renderKey + } + + if a.sessSplit.Preview.Width != previewW || a.sessSplit.Preview.Height != contentH { + a.sessSplit.Preview = viewport.New(previewW, contentH) + } + a.sessSplit.Preview.SetContent(a.sessOutputsCache) + return tea.Batch(cmds...) +} + +// outputsCollectedMsg carries the transcript/disk-derived outputs for a session +// (refs excluded — those arrive through the ref pipeline and are merged in at +// render time). dataKey stamps the transcript state the collection reflects, so +// a result for a since-modified session is recognized as stale. +type outputsCollectedMsg struct { + id string + dataKey string + outputs []session.SessionOutput +} + +// collectOutputsCmd scans a session's transcript and filesystem state off the +// UI thread. +func collectOutputsCmd(sess session.Session, dataKey string) tea.Cmd { + return func() tea.Msg { + home, _ := os.UserHomeDir() + outs := session.CollectSessionOutputs(sess, home) + outs = append(outs, session.PlanFileOutputs(sess, home, outs)...) + return outputsCollectedMsg{id: sess.ID, dataKey: dataKey, outputs: outs} + } +} + +// mergeRefOutputs returns the collected outputs plus the session's references +// as rows, in display order. Refs are merged here rather than in the collector +// so a ref whose status resolves later re-renders without a rescan. +func (a *App) mergeRefOutputs(collected []session.SessionOutput, refs []session.SessionRef) []session.SessionOutput { + rows := make([]session.SessionOutput, 0, len(collected)+len(refs)) + rows = append(rows, collected...) + for _, r := range refs { + rows = append(rows, session.RefOutput(r)) + } + session.SortOutputs(rows) + return rows +} + +// outputSection maps an output kind to its section heading. Kinds are emitted +// in SortOutputs order, so a heading is written whenever the kind changes. +func outputSection(k session.OutputKind) string { + switch k { + case session.OutputPR: + return "Pull Requests" + case session.OutputJira: + return "Jira Issues" + case session.OutputArtifact: + return "Artifacts" + case session.OutputPlan: + return "Plans" + case session.OutputMemory: + return "Memory" + case session.OutputChange: + return "Files Changed" + case session.OutputScratchpad: + return "Scratchpad" + } + return "" +} + +// outputGlyph returns the colored marker for an output row. Refs reuse their +// lifecycle coloring so an open PR still reads as open here. +func outputGlyph(o session.SessionOutput) string { + switch o.Kind { + case session.OutputPR, session.OutputJira, session.OutputArtifact: + if o.Ref != nil { + dot, _ := refStateBadge(*o.Ref) + return dot + } + return dimStyle.Render("○") + case session.OutputPlan: + return planBadge.Render("◆") + case session.OutputMemory: + return memoryBadge.Render("◆") + case session.OutputChange: + return doneBadgeStyle.Render("▸") + case session.OutputScratchpad: + return dimStyle.Render("▸") + } + return dimStyle.Render("○") +} + +// renderOutputs draws the digest. collecting is true while the transcript scan +// is still running and resolvingRefs while ref status is in flight, so an +// incomplete list never reads as a final answer. +func (a *App) renderOutputs(outs []session.SessionOutput, width int, collecting, resolvingRefs bool) string { + var sb strings.Builder + title := "── Outputs ──" + if len(outs) > 0 && a.sessSplit.Focus { + title = "── Outputs ↵:open y:copy ──" + } + sb.WriteString(statTitleStyle.Render(title) + "\n") + if collecting { + sb.WriteString(dimStyle.Render("scanning transcript…") + "\n") + } + if resolvingRefs { + sb.WriteString(dimStyle.Render("resolving PR/Jira status…") + "\n") + } + sb.WriteString("\n") + + if len(outs) == 0 { + if collecting { + return sb.String() + } + sb.WriteString(dimStyle.Render("This session produced no plans, memory, files, or references.")) + return sb.String() + } + + lastKind := session.OutputKind("") + for i, o := range outs { + if o.Kind != lastKind { + if lastKind != "" { + sb.WriteString("\n") + } + sb.WriteString(dimStyle.Bold(true).Render(outputSection(o.Kind)) + "\n") + lastKind = o.Kind + } + selected := i == a.sessOutputsCursor && a.sessSplit.Focus + sb.WriteString(outputLine(o, width, selected) + "\n") + } + return strings.TrimRight(sb.String(), "\n") +} + +// outputLine renders one digest row: cursor, kind glyph, title, then a dimmed +// detail and relative time. The detail is truncated to whatever the pane has +// left so a long path never wraps the row into two lines. +func outputLine(o session.SessionOutput, width int, selected bool) string { + cursor := " " + titleStyle := lipgloss.NewStyle().Bold(true) + if selected { + cursor = lipgloss.NewStyle().Foreground(colorBorderFocused).Bold(true).Render("> ") + titleStyle = titleStyle.Foreground(colorBorderFocused) + } + head := cursor + outputGlyph(o) + " " + titleStyle.Render(o.Title) + + suffix := "" + if o.Count > 1 { + suffix += dimStyle.Render(fmt.Sprintf(" ×%d", o.Count)) + } + if !o.Last.IsZero() { + suffix += dimStyle.Render(" · " + timeAgo(o.Last)) + } + + if o.Detail == "" { + return head + suffix + } + // Reserve the head and suffix, then fit the detail into what's left; below + // a usable minimum the detail is dropped rather than shown as an ellipsis. + avail := width - lipgloss.Width(head) - lipgloss.Width(suffix) - 2 + if avail < 8 { + return head + suffix + } + return head + " " + dimStyle.Render(truncate(o.Detail, avail)) + suffix +} + +// handleOutputsPreviewKeys drives the Outputs digest when the preview pane has +// focus: cursor movement plus Enter/o to open and y to copy the row's target. +func (a *App) handleOutputsPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cmd, bool) { + switch key { + case "enter", "o": + return a.openSelectedOutput() + case a.keymap.Actions.CopyPath, "y": + return a.copySelectedOutput() + case "/": + sp.Focus = false + return a, startListSearch(&a.sessionList), true + } + switch HandleFlatCursorNav(&a.sessOutputsCursor, len(a.sessOutputsRows), key) { + case NavCursorMoved: + a.sessOutputsCacheKey = "" // re-render so the cursor highlight moves + if sess, ok := a.selectedSession(); ok { + return a, a.updateSessionOutputsPreview(sess), true + } + return a, nil, true + case NavBoundaryDown, NavBoundaryUp: + // Boundary crossing is disabled in the sessions preview (refs/agents do + // the same) — the list pane keeps ownership of leaving the pane. + return a, nil, true + } + if scrollViewport(&sp.Preview, key) { + return a, nil, true + } + return a, nil, false +} + +// selectedOutput returns the digest row under the cursor. +func (a *App) selectedOutput() (session.SessionOutput, bool) { + if a.sessOutputsCursor < 0 || a.sessOutputsCursor >= len(a.sessOutputsRows) { + return session.SessionOutput{}, false + } + return a.sessOutputsRows[a.sessOutputsCursor], true +} + +// openSelectedOutput opens the output under the cursor: external refs go to the +// browser, and everything else jumps back to the conversation entry that +// produced it — the "why does this file exist" question the digest raises. +func (a *App) openSelectedOutput() (tea.Model, tea.Cmd, bool) { + o, ok := a.selectedOutput() + if !ok { + return a, nil, true + } + if o.URL != "" { + if err := a.openInBrowser(o.URL); err != nil { + a.copiedMsg = "Open failed: " + err.Error() + } else { + a.copiedMsg = "Opened " + o.Title + } + return a, nil, true + } + if o.MessageUUID == "" { + // Discovered on disk (scratchpad, a memory note with no recorded write): + // there is no transcript entry to jump to, so surface the path instead + // of silently doing nothing. + a.copiedMsg = o.Path + return a, nil, true + } + // Jump against the session the digest is tracking, not whatever the list + // cursor resolves to — on a day row those differ. + m, cmd := a.jumpToSessionEntry(a.sessOutputsCacheID, o.MessageUUID) + return m, cmd, true +} + +// copySelectedOutput copies the output's URL, or its path when it is a file. +func (a *App) copySelectedOutput() (tea.Model, tea.Cmd, bool) { + o, ok := a.selectedOutput() + if !ok { + return a, nil, true + } + target := o.URL + if target == "" { + target = o.Path + } + if target == "" { + return a, nil, true + } + copyToClipboard(target) + a.copiedMsg = "Copied " + o.Title + return a, nil, true +} diff --git a/internal/tui/remote.go b/internal/tui/remote.go index 3927e6c..d86c68e 100644 --- a/internal/tui/remote.go +++ b/internal/tui/remote.go @@ -40,6 +40,10 @@ func (a *App) injectRemoteSessions(sessions []session.Session) []session.Session } // cleanupStaleRemoteSessions removes saved sessions whose remote no longer exists. +// +// Every saved remote costs a network round-trip here, and an unreachable host +// costs the whole ConnectTimeout — so this must never run on the startup path. +// cleanupStaleRemotesCmd is the way in. func cleanupStaleRemoteSessions() { saved := remote.LoadSavedSessions() var kept []remote.SavedSession @@ -68,6 +72,21 @@ func cleanupStaleRemoteSessions() { } } +// cleanupStaleRemotesCmd runs the staleness sweep off the startup path. It +// reports whether anything was dropped so the caller can rebuild the list only +// when the saved set actually changed. +func cleanupStaleRemotesCmd() tea.Cmd { + return func() tea.Msg { + before := len(remote.LoadSavedSessions()) + cleanupStaleRemoteSessions() + after := len(remote.LoadSavedSessions()) + return remotesCleanedMsg{changed: after != before} + } +} + +// remotesCleanedMsg reports the result of the async staleness sweep. +type remotesCleanedMsg struct{ changed bool } + func loadSavedRemoteSessions() []session.Session { saved := remote.LoadSavedSessions() var sessions []session.Session diff --git a/internal/tui/remote_startup_test.go b/internal/tui/remote_startup_test.go new file mode 100644 index 0000000..c46064c --- /dev/null +++ b/internal/tui/remote_startup_test.go @@ -0,0 +1,102 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +// writeSavedRemote drops one saved remote session into the temp HOME that +// TestMain set up. The host is deliberately unroutable so any synchronous ping +// would have to wait out its connect timeout. +func writeSavedRemote(t *testing.T) { + t.Helper() + dir := filepath.Join(os.Getenv("HOME"), ".config", "ccx") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := "" + + "- pod_name: ssh-dead-host\n" + + " transport: ssh\n" + + " host: ccx-test-unreachable.invalid\n" + + " local_dir: /tmp/repo-remote\n" + + " status: unreachable\n" + path := filepath.Join(dir, "remote-sessions.yaml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + t.Cleanup(func() { os.Remove(path) }) +} + +func TestNewAppDoesNotPingRemotesOnStartup(t *testing.T) { + // A saved remote whose host is gone used to cost the full SSH + // ConnectTimeout (~2.5s) inside NewApp, before ccx painted anything. + writeSavedRemote(t) + + start := time.Now() + app := NewApp([]session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now()}, + }, Config{}) + elapsed := time.Since(start) + + // Generous bound: the point is "no network round-trip", not a benchmark. + if elapsed > time.Second { + t.Fatalf("NewApp took %v — it is pinging remotes on the startup path", elapsed) + } + // The saved remote must still show up as a row; only the liveness check moved. + if !app.hasRemoteSessions() { + t.Fatal("expected the saved remote session to be restored as a virtual row") + } +} + +func TestInitDispatchesRemoteCleanupWhenRemotesExist(t *testing.T) { + writeSavedRemote(t) + app := NewApp([]session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now()}, + }, Config{}) + + cmd := app.Init() + if cmd == nil { + t.Fatal("expected Init to return commands") + } + // Assert the command was *composed*, not that it ran: executing it would + // make a real SSH connection. + batch, ok := cmd().(tea.BatchMsg) + if !ok { + t.Fatalf("expected a batch of startup commands, got %T", cmd()) + } + if len(batch) < 2 { + t.Fatalf("expected the remote sweep alongside the scan, got %d commands", len(batch)) + } +} + +func TestRemotesCleanedDropsVanishedRows(t *testing.T) { + writeSavedRemote(t) + app := NewApp([]session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now()}, + }, Config{}) + m, _ := app.Update(tea.WindowSizeMsg{Width: 160, Height: 50}) + app = m.(*App) + if !app.hasRemoteSessions() { + t.Fatal("expected the remote row before the sweep") + } + + // The sweep found the remote gone and rewrote the saved set. + os.Remove(filepath.Join(os.Getenv("HOME"), ".config", "ccx", "remote-sessions.yaml")) + m, _ = app.Update(remotesCleanedMsg{changed: true}) + app = m.(*App) + + if app.hasRemoteSessions() { + t.Fatal("expected the vanished remote's row to be dropped") + } + for _, s := range app.sessions { + if s.ID == "a1" { + return + } + } + t.Fatal("expected local sessions to survive the sweep") +} diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index eb0f4c8..9eeedb7 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -24,7 +24,8 @@ const ( groupFork = 4 groupBaseProject = 5 groupProjectCentric = 6 // project rows are first-class, sessions are children - numGroupModes = 7 + groupDaily = 7 // date rows are first-class, sessions are children + numGroupModes = 8 ) // buildGroupedItems returns list items for the given group mode. @@ -33,6 +34,15 @@ const ( // sessionItem.groupKey/groupChildren on parent rows so the renderer can show // a fold chevron and so handlers can find which key to toggle. func buildGroupedItems(sessions []session.Session, groupMode int, folded map[string]bool, worktreeDir ...string) []list.Item { + // The daily view is a chronological journal: splitting the current window + // out would emit the same date twice (once per section), and both rows + // would share one fold key — folding either would fold both. Days are also + // the one grouping where "where am I sitting right now" is not the + // organizing question, so the split has nothing to offer here. + if groupMode == groupDaily { + return buildDailyItems(sessions, folded) + } + currentSessions, rest := splitCurrentWindow(sessions) buildForMode := func(ss []session.Session) []list.Item { @@ -215,6 +225,45 @@ type projectItem struct { bestTime time.Time // most-recent ModTime in this project expanded bool // current fold state at build time lifecycle session.LifecycleState + // dayKey is set only in the daily view, where a project row lives under a + // date and its fold state must be scoped to that day (the same project + // appears under many dates). Empty in the project-centric view. + dayKey string + treeDepth int // indent level; 1 under a date row, 0 at top level + treeLast bool // last project under its day (└─ vs ├─) +} + +// treeConnector returns the tree prefix for a child row, plus its width in +// cells. Depth only decides whether a connector is drawn at all — the geometry +// is deliberately flat, matching what every group mode has always rendered. +// +// last picks the glyph: └─ for the last child of its parent, ├─ otherwise. +func treeConnector(depth int, last bool) (prefix string, width int) { + if depth <= 0 { + return "", 0 + } + if last { + return "└─ ", 3 + } + return "├─ ", 3 // "├─ " is 3 cells wide +} + +// truncCells truncates s to a display-cell budget. +// +// It replaces byte slicing (s[:maxW-3]), which measured multibyte names in +// bytes against a cell budget and could cut mid-rune, and which panicked +// outright for maxW < 4 — s[:1-3] is s[:-2]. +func truncCells(s string, maxW int) string { + if maxW <= 0 { + return "" + } + if runewidth.StringWidth(s) <= maxW { + return s + } + if maxW <= 3 { + return runewidth.Truncate(s, maxW, "") + } + return runewidth.Truncate(s, maxW, "...") } func (p projectItem) FilterValue() string { @@ -284,10 +333,11 @@ func (d sessionDelegate) sessionCacheKey(m list.Model, index int, si sessionItem } func (d sessionDelegate) projectCacheKey(m list.Model, index int, pi projectItem, selected bool) string { - return fmt.Sprintf("p|%d|%d|%t|%s|%s|%t|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s", + return fmt.Sprintf("p|%d|%d|%t|%s|%s|%t|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s|%s|%d|%t", m.Width(), index, selected, listFilterTerm(m), d.hiddenBadgeKey(), pi.expanded, len(pi.sessions), pi.totalMsgs, pi.liveSessions, pi.bgSessions, pi.monSessions, - pi.inputSessions, pi.stuckCount, pi.waitCount, pi.doneCount, pi.openPRs, pi.basePath) + pi.inputSessions, pi.stuckCount, pi.waitCount, pi.doneCount, pi.openPRs, pi.basePath, + pi.dayKey, pi.treeDepth, pi.treeLast) } // renderProject draws a folder-style row for a project: chevron + name + @@ -393,7 +443,15 @@ func (d sessionDelegate) renderProject(w io.Writer, m list.Model, index int, pi name = highlighted } - line1 := fmt.Sprintf("%s%s%s %s%s %s%s", cursor, projDot, folder, name, br, timeStr, badges) + // In the daily view a project row sits under a date, so it carries a tree + // connector like a session child does — without it the three tiers read as + // one flat list. + treePrefix, _ := treeConnector(pi.treeDepth, pi.treeLast) + if treePrefix != "" { + treePrefix = dimStyle.Render(treePrefix) + } + + line1 := fmt.Sprintf("%s%s%s%s %s%s %s%s", cursor, treePrefix, projDot, folder, name, br, timeStr, badges) // Pad/clamp. if selected { bare := lipgloss.Width(line1) @@ -403,7 +461,7 @@ func (d sessionDelegate) renderProject(w io.Writer, m list.Model, index int, pi line1 = selectedRowStyle.Render(line1) } - line2 := " " + summaryStyled + line2 := strings.Repeat(" ", 8+3*pi.treeDepth) + summaryStyled if selected { bare := lipgloss.Width(line2) if bare < width { @@ -449,6 +507,10 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. d.renderProject(w, m, index, pi) return } + if di, ok := item.(dayItem); ok { + d.renderDay(w, m, index, di) + return + } si, ok := item.(sessionItem) if !ok { return @@ -463,16 +525,11 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. } width := m.Width() - // Tree connector prefix for depth>0 teammates - treePrefix := "" - treePrefixW := 0 - if si.treeDepth > 0 { - connector := "├─ " - if si.treeLast { - connector = "└─ " - } - treePrefix = dimStyle.Render(connector) - treePrefixW = 3 // "├─ " is 3 cells wide + // Tree connector prefix for depth>0 children (teammates, forks, and the + // sessions under a day's project row). + treePrefix, treePrefixW := treeConnector(si.treeDepth, si.treeLast) + if treePrefix != "" { + treePrefix = dimStyle.Render(treePrefix) } // Fold chevron for group-head rows: open when expanded, closed when collapsed. @@ -662,9 +719,8 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. project := "" if filterTerm != "" && maxProjW > 0 { project = highlightSnippet(fullProj, filterTerm, maxProjW, projStyle) - } else if len(fullProj) > maxProjW { - trunc := fullProj[:maxProjW-3] + "..." - project = projStyle.Render(trunc) + } else if runewidth.StringWidth(fullProj) > maxProjW { + project = projStyle.Render(truncCells(fullProj, maxProjW)) } else { project = projStyle.Render(projName) if branch != "" { @@ -681,8 +737,8 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. if filterTerm != "" && maxW > 0 { line2 = promptIndent + highlightSnippet(prompt, filterTerm, maxW, promptStyle) } else { - if maxW > 0 && len(prompt) > maxW { - prompt = prompt[:maxW-3] + "..." + if maxW > 0 { + prompt = truncCells(prompt, maxW) } line2 = promptIndent + promptStyle.Render(prompt) } @@ -731,7 +787,7 @@ func newSessionList(sessions []session.Session, width, height int, groupMode int // Use chain-aware filter for grouped modes so children stay visible // when their parent matches (and vice versa). var base list.FilterFunc - if groupMode == groupChain || groupMode == groupFork || groupMode == groupTree || groupMode == groupBaseProject || groupMode == groupProjectCentric { + if groupMode == groupChain || groupMode == groupFork || groupMode == groupTree || groupMode == groupBaseProject || groupMode == groupProjectCentric || groupMode == groupDaily { base = buildChainAwareFilter(items) } else { base = substringFilter @@ -760,6 +816,8 @@ func wrapPinCurrentWindow(items []list.Item, base list.FilterFunc) list.FilterFu lastProject = -1 case projectItem: lastProject = i + case dayItem: + lastProject = i case sessionItem: if v.sess.IsCurrentWindow { pinned[i] = true @@ -836,11 +894,19 @@ func trimEmptyHeaders(ranks []list.Rank, items []list.Item) []list.Rank { // semantics as substringFilter, so `is:live` (a child-only token) does NOT // count as a parent-identity match and won't reveal all children. func parentIdentityMatches(item list.Item, term string) bool { - pi, ok := item.(projectItem) - if !ok { + var identity string + switch v := item.(type) { + case projectItem: + identity = strings.Join([]string{v.displayName, v.basePath, v.branch, "is:project"}, " ") + case dayItem: + // A date row's own identity is its date, so searching "2026-08-13" or + // "Aug 13" reveals that whole day; `is:live` still only reveals the + // individually-matching sessions under it. + identity = strings.Join([]string{v.dayKey, v.day.Format("Mon Jan 2 2006"), "is:day"}, " ") + default: return false } - identity := strings.ToLower(strings.Join([]string{pi.displayName, pi.basePath, pi.branch, "is:project"}, " ")) + identity = strings.ToLower(identity) terms := strings.Fields(strings.ToLower(term)) if len(terms) == 0 { return false @@ -857,20 +923,39 @@ func parentIdentityMatches(item list.Item, term string) bool { // relationships. When a depth=0 parent matches, all its depth=1 children stay // visible. When a depth=1 child matches, its parent also stays visible. func buildChainAwareFilter(items []list.Item) list.FilterFunc { - // Pre-compute parent-child relationships. + // Pre-compute parent-child relationships. The daily view nests three tiers + // (day → project → session), so a project row is both a child and a parent; + // tracking the last row seen at each depth keeps the chain intact. parentOf := make(map[int]int) // child index → parent index childrenOf := make(map[int][]int) // parent index → child indices - lastParent := -1 + lastParent := -1 // most recent top-level (depth 0) row + lastMid := -1 // most recent depth-1 row (project under a day) + link := func(child, parent int) { + if parent < 0 { + return + } + parentOf[child] = parent + childrenOf[parent] = append(childrenOf[parent], child) + } for i, item := range items { switch v := item.(type) { + case dayItem: + lastParent, lastMid = i, -1 case projectItem: - lastParent = i - case sessionItem: if v.treeDepth == 0 { - lastParent = i - } else if lastParent >= 0 { - parentOf[i] = lastParent - childrenOf[lastParent] = append(childrenOf[lastParent], i) + lastParent, lastMid = i, -1 + continue + } + link(i, lastParent) + lastMid = i + case sessionItem: + switch { + case v.treeDepth == 0: + lastParent, lastMid = i, -1 + case v.treeDepth >= 2 && lastMid >= 0: + link(i, lastMid) + default: + link(i, lastParent) } } } @@ -900,9 +985,14 @@ func buildChainAwareFilter(items []list.Item) list.FilterFunc { expanded := make(map[int]bool) for idx := range matchSet { expanded[idx] = true - // child → parent - if pIdx, ok := parentOf[idx]; ok { - expanded[pIdx] = true + // child → every ancestor. The daily view nests three tiers, so a + // matched session must pull in its project AND that project's day — + // stopping at the immediate parent would orphan the row. + for p, ok := parentOf[idx]; ok; p, ok = parentOf[p] { + if expanded[p] { + break // this ancestor chain is already included + } + expanded[p] = true } // parent → all children, only if the parent matched on its identity if len(childrenOf[idx]) > 0 && parentIdentityMatches(items[idx], term) { @@ -1755,6 +1845,7 @@ func (a *App) helpModalContextRows() (title string, rows []helpRow) { {displayKey(km.Session.Select), "Multi-select"}, {"o / f / F", "Fold group / all / expand all"}, {"s", "Toggle session states shown (live/done/…)"}, + {"D", "Daily view (dates + what each day produced)"}, } } } @@ -1763,6 +1854,15 @@ func (a *App) helpModalContextRows() (title string, rows []helpRow) { // preview, specialized by the active preview mode. func (a *App) sessionsPreviewContextRows() []helpRow { base := []helpRow{{"↑↓ / jk", "Scroll / navigate"}, {"←", "Unfocus"}, {"tab", "Cycle mode"}, {"p", "Page menu"}} + // A date row owns the preview regardless of the selected mode. + if _, ok := a.selectedDay(); ok { + return []helpRow{ + {"↑↓", "Navigate the day's outputs"}, + {"↵ / o", "Open the session that produced it"}, + {displayKey(a.keymap.Actions.CopyPath), "Copy URL"}, + {"←", "Unfocus"}, + } + } switch a.sessPreviewMode { case sessPreviewRefs: return []helpRow{ @@ -1772,6 +1872,13 @@ func (a *App) sessionsPreviewContextRows() []helpRow { {displayKey(a.keymap.Actions.CopyPath), "Copy"}, {"←", "Unfocus"}, } + case sessPreviewOutputs: + return []helpRow{ + {"↑↓", "Navigate outputs"}, + {"↵ / o", "Open ref or jump to producing entry"}, + {displayKey(a.keymap.Actions.CopyPath), "Copy URL / path"}, + {"←", "Unfocus"}, + } case sessPreviewAgents, sessPreviewTasksPlan: return []helpRow{ {"↑↓", "Navigate"}, diff --git a/internal/tui/sessions_tree_test.go b/internal/tui/sessions_tree_test.go new file mode 100644 index 0000000..3e2049f --- /dev/null +++ b/internal/tui/sessions_tree_test.go @@ -0,0 +1,116 @@ +package tui + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/charmbracelet/bubbles/list" + "github.com/sendbird/ccx/internal/session" +) + +// renderRow renders one list item through the session delegate and returns its +// two lines with styling stripped, so assertions read the glyphs the user sees. +func renderRow(t *testing.T, item list.Item, width int) (line1, line2 string) { + t.Helper() + l := list.New([]list.Item{item}, + sessionDelegate{timeW: 6, msgW: 3, rowCache: newSessionRowCache(8)}, width, 10) + var sb strings.Builder + d := sessionDelegate{timeW: 6, msgW: 3, rowCache: newSessionRowCache(8)} + d.Render(&sb, l, 0, item) + lines := strings.SplitN(stripANSI(sb.String()), "\n", 2) + if len(lines) < 2 { + return lines[0], "" + } + return lines[0], lines[1] +} + +// A project row is the LAST child of its day: it must close the run with └─, +// not repeat ├─. renderProject had no way to express "last" at all. +func TestProjectRowRendersLastChildConnector(t *testing.T) { + base := projectItem{ + basePath: "/tmp/repo-a", displayName: "repo-a", dayKey: "2026-08-13", treeDepth: 1, + sessions: []session.Session{{ID: "a1", ModTime: time.Now(), MsgCount: 3}}, + bestTime: time.Now(), + } + + mid := base + mid.treeLast = false + line1, _ := renderRow(t, mid, 120) + if !strings.Contains(line1, "├─") { + t.Fatalf("a non-last project row must render ├─, got %q", line1) + } + + last := base + last.treeLast = true + line1, _ = renderRow(t, last, 120) + if !strings.Contains(line1, "└─") { + t.Fatalf("the last project row under a day must render └─, got %q", line1) + } + if strings.Contains(line1, "├─") { + t.Fatalf("the last project row must not also render ├─, got %q", line1) + } +} + +// The row cache is keyed by everything that changes a row's pixels. treeLast +// flips a glyph, so leaving it out of the key serves a stale ├─ for a row that +// has become the last child. +func TestProjectCacheKeyVariesWithTreeLast(t *testing.T) { + l := list.New(nil, sessionDelegate{}, 100, 10) + d := sessionDelegate{rowCache: newSessionRowCache(8)} + pi := projectItem{basePath: "/tmp/repo-a", displayName: "repo-a", dayKey: "d", treeDepth: 1} + + notLast := d.projectCacheKey(l, 0, pi, false) + pi.treeLast = true + isLast := d.projectCacheKey(l, 0, pi, false) + + if notLast == isLast { + t.Fatalf("project cache key must change with treeLast, both were %q", notLast) + } +} + +// A depth-0 row (every non-daily top-level row) draws no connector at all. +func TestTopLevelRowsDrawNoConnector(t *testing.T) { + line1, _ := renderRow(t, sessionItem{ + sess: session.Session{ID: "s1", ShortID: "s1", ProjectName: "repo", ModTime: time.Now(), MsgCount: 2}, + }, 120) + if strings.Contains(line1, "├─") || strings.Contains(line1, "└─") { + t.Fatalf("a top-level session row must not draw a tree connector, got %q", line1) + } +} + +// Rendering at a narrow width used to panic: prompt[:maxW-3] with maxW in 1..3 +// evaluates to a negative bound ("slice bounds out of range [:-1]"), taking the +// whole render down with it. +func TestRenderSessionSurvivesNarrowWidth(t *testing.T) { + si := sessionItem{sess: session.Session{ + ID: "s1", ShortID: "s1", ProjectName: "repo-a", ModTime: time.Now(), MsgCount: 4, + FirstPrompt: "a first prompt long enough to need truncating", + }} + // Widths on both sides of the old panic threshold (maxW = width-11). + for w := 1; w <= 20; w++ { + renderRow(t, si, w) // panics before the fix + } +} + +// Project names and prompts were sliced by BYTE against a CELL budget, so a +// multibyte name was cut mid-rune and rendered as replacement characters. +func TestRenderSessionTruncatesMultibyteOnRuneBoundary(t *testing.T) { + si := sessionItem{sess: session.Session{ + ID: "s1", ShortID: "s1", ModTime: time.Now(), MsgCount: 4, + ProjectName: strings.Repeat("한글프로젝트", 6), + FirstPrompt: strings.Repeat("한글 프롬프트 ", 12), + }} + for w := 12; w <= 90; w++ { + line1, line2 := renderRow(t, si, w) + for _, ln := range []string{line1, line2} { + if strings.ContainsRune(ln, '�') { + t.Fatalf("width %d: row cut mid-rune (U+FFFD present): %q", w, ln) + } + if !utf8.ValidString(ln) { + t.Fatalf("width %d: row is not valid UTF-8: %q", w, ln) + } + } + } +} diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go index c1163d6..f92e433 100644 --- a/internal/tui/shortcuts.go +++ b/internal/tui/shortcuts.go @@ -1,6 +1,10 @@ package tui -import tea "github.com/charmbracelet/bubbletea" +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) // ShortcutMap maps a key string ("1"-"9") to a command registry name. type ShortcutMap map[string]string @@ -156,6 +160,33 @@ func migrateShortcuts(sc Shortcuts) { } } +// rowSupportsPreviewModes reports whether the row under the cursor can honor a +// preview-mode shortcut. +// +// A date row, and a project row nested inside one, always render that scope's +// outputs pane: updateSessionPreview() routes them to updateDayPreview / +// updateDayProjectPreview without ever consulting sessPreviewMode. Firing a +// preview-mode shortcut there changed hidden state and repainted nothing, so +// the digits looked broken — and shortcutHint() advertised all ten of them +// anyway. Only sessions have preview modes, so only sessions get the digits. +// +// Plain project rows in the non-daily browser are deliberately excluded from +// this check: selectedSession() falls back to the project's most-recent session +// there, and refs/outputs modes preview it directly, so the digits do change +// what is on screen. +func (a *App) rowSupportsPreviewModes() bool { + if a.state != viewSessions { + return true + } + return !a.selectedOwnsDayPane() +} + +// isPreviewModeCmd reports whether a command name is one of the preview-mode +// switches that only apply to a session row. +func isPreviewModeCmd(name string) bool { + return strings.HasPrefix(name, "preview:") +} + // handleShortcutKey checks if a key press matches a shortcut for the current // view and focus side, and executes the corresponding command. // Returns (model, cmd, true) if handled, (nil, nil, false) otherwise. @@ -188,6 +219,13 @@ func (a *App) handleShortcutKey(key string) (tea.Model, tea.Cmd, bool) { return nil, nil, false } + // Swallow rather than fall through: the digit is bound to a preview mode + // the current row cannot show, and letting it reach the list would scroll + // the cursor instead — a second surprise on top of the first. + if isPreviewModeCmd(cmdName) && !a.rowSupportsPreviewModes() { + return a, nil, true + } + entry, found := a.findCmdEntry(cmdName) if !found { return nil, nil, false @@ -277,10 +315,16 @@ func (a *App) shortcutHint() string { } // Build hint in key order (0-9); 0 is rendered first as the quick "live" key. + previewOK := a.rowSupportsPreviewModes() var parts []string for _, i := range "0123456789" { key := string(i) if cmd, ok := sm[key]; ok { + // A hint for a key that does nothing on this row is worse than no + // hint: it is the footer promising a mode the row cannot render. + if isPreviewModeCmd(cmd) && !previewOK { + continue + } // Shorten command name: "preview:conv" -> "conv" short := cmd if idx := len(cmd) - 1; idx > 0 { diff --git a/internal/tui/shortcuts_dayrow_test.go b/internal/tui/shortcuts_dayrow_test.go new file mode 100644 index 0000000..473d4bc --- /dev/null +++ b/internal/tui/shortcuts_dayrow_test.go @@ -0,0 +1,143 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +// dailyShortcutApp builds a daily-view app whose list is +// day → project → sessions, so each tier can be put under the cursor. +func dailyShortcutApp(t *testing.T) *App { + t.Helper() + now := time.Now() + day := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, now.Location()) + sessions := []session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day, MsgCount: 10}, + {ID: "a2", ShortID: "a2", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day.Add(-time.Hour), MsgCount: 5}, + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: day.Add(-2 * time.Hour), MsgCount: 7}, + } + a := newTestApp(sessions) + a.sessGroupMode = groupDaily + a.rebuildSessionList() + return a +} + +// selectRowKind moves the cursor to the first row of the requested kind. +func selectRowKind(t *testing.T, a *App, kind string) { + t.Helper() + for i, it := range a.sessionList.VisibleItems() { + var got string + switch v := it.(type) { + case dayItem: + got = "day" + case projectItem: + got = "project" + if v.dayKey == "" { + got = "plainproject" + } + case sessionItem: + got = "session" + } + if got == kind { + a.sessionList.Select(i) + return + } + } + t.Fatalf("no %s row in the list", kind) +} + +// A date row always renders the day-outputs pane, so the preview-mode digits +// cannot apply to it. Before the fix they mutated sessPreviewMode invisibly. +func TestShortcutDigitsIgnoredOnDayRow(t *testing.T) { + a := dailyShortcutApp(t) + selectRowKind(t, a, "day") + + a.sessPreviewMode = sessPreviewConversation + _, _, handled := a.handleShortcutKey("3") + + if !handled { + t.Fatal("the digit must be swallowed on a day row, not fall through to the list cursor") + } + if a.sessPreviewMode != sessPreviewConversation { + t.Fatalf("preview mode changed on a day row: got %v, want %v (unchanged)", + a.sessPreviewMode, sessPreviewConversation) + } +} + +// A day-scoped project row drives the same pane as its date row. +func TestShortcutDigitsIgnoredOnDayProjectRow(t *testing.T) { + a := dailyShortcutApp(t) + selectRowKind(t, a, "project") + + if !a.selectedOwnsDayPane() { + t.Fatal("fixture is wrong: the selected project row should own the day pane") + } + a.sessPreviewMode = sessPreviewConversation + _, _, handled := a.handleShortcutKey("5") + + if !handled { + t.Fatal("the digit must be swallowed on a day-scoped project row") + } + if a.sessPreviewMode != sessPreviewConversation { + t.Fatalf("preview mode changed on a day-scoped project row: got %v, want %v (unchanged)", + a.sessPreviewMode, sessPreviewConversation) + } +} + +// The hint is the footer's promise about what the digits do. On a row with no +// preview modes it must promise nothing. +func TestShortcutHintDropsPreviewModesOnDayRow(t *testing.T) { + a := dailyShortcutApp(t) + selectRowKind(t, a, "day") + + hint := a.shortcutHint() + for _, advertised := range []string{"agents", "conv", "refs", "stats", "live"} { + if strings.Contains(hint, advertised) { + t.Fatalf("hint on a day row advertises %q, which that row cannot render: %q", advertised, hint) + } + } +} + +// The counterpart: ordinary session rows must keep every digit working. +func TestShortcutDigitsStillSwitchPreviewOnSessionRow(t *testing.T) { + a := dailyShortcutApp(t) + selectRowKind(t, a, "session") + + a.sessPreviewMode = sessPreviewConversation + _, _, handled := a.handleShortcutKey("3") + + if !handled { + t.Fatal("digit shortcuts must stay live on a session row") + } + if a.sessPreviewMode != sessPreviewAgents { + t.Fatalf("key 3 on a session row: got mode %v, want %v (agents)", + a.sessPreviewMode, sessPreviewAgents) + } + if hint := a.shortcutHint(); !strings.Contains(hint, "3:agents") { + t.Fatalf("session row hint must still advertise the preview modes, got %q", hint) + } +} + +// A project head in the non-daily browser is deliberately left alone: its +// preview falls back to the project's most-recent session, so the digits do +// change what is on screen there. +func TestShortcutDigitsStillWorkOnPlainProjectRow(t *testing.T) { + a := newTestApp([]session.Session{ + {ID: "x", ShortID: "x", ProjectPath: "/tmp/repo-x", ProjectName: "repo-x", ModTime: time.Now(), MsgCount: 1}, + }) + a.sessGroupMode = groupProjectCentric + a.rebuildSessionList() + selectRowKind(t, a, "plainproject") + + a.sessPreviewMode = sessPreviewConversation + if _, _, handled := a.handleShortcutKey("3"); !handled { + t.Fatal("digit shortcuts must stay live on a plain project row") + } + if a.sessPreviewMode != sessPreviewAgents { + t.Fatalf("key 3 on a plain project row: got mode %v, want %v (agents)", + a.sessPreviewMode, sessPreviewAgents) + } +} diff --git a/internal/tui/state.go b/internal/tui/state.go index fbf5468..8aa35be 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -14,7 +14,8 @@ import ( // Preferences holds persisted view preferences that survive restarts. type Preferences struct { - GroupMode string `yaml:"group_mode,omitempty"` // flat|proj|tree|chain|fork + GroupMode string `yaml:"group_mode,omitempty"` // flat|proj|tree|chain|fork|repo|projects|daily + PrevGroupMode string `yaml:"prev_group_mode,omitempty"` // grouping `D` returns to when leaving the daily view PreviewMode string `yaml:"preview_mode,omitempty"` // conv|stats|mem|tasks|agents|shells|contexts|live ViewMode string `yaml:"view_mode,omitempty"` // sessions|config|plugins|stats ConvDetailLevel int `yaml:"conv_detail_level,omitempty"` // 0=compact,1=standard,2=verbose @@ -366,6 +367,21 @@ func fillKeymapDefaults(cfg *CCXConfig, d Keymap) { } } +// groupModeNames maps the persisted/CLI name of a grouping to its constant. +// Shared by preference restore and the -group flag so the two can never accept +// different sets of names. +var groupModeNames = map[string]int{ + "flat": groupFlat, "proj": groupProject, "tree": groupTree, + "chain": groupChain, "fork": groupFork, "repo": groupBaseProject, + "projects": groupProjectCentric, "daily": groupDaily, "day": groupDaily, +} + +// groupModeFromString resolves a grouping name, reporting whether it is known. +func groupModeFromString(name string) (int, bool) { + m, ok := groupModeNames[name] + return m, ok +} + // groupModeString converts a group mode int to its string name. func groupModeString(mode int) string { switch mode { @@ -383,6 +399,8 @@ func groupModeString(mode int) string { return "repo" case groupProjectCentric: return "projects" + case groupDaily: + return "daily" } return "" } @@ -410,6 +428,8 @@ func sessPreviewString(mode sessPreview) string { return "contexts" case sessPreviewRefs: return "refs" + case sessPreviewOutputs: + return "outputs" case sessPreviewLive: return "live" } @@ -465,6 +485,7 @@ func (a *App) capturePreferences() Preferences { return Preferences{ GroupMode: groupModeString(a.sessGroupMode), + PrevGroupMode: groupModeString(a.preDailyGroupMode), PreviewMode: sessPreviewString(a.sessPreviewMode), ViewMode: viewStateString(a.state), ConvDetailLevel: int(a.conv.rightPaneMode), @@ -483,6 +504,12 @@ func (a *App) applyPreferences(p Preferences) { if a.config.GroupMode == "" && p.GroupMode != "" { a.config.GroupMode = p.GroupMode } + // Restore where `D` returns to. Without this, a user whose saved grouping is + // daily always lands in the default on toggle-out rather than the view they + // actually work in. + if m, ok := groupModeFromString(p.PrevGroupMode); ok && m != groupDaily { + a.preDailyGroupMode = m + } if a.config.PreviewMode == "" && p.PreviewMode != "" { a.config.PreviewMode = p.PreviewMode } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index d59d255..0140ab7 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -135,6 +135,10 @@ var ( inputBadgeStyle = lipgloss.NewStyle().Foreground(colorFilter).Bold(true) // PR reference badge (open PRs surfaced on the session row) — GitHub purple. prBadgeStyle = lipgloss.NewStyle().Foreground(colorPurple).Bold(true) + // Output badges for the daily view / outputs digest. Jira and artifacts get + // their own hues so a day's rollup is readable without reading the labels. + jiraBadgeStyle = lipgloss.NewStyle().Foreground(colorUser).Bold(true) + artifactBadgeStyle = lipgloss.NewStyle().Foreground(colorTeal).Bold(true) // Status dots that replace the LIVE/BUSY text badges: a single ● before the // session ID. Green = live & idle, amber = busy/responding. liveDotStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true) diff --git a/main.go b/main.go index 1e07674..d6e0556 100644 --- a/main.go +++ b/main.go @@ -259,8 +259,8 @@ func main() { flag.StringVar(&initialFocus, "initial-focus", "", "startup focus strategy: tmux (default: tmux window match, else most recent) | cwd (adds a CWD-based directory-walk fallback before most recent)") flag.StringVar(&worktreeDir, "worktree-dir", ".worktree", "subdirectory name for git worktrees") flag.StringVar(&searchQuery, "search", "", "start with session list filtered by search query") - flag.StringVar(&groupMode, "group", "", "initial group mode (flat|proj|tree|chain|fork)") - flag.StringVar(&previewMode, "preview", "", "initial preview mode (conv|stats|mem|tasks)") + flag.StringVar(&groupMode, "group", "", "initial group mode (flat|proj|tree|chain|fork|repo|projects|daily)") + flag.StringVar(&previewMode, "preview", "", "initial preview mode (conv|stats|mem|scratch|tasks|agents|wf|shells|contexts|refs|outputs)") flag.StringVar(&viewMode, "view", "", "initial view (sessions|config|plugins|stats)") flag.StringVar(&sessionID, "session", "", "open a specific session by ID (prefix match)") flag.Usage = func() {