diff --git a/internal/session/instance.go b/internal/session/instance.go index 7c92e81f..40f7ae27 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -608,6 +608,29 @@ func (inst *Instance) GetLastActivityTime() time.Time { return inst.CreatedAt } +// DisplayLastActivityTime returns the timestamp to show as "last active" in +// the UI. It intentionally differs from GetLastActivityTime: that method +// returns the tmux tracker's raw lastChangeTime (which is seeded with +// time.Now() when the tracker is lazily created, so it leaks ~ the TUI load +// time for sessions that never confirm real activity — e.g. error/idle/ +// stopped panes) and also feeds OpenCode rotation windows, so its semantics +// must not change. +// +// Here we consult only CONFIRMED activity (LastObservedActivity guards on +// realActivityConfirmed). When none has been observed we fall back to the +// persisted last-accessed time — matching what the web serves — and finally +// to CreatedAt. This keeps the TUI "⏱ last active" line in agreement with +// the web instead of resetting to the most recent TUI load. +func (inst *Instance) DisplayLastActivityTime() time.Time { + if ts, ok := inst.LastObservedActivity(); ok { + return ts + } + if !inst.LastAccessedAt.IsZero() { + return inst.LastAccessedAt + } + return inst.CreatedAt +} + // LastObservedActivity returns the last time the tmux tracker confirmed a // real busy spike for this session, and a bool that is false when no // confirmation has happened (the instance has no tmux session, or the diff --git a/internal/session/instance_display_activity_test.go b/internal/session/instance_display_activity_test.go new file mode 100644 index 00000000..4151f92e --- /dev/null +++ b/internal/session/instance_display_activity_test.go @@ -0,0 +1,54 @@ +package session + +import ( + "testing" + "time" +) + +// DisplayLastActivityTime is the UI-facing "last active" timestamp. Unlike +// GetLastActivityTime (which feeds OpenCode rotation windows and returns the +// tmux tracker's raw lastChangeTime), it must never leak the tracker's +// initialization timestamp (~ TUI load time) for a session that has no +// CONFIRMED activity. Error/idle/stopped sessions with a live-but-inactive +// tmux pane fall into exactly that case: the web shows the persisted +// last-accessed time, and the TUI must match it rather than jumping to the +// most recent TUI load. See the last-used regression. + +func TestDisplayLastActivityTime_NoConfirmedActivity_UsesLastAccessed(t *testing.T) { + accessed := time.Now().Add(-24 * time.Hour) + created := time.Now().Add(-72 * time.Hour) + // nil tmuxSession => LastObservedActivity() reports (zero,false), i.e. no + // confirmed activity — the error-session case. + inst := &Instance{ID: "err-sess", CreatedAt: created, LastAccessedAt: accessed} + + got := inst.DisplayLastActivityTime() + if !got.Equal(accessed) { + t.Errorf("with no confirmed activity, want LastAccessedAt %v, got %v", accessed, got) + } + // Must NOT report ~now (the load-time leak this fix targets). + if time.Since(got) < time.Hour { + t.Errorf("display time %v is suspiciously recent — the load-time leak is back", got) + } +} + +func TestDisplayLastActivityTime_NoAccess_FallsBackToCreated(t *testing.T) { + created := time.Now().Add(-72 * time.Hour) + inst := &Instance{ID: "err-sess-2", CreatedAt: created} // LastAccessedAt zero + + got := inst.DisplayLastActivityTime() + if !got.Equal(created) { + t.Errorf("with no access and no activity, want CreatedAt %v, got %v", created, got) + } +} + +// The zero-data tail: very old rows persist last_accessed AND created_at as the +// Go zero sentinel (-62135596800 unix). Both fall through to a zero time, which +// the UI renders as "unknown" — an honest blank, never the ~load-time leak. +func TestDisplayLastActivityTime_AllZero_ReturnsZeroNotLoadTime(t *testing.T) { + inst := &Instance{ID: "err-sess-3"} // CreatedAt and LastAccessedAt both zero + + got := inst.DisplayLastActivityTime() + if !got.IsZero() { + t.Errorf("with no access, activity, or creation time, want zero time, got %v", got) + } +} diff --git a/internal/ui/home.go b/internal/ui/home.go index d8904433..c82a40dc 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -16215,8 +16215,11 @@ func (h *Home) renderPreviewPane(width, height int) string { b.WriteString(infoStyle.Render("📁 " + pathStr)) b.WriteString("\n") - // Activity time - shows when session was last active - activityTime := selected.GetLastActivityTime() + // Activity time - shows when session was last active. Uses the display- + // oriented accessor so sessions with no confirmed activity (error/idle/ + // stopped) fall back to the persisted last-accessed time — matching the + // web — instead of leaking the tmux tracker's ~load-time seed. + activityTime := selected.DisplayLastActivityTime() activityStr := formatRelativeTime(activityTime) if selectedStatus == session.StatusRunning { activityStr = "active now"