package app import ( "encoding/json" "io" "net/http" "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) // 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"` Name string `json:"name"` 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 keyMsg) (tea.Model, tea.Cmd) { switch { case k.Type == keyEsc || runeIs(k, "R"): m.overlay = OverlayNone case k.Type == keyUp || runeIs(k, "k"): if m.sessionListIndex > 0 { m.sessionListIndex-- } case k.Type == keyDown || runeIs(k, "j"): if m.sessionListIndex < len(m.sessionList)-1 { m.sessionListIndex++ } case k.Type == 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)) // Prefer the human session name (SessionNamedEvent, intent-derived) over the raw // workflow id — it's what the operator recognizes a session by. stage := s.Name if stage == "" { stage = s.WorkflowID } if s.StageCount > 0 { stage += " ·" + itoa(s.StageCount) + "stg" } stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2) dateCell := mbg(t, padRaw(absDateISO(s.LastActivityAt), 12), t.P.Dim) ageCell := mbg(t, "· "+relativeAge(s.LastActivityAt), t.P.Faint) return marker + idCell + " " + statusCell + " " + stageCell + " " + dateCell + " " + ageCell } // absDateISO renders an ISO-8601 instant (the server's LastActivityAt) as a compact local // date via shortDateTime, or "—" when absent/unparseable. func absDateISO(iso string) string { if iso == "" { return "—" } ts, err := time.Parse(time.RFC3339Nano, iso) if err != nil { if ts, err = time.Parse(time.RFC3339, iso); err != nil { return "—" } } return shortDateTime(ts.UnixMilli()) } // 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()) }