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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ The preview always shows **what that scope produced** — PRs, Jira issues, arti

Rows read as a **timeline**: every output in the order it first appeared, stamped with that time, kinds interleaved — a day is lived in time, and "what happened after the PR went up" is the question the pane is usually asked. An output whose first mention falls on another date (a long-lived session carrying a ref in) shows its full date rather than a bare time that would belong to the wrong day, and a `~` marks a time taken from the producing session because the output records no entry of its own (plan slugs, and refs extracted by an older build).

To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each kind the scope produced (`PRs`, `Jira`, `Artifacts`, `Plans`), each carrying its own count so the bar doubles as the day's rollup. `tab`/`shift+tab` walks the bar, and the number keys jump straight to a tab — `1` is `All` and the rest follow the bar **as rendered**, so `4` is whatever sits fourth on screen (the bar drops kinds the day produced none of, so a fixed digit-to-kind table would point at labels that are not there). The digits are the same keys that select preview modes on a session row; a day row has no preview modes, so they address the only axis it has. Kinds are tabs rather than sections in one list because a busy day produces 500+ outputs, and stacked sections put the later kinds hundreds of lines below the fold. Every tab keeps the one chronology, and the selected tab is sticky as you walk between dates, so "what PRs did each day produce" stays a single keypress per day; a day with none of that kind says so rather than silently falling back to `All`.
To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each kind the scope produced (`PRs`, `Jira`, `Artifacts`, `Plans`, `Scratchpad`), each carrying its own count so the bar doubles as the day's rollup. `tab`/`shift+tab` walks the bar, and the number keys jump straight to a tab — `1` is `All` and the rest follow the bar **as rendered**, so `4` is whatever sits fourth on screen (the bar drops kinds the day produced none of, so a fixed digit-to-kind table would point at labels that are not there). The digits are the same keys that select preview modes on a session row; a day row has no preview modes, so they address the only axis it has. Kinds are tabs rather than sections in one list because a busy day produces 500+ outputs, and stacked sections put the later kinds hundreds of lines below the fold. Every tab keeps the one chronology, and the selected tab is sticky as you walk between dates, so "what PRs did each day produce" stays a single keypress per day; a day with none of that kind says so rather than silently falling back to `All`.

Scratchpad rows arrive a moment after the rest: refs and plan slugs ride along on the session scan, but a scratchpad is a directory walk per session (one measured scratchpad held 6,805 files), so it is collected off the UI thread and filled in as it lands. Moving between dates stays instant either way.

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), `y` copies its URL or path, and `x` lists every action that applies to the row (see [Output Row Actions](#output-row-actions-x)). 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.

Expand Down
10 changes: 7 additions & 3 deletions internal/session/outputs.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func SortOutputs(outs []SessionOutput) {
// 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 = append(outs, ScratchpadOutputs(sess)...)
outs = mergeMemoryDescriptions(outs, sess, home)
SortOutputs(outs)
return outs
Expand Down Expand Up @@ -237,11 +237,15 @@ func hasOutputToolMarker(line []byte) bool {
return false
}

// collectScratchpadOutputs lists the session's scratchpad files. Unlike the
// ScratchpadOutputs 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 {
//
// Exported because the daily pane collects this per session off the UI thread:
// the walk costs real time (one measured scratchpad held 6,805 files) and a day
// can hold hundreds of sessions.
func ScratchpadOutputs(sess Session) []SessionOutput {
files := LoadScratchpadFiles(sess.ProjectPath, sess.ID)
outs := make([]SessionOutput, 0, len(files))
for _, f := range files {
Expand Down
60 changes: 41 additions & 19 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,12 @@ type App struct {
dayOutputsCursor int // cursor within the day pane's output list
dayOutputsCacheID string // day key the cursor currently tracks
dayOutputTabKind session.OutputKind // day pane's active kind tab ("" = the All timeline)
// Scratchpad rows for the day pane. Unlike refs and plan slugs — which the
// session scan already carries — scratchpad lives on disk and needs a walk
// per session, so it is collected off the UI thread and cached per scope.
dayScratchpad map[string][]session.SessionOutput // session ID → its scratchpad rows
dayScratchpadCollected map[string]bool // session IDs already walked
dayScratchpadInFlight map[string]bool // walks currently running
// The day pane searches independently of the session list: the two answer
// different questions ("which sessions" vs "which outputs"), and a day with
// hundreds of outputs needs narrowing even when the session list does not.
Expand Down Expand Up @@ -910,21 +916,24 @@ func NewApp(sessions []session.Session, cfg Config) *App {
}

a := &App{
state: viewSessions,
sessions: sessions,
sessionsLoading: true, // always true — full scan happens async
config: cfg,
keymap: km,
splitRatio: 35,
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),
convPreviewRowCache: newSessionRowCache(4096),
termFocused: true,
state: viewSessions,
sessions: sessions,
sessionsLoading: true, // always true — full scan happens async
config: cfg,
keymap: km,
splitRatio: 35,
selectedSet: make(map[string]bool),
hiddenBadges: make(map[string]bool),
refsInFlight: make(map[string]bool),
outputsInFlight: make(map[string]bool),
dayScratchpad: make(map[string][]session.SessionOutput),
dayScratchpadCollected: make(map[string]bool),
dayScratchpadInFlight: make(map[string]bool),
sessRefsSelected: make(map[string]bool),
notifyPrev: make(map[string]session.LifecycleState),
sessionRowCache: newSessionRowCache(1024),
convPreviewRowCache: newSessionRowCache(4096),
termFocused: true,
// Default to a true project-centric browser: ccx now opens with one
// row per project (folder-like), and sessions of the same repo (and
// its worktrees) appear as expandable children beneath the project
Expand Down Expand Up @@ -1450,6 +1459,21 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return a, nil

case dayScratchpadMsg:
// One session's scratchpad walk landed. Re-rendering the owning scope
// both shows the new rows and dispatches the next batch, which is how a
// day with more sessions than the in-flight cap finishes collecting.
delete(a.dayScratchpadInFlight, msg.sessID)
a.dayScratchpadCollected[msg.sessID] = true
if len(msg.outputs) > 0 {
a.dayScratchpad[msg.sessID] = msg.outputs
}
if a.state == viewSessions && a.sessSplit.Show && a.selectedOwnsDayPane() {
a.sessSplit.CacheKey = "" // the row set changed under the same scope
return a, a.renderOwningDayScope()
}
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.
Expand Down Expand Up @@ -5512,8 +5536,7 @@ func (a *App) updateSessionPreview() tea.Cmd {
}
a.sessSplit.CacheKey = cacheKey
a.sessPreviewPinned = false
a.updateDayPreview(di)
return nil
return a.updateDayPreview(di)
}
if pi, ok := a.selectedProject(); ok {
// In the daily view a project row is the middle tier: it aggregates one
Expand All @@ -5527,8 +5550,7 @@ func (a *App) updateSessionPreview() tea.Cmd {
}
a.sessSplit.CacheKey = cacheKey
a.sessPreviewPinned = false
a.updateDayProjectPreview(pi)
return nil
return a.updateDayProjectPreview(pi)
}
// In refs/outputs mode a project head row previews its representative
// session (selectedSession returns pi.sessions[0] for a projectItem). The
Expand Down
13 changes: 8 additions & 5 deletions internal/tui/daily_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,12 @@ func TestDailyPreviewShowsDaySummary(t *testing.T) {
}
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)")
}
// The pane must be fully rendered by the time updateSessionPreview returns:
// walking across dates cannot wait on I/O. Any command it returns is
// background enrichment (the scratchpad walk, which needs a directory walk
// per session), never something the content below depends on — so the
// assertion is on the content, not on the absence of a command.
_ = app.updateSessionPreview()
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
Expand Down Expand Up @@ -519,7 +522,7 @@ func TestDayPreviewCollapsesRepeatedOutputs(t *testing.T) {
{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)
rows := buildDayOutputRows(di, nil)

if len(rows) != 1 {
t.Fatalf("expected the repeated PR to collapse to one row, got %d", len(rows))
Expand Down Expand Up @@ -547,7 +550,7 @@ func TestDayPreviewTabsCoverEveryKindProduced(t *testing.T) {
},
}}
di := buildDailyItems(sessions, nil)[0].(dayItem)
rows := buildDayOutputRows(di)
rows := buildDayOutputRows(di, nil)

tabs := dayOutputTabsFor(rows, "")
var got []string
Expand Down
81 changes: 68 additions & 13 deletions internal/tui/daypane.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,52 @@ var dayOutputTabOrder = []dayOutputTab{
{label: "Jira", kind: session.OutputJira},
{label: "Artifacts", kind: session.OutputArtifact},
{label: "Plans", kind: session.OutputPlan},
{label: "Scratchpad", kind: session.OutputScratchpad},
}

// dayScratchpadMsg carries one session's scratchpad rows back to the UI thread.
type dayScratchpadMsg struct {
sessID string
outputs []session.SessionOutput
}

// collectDayScratchpadCmd walks one session's scratchpad off the UI thread.
//
// Refs and plan slugs ride along on the session scan, but scratchpad lives on
// disk and costs a directory walk per session — one measured scratchpad here
// held 6,805 files. A day holds 250+ sessions, so doing this inline would stall
// the pane on every date change; results are cached per session ID and survive
// walking away and back.
func collectDayScratchpadCmd(sess session.Session) tea.Cmd {
return func() tea.Msg {
return dayScratchpadMsg{
sessID: sess.ID,
outputs: session.ScratchpadOutputs(sess),
}
}
}

// dayScratchpadCmds dispatches a walk for each session in scope that has not
// been collected yet, capped so one very busy day cannot fan out unboundedly.
// Sessions left over are picked up on the next render, since each completion
// re-renders the pane.
func (a *App) dayScratchpadCmds(sessions []session.Session) []tea.Cmd {
const maxInFlight = 8
var cmds []tea.Cmd
for _, s := range sessions {
if len(cmds) >= maxInFlight {
break
}
if s.ID == "" || s.ProjectPath == "" {
continue
}
if a.dayScratchpadCollected[s.ID] || a.dayScratchpadInFlight[s.ID] {
continue
}
a.dayScratchpadInFlight[s.ID] = true
cmds = append(cmds, collectDayScratchpadCmd(s))
}
return cmds
}

// dayOutputTabsFor returns All plus a tab for every kind the scope actually
Expand Down Expand Up @@ -164,7 +210,7 @@ func dayOutputTabIndex(tabs []dayOutputTab, active session.OutputKind) int {
// (session.SortRefs), so the timeline only exists because of the explicit sort
// below. Kind filtering is a separate step (filterDayOutputRows) so every tab
// keeps this one order.
func buildDayOutputRows(di dayItem) []dayOutputRow {
func buildDayOutputRows(di dayItem, scratchpad map[string][]session.SessionOutput) []dayOutputRow {
var rows []dayOutputRow
byKey := map[string]int{} // identity → index into rows
add := func(o session.SessionOutput, s session.Session, ts time.Time, approx bool) {
Expand Down Expand Up @@ -200,6 +246,13 @@ func buildDayOutputRows(di dayItem) []dayOutputRow {
Kind: session.OutputPlan, Title: slug, Last: s.ModTime, Count: 1,
}, s, s.ModTime, true)
}
// Scratchpad arrives asynchronously (see collectDayScratchpadCmd), so a
// session not yet walked simply contributes nothing this render and the
// rows appear when its walk lands.
for _, o := range scratchpad[s.ID] {
ts, approx := outputWhen(o.Last, s)
add(o, s, ts, approx)
}
}
sortDayOutputRowsByTime(rows)
return rows
Expand Down Expand Up @@ -259,7 +312,7 @@ func outputKindRank(k session.OutputKind) int {
// 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) {
func (a *App) updateDayPreview(di dayItem) tea.Cmd {
previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1)
contentH := max(a.height-3, 1)

Expand All @@ -275,7 +328,7 @@ func (a *App) updateDayPreview(di dayItem) {
// outputs behind a filter the user is no longer thinking about.
a.dayOutputQuery = ""
}
all := buildDayOutputRows(di)
all := buildDayOutputRows(di, a.dayScratchpad)

// Recreate the viewport only on a size change. Rebuilding it every call
// would reset YOffset to 0, and since cursor movement re-renders, every
Expand All @@ -289,12 +342,13 @@ func (a *App) updateDayPreview(di dayItem) {
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, di.day, all, previewW))
return tea.Batch(a.dayScratchpadCmds(di.sessions)...)
}

// 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) {
func (a *App) updateDayProjectPreview(pi projectItem) tea.Cmd {
previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1)
contentH := max(a.height-3, 1)

Expand All @@ -304,7 +358,7 @@ func (a *App) updateDayProjectPreview(pi projectItem) {
a.dayOutputsCacheID = cacheID
a.dayOutputQuery = "" // see updateDayPreview: queries do not travel
}
all := buildDayOutputRows(dayItem{sessions: pi.sessions})
all := buildDayOutputRows(dayItem{sessions: pi.sessions}, a.dayScratchpad)

if a.sessSplit.Preview.Width != previewW || a.sessSplit.Preview.Height != contentH {
a.sessSplit.Preview = viewport.New(previewW, contentH)
Expand All @@ -316,6 +370,7 @@ func (a *App) updateDayProjectPreview(pi projectItem) {
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, dayKeyTime(pi.dayKey), all, previewW))
return tea.Batch(a.dayScratchpadCmds(pi.sessions)...)
}

// dayKeyTime parses a "2006-01-02" fold key back into a local date. A zero time
Expand Down Expand Up @@ -517,7 +572,7 @@ func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cm
switch HandleFlatCursorNav(&a.dayOutputsCursor, len(a.dayOutputRows), key) {
case NavCursorMoved:
a.sessSplit.CacheKey = "" // force the day pane to re-render with the new highlight
a.renderOwningDayScope()
cmd := a.renderOwningDayScope()
// Nudge the viewport so the cursor stays in view as it walks past the
// fold (the tasks/agents preview does the same).
switch key {
Expand All @@ -526,7 +581,7 @@ func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cm
case "down", "j":
sp.Preview.LineDown(1)
}
return a, nil, true
return a, cmd, true
case NavBoundaryDown, NavBoundaryUp:
return a, nil, true
}
Expand Down Expand Up @@ -603,25 +658,25 @@ func (a *App) dayOutputTabHint() string {
// a.dayOutputRows no longer knows.
func (a *App) currentDayOutputRows() []dayOutputRow {
if di, ok := a.selectedDay(); ok {
return buildDayOutputRows(di)
return buildDayOutputRows(di, a.dayScratchpad)
}
if pi, ok := a.selectedProject(); ok && pi.dayKey != "" {
return buildDayOutputRows(dayItem{sessions: pi.sessions})
return buildDayOutputRows(dayItem{sessions: pi.sessions}, a.dayScratchpad)
}
return nil
}

// renderOwningDayScope re-renders whichever scope owns the day pane. A
// day-scoped PROJECT row owns it too (selectedOwnsDayPane), and rendering only
// the day case left the pane frozen on those rows.
func (a *App) renderOwningDayScope() {
func (a *App) renderOwningDayScope() tea.Cmd {
if di, ok := a.selectedDay(); ok {
a.updateDayPreview(di)
return
return a.updateDayPreview(di)
}
if pi, ok := a.selectedProject(); ok && pi.dayKey != "" {
a.updateDayProjectPreview(pi)
return a.updateDayProjectPreview(pi)
}
return nil
}

func (a *App) selectedDayOutput() (dayOutputRow, bool) {
Expand Down
Loading
Loading