Skip to content

Commit 569acb9

Browse files
jongioCopilot
andcommitted
feat: add frecency sort that ranks sessions by launch frequency and recency
Records a launch count and last-launched time per session from the TUI and dispatch open, then adds a frecency sort field that blends launch frequency with a recency decay. Sessions with no launch history keep updated-time order. Closes #230 Co-authored-by: Copilot App <[email protected]>
1 parent 760653d commit 569acb9

9 files changed

Lines changed: 278 additions & 9 deletions

File tree

internal/config/config.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func (v *NamedView) Validate() error {
7979
}
8080
if v.Sort != "" {
8181
switch v.Sort {
82-
case SortFieldUpdated, SortFieldCreated, SortFieldTurns, SortFieldName, SortFieldFolder:
82+
case SortFieldUpdated, SortFieldCreated, SortFieldTurns, SortFieldName, SortFieldFolder, SortFieldFrecency:
8383
default:
8484
return fmt.Errorf("named view %q: invalid sort %q", v.Name, v.Sort)
8585
}
@@ -202,6 +202,11 @@ type Config struct {
202202
// by a marker in the session list.
203203
SessionNotes map[string]string `json:"sessionNotes,omitempty"`
204204

205+
// SessionLaunches maps session IDs to their launch statistics. It is
206+
// used by the frecency sort to rank sessions the user launches often
207+
// and recently ahead of ones they rarely open.
208+
SessionLaunches map[string]SessionLaunch `json:"sessionLaunches,omitempty"`
209+
205210
// AISearch enables Copilot SDK-powered AI search. When false (the
206211
// default), only the local FTS5 index is used. Set to true to also
207212
// query the Copilot backend for semantically relevant sessions.
@@ -338,11 +343,12 @@ const (
338343

339344
// Sort field constants for NamedView.Sort and DefaultSort.
340345
const (
341-
SortFieldUpdated = "updated"
342-
SortFieldCreated = "created"
343-
SortFieldTurns = "turns"
344-
SortFieldName = "name"
345-
SortFieldFolder = "folder"
346+
SortFieldUpdated = "updated"
347+
SortFieldCreated = "created"
348+
SortFieldTurns = "turns"
349+
SortFieldName = "name"
350+
SortFieldFolder = "folder"
351+
SortFieldFrecency = "frecency"
346352
)
347353

348354
// Pivot mode constants for NamedView.Pivot.

internal/config/frecency.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package config
2+
3+
import (
4+
"math"
5+
"time"
6+
)
7+
8+
// frecencyHalfLife is the age at which a session's launch weight decays to
9+
// half. A week strikes a balance between surfacing recent work and keeping
10+
// long-running favorites near the top.
11+
const frecencyHalfLife = 7 * 24 * time.Hour
12+
13+
// SessionLaunch records how many times a session has been launched from
14+
// Dispatch and when it was last launched. It powers the frecency sort.
15+
type SessionLaunch struct {
16+
// Count is the total number of times the session has been launched.
17+
Count int `json:"count"`
18+
19+
// Last is the Unix time (seconds) of the most recent launch.
20+
Last int64 `json:"last"`
21+
}
22+
23+
// RecordLaunch increments the launch count for a session and stamps the launch
24+
// time. now is passed in so callers and tests control the clock. Empty session
25+
// IDs are ignored so new sessions started without an ID do not pollute stats.
26+
func (c *Config) RecordLaunch(sessionID string, now time.Time) {
27+
if sessionID == "" {
28+
return
29+
}
30+
if c.SessionLaunches == nil {
31+
c.SessionLaunches = make(map[string]SessionLaunch)
32+
}
33+
st := c.SessionLaunches[sessionID]
34+
st.Count++
35+
st.Last = now.Unix()
36+
c.SessionLaunches[sessionID] = st
37+
}
38+
39+
// FrecencyScore returns a ranking score for a session's launch history. Higher
40+
// scores rank first. The score is the launch count scaled by an exponential
41+
// recency decay, so a session launched often and recently outranks one that is
42+
// launched often but long ago, or recently but only once. A session with no
43+
// launches scores zero, which lets the sort fall back to its incoming order.
44+
func FrecencyScore(st SessionLaunch, now time.Time) float64 {
45+
if st.Count <= 0 {
46+
return 0
47+
}
48+
age := now.Sub(time.Unix(st.Last, 0))
49+
if age < 0 {
50+
age = 0
51+
}
52+
decay := math.Exp2(-age.Hours() / frecencyHalfLife.Hours())
53+
return float64(st.Count) * decay
54+
}

internal/config/frecency_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestRecordLaunch(t *testing.T) {
9+
c := &Config{}
10+
base := time.Unix(1_000_000, 0)
11+
c.RecordLaunch("s1", base)
12+
if got := c.SessionLaunches["s1"].Count; got != 1 {
13+
t.Fatalf("Count = %d, want 1", got)
14+
}
15+
if got := c.SessionLaunches["s1"].Last; got != base.Unix() {
16+
t.Errorf("Last = %d, want %d", got, base.Unix())
17+
}
18+
later := base.Add(time.Hour)
19+
c.RecordLaunch("s1", later)
20+
if got := c.SessionLaunches["s1"].Count; got != 2 {
21+
t.Errorf("Count = %d, want 2", got)
22+
}
23+
if got := c.SessionLaunches["s1"].Last; got != later.Unix() {
24+
t.Errorf("Last = %d, want %d", got, later.Unix())
25+
}
26+
}
27+
28+
func TestRecordLaunchIgnoresEmptyID(t *testing.T) {
29+
c := &Config{}
30+
c.RecordLaunch("", time.Now())
31+
if len(c.SessionLaunches) != 0 {
32+
t.Errorf("empty ID should not be recorded, got %d entries", len(c.SessionLaunches))
33+
}
34+
}
35+
36+
func TestFrecencyScoreZeroWithoutLaunches(t *testing.T) {
37+
if s := FrecencyScore(SessionLaunch{}, time.Now()); s != 0 {
38+
t.Errorf("score = %v, want 0 for no launches", s)
39+
}
40+
}
41+
42+
func TestFrecencyScoreRanksMoreLaunchesHigher(t *testing.T) {
43+
now := time.Now()
44+
last := now.Unix()
45+
few := FrecencyScore(SessionLaunch{Count: 1, Last: last}, now)
46+
many := FrecencyScore(SessionLaunch{Count: 5, Last: last}, now)
47+
if many <= few {
48+
t.Errorf("more launches should score higher: many=%v few=%v", many, few)
49+
}
50+
}
51+
52+
func TestFrecencyScoreRanksRecentHigher(t *testing.T) {
53+
now := time.Now()
54+
recent := FrecencyScore(SessionLaunch{Count: 3, Last: now.Unix()}, now)
55+
old := FrecencyScore(SessionLaunch{Count: 3, Last: now.Add(-30 * 24 * time.Hour).Unix()}, now)
56+
if recent <= old {
57+
t.Errorf("more recent should score higher: recent=%v old=%v", recent, old)
58+
}
59+
}
60+
61+
func TestFrecencyScoreHalfLifeDecay(t *testing.T) {
62+
now := time.Now()
63+
fresh := FrecencyScore(SessionLaunch{Count: 4, Last: now.Unix()}, now)
64+
oneHalfLife := FrecencyScore(SessionLaunch{Count: 4, Last: now.Add(-frecencyHalfLife).Unix()}, now)
65+
ratio := oneHalfLife / fresh
66+
if ratio < 0.45 || ratio > 0.55 {
67+
t.Errorf("half-life decay ratio = %v, want ~0.5", ratio)
68+
}
69+
}
70+
71+
func TestSessionLaunchesRoundTrip(t *testing.T) {
72+
withTempConfigDir(t)
73+
original := &Config{
74+
SessionLaunches: map[string]SessionLaunch{
75+
"s1": {Count: 3, Last: 1_700_000_000},
76+
},
77+
}
78+
if err := Save(original); err != nil {
79+
t.Fatalf("Save: %v", err)
80+
}
81+
loaded, err := Load()
82+
if err != nil {
83+
t.Fatalf("Load: %v", err)
84+
}
85+
got := loaded.SessionLaunches["s1"]
86+
if got.Count != 3 || got.Last != 1_700_000_000 {
87+
t.Errorf("round-trip = %+v, want {Count:3 Last:1700000000}", got)
88+
}
89+
}

internal/data/models.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ const (
239239
// (Waiting > Active > Stale > Idle). This is applied post-load
240240
// in the TUI layer since attention status is computed at runtime.
241241
SortByAttention SortField = "attention"
242+
// SortByFrecency ranks sessions by how often and how recently the user
243+
// has launched them. Like attention, it is applied post-load in the TUI
244+
// layer because launch statistics live in the user config, not the store.
245+
SortByFrecency SortField = "frecency"
242246
)
243247

244248
// SortOrder indicates ascending or descending sort direction.

internal/tui/frecency_sort_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/jongio/dispatch/internal/config"
8+
"github.com/jongio/dispatch/internal/data"
9+
)
10+
11+
func TestSortByFrecency_WrongField(t *testing.T) {
12+
m := newTestModel()
13+
m.sort.Field = data.SortByUpdated
14+
m.cfg.SessionLaunches = map[string]config.SessionLaunch{"s2": {Count: 9, Last: time.Now().Unix()}}
15+
sessions := []data.Session{{ID: "s1"}, {ID: "s2"}}
16+
m.sortByFrecency(sessions)
17+
if sessions[0].ID != "s1" {
18+
t.Errorf("no-op expected when field != frecency, got %s first", sessions[0].ID)
19+
}
20+
}
21+
22+
func TestSortByFrecency_RanksMostUsedFirst(t *testing.T) {
23+
m := newTestModel()
24+
m.sort.Field = data.SortByFrecency
25+
m.sort.Order = data.Descending
26+
now := time.Now().Unix()
27+
m.cfg.SessionLaunches = map[string]config.SessionLaunch{
28+
"low": {Count: 1, Last: now},
29+
"high": {Count: 10, Last: now},
30+
}
31+
sessions := []data.Session{{ID: "low"}, {ID: "high"}}
32+
m.sortByFrecency(sessions)
33+
if sessions[0].ID != "high" {
34+
t.Errorf("most-used should be first, got %s", sessions[0].ID)
35+
}
36+
}
37+
38+
func TestSortByFrecency_NoHistoryFallbackKeepsOrder(t *testing.T) {
39+
m := newTestModel()
40+
m.sort.Field = data.SortByFrecency
41+
m.sort.Order = data.Descending
42+
m.cfg.SessionLaunches = map[string]config.SessionLaunch{
43+
"b": {Count: 4, Last: time.Now().Unix()},
44+
}
45+
sessions := []data.Session{{ID: "a"}, {ID: "c"}, {ID: "b"}}
46+
m.sortByFrecency(sessions)
47+
if sessions[0].ID != "b" {
48+
t.Errorf("launched session should be first, got %s", sessions[0].ID)
49+
}
50+
if sessions[1].ID != "a" || sessions[2].ID != "c" {
51+
t.Errorf("no-history order should be stable a,c; got %s,%s", sessions[1].ID, sessions[2].ID)
52+
}
53+
}
54+
55+
func TestSortByFrecency_RecencyDecayOrders(t *testing.T) {
56+
m := newTestModel()
57+
m.sort.Field = data.SortByFrecency
58+
m.sort.Order = data.Descending
59+
now := time.Now()
60+
m.cfg.SessionLaunches = map[string]config.SessionLaunch{
61+
"stale": {Count: 3, Last: now.Add(-60 * 24 * time.Hour).Unix()},
62+
"recent": {Count: 3, Last: now.Unix()},
63+
}
64+
sessions := []data.Session{{ID: "stale"}, {ID: "recent"}}
65+
m.sortByFrecency(sessions)
66+
if sessions[0].ID != "recent" {
67+
t.Errorf("recent should outrank stale at equal counts, got %s", sessions[0].ID)
68+
}
69+
}

internal/tui/handlers.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) (Model, tea.Cmd) {
196196
prevID := m.selectedSessionID()
197197
m.sessions = m.applySessionFilters(msg.sessions)
198198
m.sortByAttention(m.sessions)
199+
m.sortByFrecency(m.sessions)
199200
m.groups = nil
200201
m.syncSessionListStatuses()
201202
m.sessionList.SetSessions(m.sessions)
@@ -221,6 +222,7 @@ func (m Model) handleGroupsLoaded(msg groupsLoadedMsg) (Model, tea.Cmd) {
221222
m.groups = m.applyGroupFilters(msg.groups)
222223
for i := range m.groups {
223224
m.sortByAttention(m.groups[i].Sessions)
225+
m.sortByFrecency(m.groups[i].Sessions)
224226
}
225227
m.sessions = nil
226228
m.syncSessionListStatuses()
@@ -716,6 +718,7 @@ func (m Model) handleAISessionsLoaded(msg aiSessionsLoadedMsg) (Model, tea.Cmd)
716718
}
717719
}
718720
m.sortByAttention(m.sessions)
721+
m.sortByFrecency(m.sessions)
719722
m.syncSessionListStatuses()
720723
m.sessionList.SetSessions(m.sessions)
721724
m.searchBar.SetResultCount(m.sessionList.SessionCount())

internal/tui/model.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3148,6 +3148,25 @@ func attentionPriority(status data.AttentionStatus) int {
31483148
}
31493149
}
31503150

3151+
// sortByFrecency re-sorts the session slice by frecency when the current
3152+
// sort field is SortByFrecency. Sessions the user launches often and
3153+
// recently sort first; sessions with no launch history keep their incoming
3154+
// (updated-time) order via a stable sort.
3155+
func (m *Model) sortByFrecency(sessions []data.Session) {
3156+
if m.sort.Field != data.SortByFrecency {
3157+
return
3158+
}
3159+
now := time.Now()
3160+
slices.SortStableFunc(sessions, func(a, b data.Session) int {
3161+
sa := config.FrecencyScore(m.cfg.SessionLaunches[a.ID], now)
3162+
sb := config.FrecencyScore(m.cfg.SessionLaunches[b.ID], now)
3163+
if m.sort.Order == data.Ascending {
3164+
return cmp.Compare(sa, sb)
3165+
}
3166+
return cmp.Compare(sb, sa)
3167+
})
3168+
}
3169+
31513170
// ---------------------------------------------------------------------------
31523171
// Sort / pivot cycling
31533172
// ---------------------------------------------------------------------------
@@ -3157,6 +3176,7 @@ var sortFields = []data.SortField{
31573176
data.SortByFolder,
31583177
data.SortByName,
31593178
data.SortByAttention,
3179+
data.SortByFrecency,
31603180
}
31613181

31623182
func (m *Model) cycleSort() {
@@ -3254,6 +3274,8 @@ func sortDisplayLabel(f data.SortField) string {
32543274
return "name"
32553275
case data.SortByAttention:
32563276
return "attention"
3277+
case data.SortByFrecency:
3278+
return "frecency"
32573279
default:
32583280
return "updated"
32593281
}
@@ -3439,6 +3461,7 @@ func (m *Model) resolveShellAndLaunchDirect(sessionID, cwd, mode string) tea.Cmd
34393461
// runs the Copilot CLI session resume in the current terminal, and quits
34403462
// the TUI when the session ends.
34413463
func (m *Model) launchInPlace(sessionID, cwd string) tea.Cmd {
3464+
m.recordLaunch(sessionID)
34423465
cfg := m.resumeConfigForSession(cwd)
34433466
cmd, err := platform.NewResumeCmd(sessionID, cfg)
34443467
if err != nil {
@@ -3464,6 +3487,7 @@ func launchStyleForMode(mode string) string {
34643487

34653488
// launchExternal opens the session in a new tab, window, or pane depending on launchStyle.
34663489
func (m *Model) launchExternal(shell platform.ShellInfo, sessionID, cwd, launchStyle string) tea.Cmd {
3490+
m.recordLaunch(sessionID)
34673491
cfg := m.resumeConfigForSession(cwd)
34683492
cfg.LaunchStyle = launchStyle
34693493
return func() tea.Msg {
@@ -3488,6 +3512,17 @@ func (m Model) resumeConfigForSession(cwd string) platform.ResumeConfig {
34883512
}
34893513
}
34903514

3515+
// recordLaunch stamps a session launch in the config so the frecency sort
3516+
// can rank frequently and recently launched sessions first. Empty IDs (new
3517+
// sessions started from a folder) are ignored.
3518+
func (m *Model) recordLaunch(sessionID string) {
3519+
if sessionID == "" {
3520+
return
3521+
}
3522+
m.cfg.RecordLaunch(sessionID, time.Now())
3523+
m.saveConfig()
3524+
}
3525+
34913526
func (m Model) selectedSessionID() string {
34923527
if sess, ok := m.sessionList.Selected(); ok {
34933528
return sess.ID
@@ -4300,6 +4335,8 @@ func sortFieldFromConfig(s string) data.SortField {
43004335
return data.SortByName
43014336
case pivotFolder, "cwd":
43024337
return data.SortByFolder
4338+
case "frecency":
4339+
return data.SortByFrecency
43034340
default:
43044341
return data.SortByUpdated
43054342
}
@@ -4315,6 +4352,8 @@ func sortFieldToConfig(f data.SortField) string {
43154352
return "name"
43164353
case data.SortByFolder:
43174354
return "folder"
4355+
case data.SortByFrecency:
4356+
return "frecency"
43184357
default:
43194358
return "updated"
43204359
}

internal/tui/model_coverage_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,8 @@ func TestCovCycleSortFullCycle(t *testing.T) {
479479
m := newTestModel()
480480
m.sort.Field = data.SortByUpdated
481481

482-
// sortFields = [SortByUpdated, SortByFolder, SortByName, SortByAttention]
483-
expected := []data.SortField{data.SortByFolder, data.SortByName, data.SortByAttention, data.SortByUpdated}
482+
// sortFields = [SortByUpdated, SortByFolder, SortByName, SortByAttention, SortByFrecency]
483+
expected := []data.SortField{data.SortByFolder, data.SortByName, data.SortByAttention, data.SortByFrecency, data.SortByUpdated}
484484
for _, exp := range expected {
485485
m.cycleSort()
486486
if m.sort.Field != exp {

0 commit comments

Comments
 (0)