diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 190274b0..59d79708 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -53,6 +53,7 @@ const ( OverlayConfig OverlayStats OverlayIdeas + OverlaySessions ) // RouterEntry is one line in a session's conversation transcript. @@ -259,6 +260,14 @@ type Model struct { ideasIndex int ideasLoading bool + // session browser (OverlaySessions) — recent sessions fetched over HTTP from + // GET /sessions, so prior runs are reachable after a server restart + TUI reopen + // (the WS stream only carries live sessions, not the historical roster). + sessionList []SessionSummary + sessionListIndex int + sessionListLoading bool + sessionListErr string + // command palette paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 10cd6b4d..a9bc57ad 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -61,6 +61,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.statsModal()) case OverlayIdeas: return m.center(m.ideasModal()) + case OverlaySessions: + return m.center(m.sessionsModal()) } return base } diff --git a/apps/tui-go/internal/app/sessions_overlay.go b/apps/tui-go/internal/app/sessions_overlay.go new file mode 100644 index 00000000..6a4f73d1 --- /dev/null +++ b/apps/tui-go/internal/app/sessions_overlay.go @@ -0,0 +1,259 @@ +package app + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SessionSummary is one recent session from GET /sessions. Mirrors the server's +// SessionSummaryResponse (apps/server SessionRoutes.kt) — the wire shape. +type SessionSummary struct { + SessionID string `json:"sessionId"` + Status string `json:"status"` + Intent string `json:"intent"` + WorkflowID string `json:"workflowId"` + StageCount int `json:"stageCount"` + CreatedAt string `json:"createdAt"` + LastActivityAt string `json:"lastActivityAt"` +} + +// sessionsLoadedMsg carries the result of a GET /sessions fetch. Exactly one of +// sessions / err is meaningful; err non-empty means the fetch failed and the +// overlay shows it instead of crashing. +type sessionsLoadedMsg struct { + sessions []SessionSummary + err string +} + +// sessionResumeMsg is the outcome of asking the server to resume a session +// (POST /sessions/{id}/resume). On success its events replay onto the live +// stream the app already drains; on failure the overlay shows the error. +type sessionResumeMsg struct { + sessionID string + err string +} + +// sessionsHTTPTimeout bounds the REST calls so a wedged server can't hang the UI. +const sessionsHTTPTimeout = 5 * time.Second + +// fetchSessions is a tea.Cmd that GETs the recent-session roster over HTTP and +// reports it as a sessionsLoadedMsg. base is the http://host:port origin (from +// the ws client); an empty base yields an error message rather than a panic. +func fetchSessions(base string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return sessionsLoadedMsg{err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Get(base + "/sessions") + if err != nil { + return sessionsLoadedMsg{err: "fetch failed: " + err.Error()} + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return sessionsLoadedMsg{err: "server returned " + resp.Status} + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return sessionsLoadedMsg{err: "read failed: " + err.Error()} + } + var out []SessionSummary + if err := json.Unmarshal(body, &out); err != nil { + return sessionsLoadedMsg{err: "decode failed: " + err.Error()} + } + return sessionsLoadedMsg{sessions: out} + } +} + +// resumeSession is a tea.Cmd that asks the server to rehydrate and relaunch a +// session (POST /sessions/{id}/resume). The server replays the rehydrated +// session onto the global stream this client already reads, so no WS re-dial is +// needed — the existing transport carries it. +func resumeSession(base, id string) tea.Cmd { + return func() tea.Msg { + if base == "" { + return sessionResumeMsg{sessionID: id, err: "no server address"} + } + client := &http.Client{Timeout: sessionsHTTPTimeout} + resp, err := client.Post(base+"/sessions/"+id+"/resume", "application/json", nil) + if err != nil { + return sessionResumeMsg{sessionID: id, err: "resume failed: " + err.Error()} + } + defer resp.Body.Close() + // 202 Accepted on success; 200 (already running) is fine too. + if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK { + return sessionResumeMsg{sessionID: id, err: "server returned " + resp.Status} + } + return sessionResumeMsg{sessionID: id} + } +} + +// openSessions opens the recent-session browser and kicks off the HTTP fetch. +// It is global (not bound to the selected session), so it opens from anywhere — +// including the idle list after a fresh reconnect with no live sessions yet. +func (m *Model) openSessions() tea.Cmd { + m.overlay = OverlaySessions + m.sessionListIndex = 0 + m.sessionListErr = "" + m.sessionListLoading = true + return fetchSessions(m.client.HTTPBase()) +} + +// handleSessionsKey owns every key while the session browser is open: navigate +// the roster, esc closes, enter resumes the selected session. +func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case k.Type == tea.KeyEsc || runeIs(k, "R"): + m.overlay = OverlayNone + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.sessionListIndex > 0 { + m.sessionListIndex-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.sessionListIndex < len(m.sessionList)-1 { + m.sessionListIndex++ + } + case k.Type == tea.KeyEnter: + if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) { + return m.openSelectedSession() + } + } + return m, nil +} + +// openSelectedSession focuses the highlighted recent session locally and asks the +// server to resume it. Focusing it first means its replayed events land on the +// session the operator is now looking at; the resume cmd does the rest over the +// existing stream (no WS re-dial — see resumeSession). +func (m Model) openSelectedSession() (tea.Model, tea.Cmd) { + sum := m.sessionList[m.sessionListIndex] + s := m.ensureSession(sum.SessionID) + if sum.WorkflowID != "" { + s.WorkflowID = sum.WorkflowID + if s.Name == "" { + s.Name = sum.WorkflowID + } + } + if sum.Status != "" { + s.Status = sum.Status + } + m.selectedID = sum.SessionID + m.sessionEntered = true + m.overlay = OverlayNone + return m, resumeSession(m.client.HTTPBase(), sum.SessionID) +} + +func (m Model) sessionsModal() string { + t := m.theme + w := m.modalWidth() + + var b strings.Builder + b.WriteString(m.titleLine("sessions")) + if len(m.sessionList) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.sessionListIndex+1)+"/"+itoa(len(m.sessionList))+")", t.P.Faint)) + } + b.WriteString("\n\n") + + if m.sessionListErr != "" { + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.sessionListErr, w-8)) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if m.sessionListLoading && len(m.sessionList) == 0 { + b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + if len(m.sessionList) == 0 { + b.WriteString(mbg(t, " no recent sessions", t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}})) + return t.Overlay.Width(w).Render(b.String()) + } + + // Windowed roster, keeping the selected row in view. + bodyH := m.height*70/100 - 6 + if bodyH < 4 { + bodyH = 4 + } + off := 0 + if len(m.sessionList) > bodyH { + off = m.sessionListIndex - bodyH/2 + if off < 0 { + off = 0 + } + if off > len(m.sessionList)-bodyH { + off = len(m.sessionList) - bodyH + } + } + end := off + bodyH + if end > len(m.sessionList) { + end = len(m.sessionList) + } + for i := off; i < end; i++ { + b.WriteString(m.sessionListRow(i) + "\n") + } + + b.WriteString("\n" + modalHints(t, [][2]string{ + {"↑↓", "select"}, {"enter", "resume"}, {"R/esc", "close"}, + })) + return t.Overlay.Width(w).Render(b.String()) +} + +// sessionListRow renders one roster line: marker, short id, status, current +// stage (if any), and relative age of the last activity. +func (m Model) sessionListRow(i int) string { + t := m.theme + s := m.sessionList[i] + + marker := mbg(t, " ", t.P.BgPanel) + idFg := t.P.Fg + if i == m.sessionListIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + idFg = t.P.FgStrong + } + + idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortSessionID(s.SessionID), 8)) + statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true). + Render(padRaw(statusLabel(s.Status), 9)) + + stage := s.WorkflowID + if s.StageCount > 0 { + stage += " ·" + itoa(s.StageCount) + "stg" + } + stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2) + + age := relativeAge(s.LastActivityAt) + ageCell := mbg(t, age, t.P.Faint) + + return marker + idCell + " " + statusCell + " " + stageCell + " " + ageCell +} + +// shortSessionID trims a long session id to a recognizable prefix for the list. +func shortSessionID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} + +// relativeAge renders an ISO-8601 instant string (Instant.toString() on the +// server) as a compact "Ns ago" age, falling back to "—" when absent/unparseable. +func relativeAge(iso string) string { + if iso == "" { + return "—" + } + ts, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + ts, err = time.Parse(time.RFC3339, iso) + if err != nil { + return "—" + } + } + return agoLabel(nowMillis() - ts.UnixMilli()) +} diff --git a/apps/tui-go/internal/app/sessions_overlay_test.go b/apps/tui-go/internal/app/sessions_overlay_test.go new file mode 100644 index 00000000..83709458 --- /dev/null +++ b/apps/tui-go/internal/app/sessions_overlay_test.go @@ -0,0 +1,152 @@ +package app + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func sessionsModel() Model { + m := NewModel(nil) + m.width, m.height = 120, 40 + m.theme = NewTheme(SoftBlue) + return m +} + +// sampleSummaries is a deterministic two-row roster used across the tests. +func sampleSummaries() []SessionSummary { + return []SessionSummary{ + {SessionID: "aaaaaaaa-1111", Status: "ACTIVE", WorkflowID: "refactor", StageCount: 2}, + {SessionID: "bbbbbbbb-2222", Status: "FAILED", WorkflowID: "healthcheck"}, + } +} + +func TestSessionsOverlayToggle(t *testing.T) { + m := sessionsModel() + // `R` opens the browser from the idle list. + updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")}) + m = updated.(Model) + if m.overlay != OverlaySessions { + t.Fatalf("expected OverlaySessions after R, got %d", m.overlay) + } + if !m.sessionListLoading { + t.Fatalf("expected loading state right after open") + } + // `esc` closes it (routed through the overlay key handler). + updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after esc, got %d", m.overlay) + } +} + +func TestSessionsLoadedPopulatesAndRenders(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionListLoading = true + m.applySessionsLoaded(sessionsLoadedMsg{sessions: sampleSummaries()}) + if m.sessionListLoading { + t.Fatalf("expected loading cleared after load") + } + if len(m.sessionList) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(m.sessionList)) + } + out := m.sessionsModal() + if !strings.Contains(out, "aaaaaaaa") || !strings.Contains(out, "bbbbbbbb") { + t.Fatalf("modal missing session ids:\n%s", out) + } + if !strings.Contains(out, "ACTIVE") || !strings.Contains(out, "FAILED") { + t.Fatalf("modal missing statuses:\n%s", out) + } +} + +func TestSessionsNavigationBoundsChecked(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionList = sampleSummaries() + // Up at the top is a no-op (clamped at 0). + updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp}) + m = updated.(Model) + if m.sessionListIndex != 0 { + t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex) + } + // Down moves to row 1. + updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex) + } + // Down again is clamped at the last row. + updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex) + } + // `j` is the vim-style alias for down (still clamped here). + updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + m = updated.(Model) + if m.sessionListIndex != 1 { + t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex) + } +} + +func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionList = sampleSummaries() + m.sessionListIndex = 1 // the FAILED healthcheck row + updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.overlay != OverlayNone { + t.Fatalf("expected overlay closed after resume, got %d", m.overlay) + } + if m.selectedID != "bbbbbbbb-2222" { + t.Fatalf("expected selected id bbbbbbbb-2222, got %q", m.selectedID) + } + if !m.sessionEntered { + t.Fatalf("expected session entered after resume") + } + if s := m.session("bbbbbbbb-2222"); s == nil || s.WorkflowID != "healthcheck" { + t.Fatalf("expected vivified session with workflow healthcheck, got %+v", s) + } + // A resume cmd is emitted; with a nil client (no server addr) it resolves to a + // sessionResumeMsg carrying the error — exercised here so it can't panic. + if cmd == nil { + t.Fatalf("expected a resume cmd to be emitted") + } + if msg, ok := cmd().(sessionResumeMsg); !ok { + t.Fatalf("expected sessionResumeMsg from resume cmd, got %T", cmd()) + } else if msg.sessionID != "bbbbbbbb-2222" { + t.Fatalf("resume msg for wrong session: %q", msg.sessionID) + } +} + +func TestSessionsFetchErrorShownNoPanic(t *testing.T) { + m := sessionsModel() + m.overlay = OverlaySessions + m.sessionListLoading = true + m.applySessionsLoaded(sessionsLoadedMsg{err: "server returned 503 Service Unavailable"}) + if m.sessionListLoading { + t.Fatalf("expected loading cleared on error") + } + if m.sessionListErr == "" { + t.Fatalf("expected error recorded") + } + out := m.sessionsModal() + if !strings.Contains(out, "503") { + t.Fatalf("modal should surface the fetch error:\n%s", out) + } +} + +func TestRelativeAgeParsing(t *testing.T) { + if got := relativeAge(""); got != "—" { + t.Fatalf("empty age should be em-dash, got %q", got) + } + if got := relativeAge("not-a-timestamp"); got != "—" { + t.Fatalf("unparseable age should be em-dash, got %q", got) + } + if got := relativeAge("2020-01-01T00:00:00Z"); !strings.HasSuffix(got, "ago") { + t.Fatalf("a real instant should render an age, got %q", got) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 8d8c5edb..c8b54d40 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -51,12 +51,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.applyServerPhased(msg.m) return m, readServer(m.client) + case sessionsLoadedMsg: + m.applySessionsLoaded(msg) + return m, nil + + case sessionResumeMsg: + if msg.err != "" { + m.sessionListErr = "resume: " + msg.err + } + return m, nil + case tea.KeyMsg: return m.handleKey(msg) } return m, nil } +// applySessionsLoaded folds a GET /sessions result into the browser, clamping the +// cursor and surfacing any fetch error in place of a crash. +func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) { + m.sessionListLoading = false + if msg.err != "" { + m.sessionListErr = msg.err + return + } + m.sessionListErr = "" + m.sessionList = msg.sessions + if m.sessionListIndex >= len(m.sessionList) { + m.sessionListIndex = 0 + } +} + func (m *Model) applyConn(s ws.Status) { switch { case s.Connected: @@ -178,6 +203,10 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.openArtifacts() case "I": m.openIdeas() + case "R": + // Resume browser: recent sessions over HTTP, reachable even after a server + // restart + TUI reopen (the live stream only carries running sessions). + return m, m.openSessions() case "S": m.openStats() case "g": @@ -359,6 +388,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.overlay == OverlayConfig { return m.handleConfigKey(k) } + // Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't + // share the generic no-cmd esc path below. + if m.overlay == OverlaySessions { + return m.handleSessionsKey(k) + } if k.Type == tea.KeyEsc { m.overlay = OverlayNone return m, nil @@ -566,6 +600,7 @@ func paletteCommands() []paletteCmd { {"events", "event inspector", "browse the event stream"}, {"artifacts", "view artifacts", "browse this session's artifacts"}, {"stats", "session stats", "metrics for the selected session"}, + {"sessions", "resume session", "browse / resume recent sessions"}, {"config", "edit config", "view / change correx settings"}, {"mode", "toggle mode", "switch chat / steering"}, {"cancel", "cancel session", "stop the selected session"}, @@ -605,6 +640,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.openArtifacts() case "stats": m.openStats() + case "sessions": + return m, m.openSessions() case "config": m.openConfig() case "mode": diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index f5898920..b48eb377 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -232,9 +232,9 @@ func (m Model) renderFooter() string { hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")} case m.displayState() == StateIdle: if nrw { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} } else { - hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} + hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} } default: // StateInSession if nrw { diff --git a/apps/tui-go/internal/ws/client.go b/apps/tui-go/internal/ws/client.go index 7e59ccb6..4313ff04 100644 --- a/apps/tui-go/internal/ws/client.go +++ b/apps/tui-go/internal/ws/client.go @@ -28,23 +28,36 @@ type Status struct { // Client is a reconnecting WebSocket client. Construct with New, then Run in a // goroutine. Incoming and Conn are drained by the UI. type Client struct { - url string - send chan []byte - in chan protocol.ServerMessage - conn chan Status + url string + httpBase string + send chan []byte + in chan protocol.ServerMessage + conn chan Status } // New builds a client targeting ws://host:port/stream. func New(host string, port int) *Client { - u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"} + hp := hostPort(host, port) + u := url.URL{Scheme: "ws", Host: hp, Path: "/stream"} return &Client{ - url: u.String(), - send: make(chan []byte, 64), - in: make(chan protocol.ServerMessage, 256), - conn: make(chan Status, 16), + url: u.String(), + httpBase: (&url.URL{Scheme: "http", Host: hp}).String(), + send: make(chan []byte, 64), + in: make(chan protocol.ServerMessage, 256), + conn: make(chan Status, 16), } } +// HTTPBase is the http://host:port origin the WS client connects to, for REST +// calls (e.g. GET /sessions) that share the server's host/port. Empty for a nil +// client (the test harness constructs Models with a nil client). +func (c *Client) HTTPBase() string { + if c == nil { + return "" + } + return c.httpBase +} + func hostPort(host string, port int) string { h := host if h == "" {