feat(tui-go): session list / resume view (E)
R opens a navigable overlay of recent sessions (GET /sessions: short id, status,
workflow/stage, relative age); enter resumes via POST /sessions/{id}/resume, which
replays onto the existing global /stream (no WS re-dial). Fetch/resume errors surface
in the overlay, never panic. stdlib-only (ws.Client.HTTPBase derives the base URL).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user