Skip to content
This repository was archived by the owner on Apr 16, 2026. It is now read-only.

Commit f9efab3

Browse files
authored
Merge pull request #22 from yepzdk/feat/issue-3-session-views
feat: add session list and detail view with observations
2 parents 7596dbd + 7be4c04 commit f9efab3

5 files changed

Lines changed: 222 additions & 23 deletions

File tree

assets/viewer/index.html

Lines changed: 138 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,13 @@
143143
.session-card {
144144
padding: 1rem; background: #161b22;
145145
border: 1px solid #30363d; border-radius: 6px;
146-
margin-bottom: 0.5rem;
146+
margin-bottom: 0.5rem; cursor: pointer;
147+
transition: border-color 0.15s;
148+
}
149+
.session-card:hover { border-color: #58a6ff; }
150+
.session-card.expanded { border-color: #58a6ff; }
151+
.session-card .session-header {
152+
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
147153
}
148154
.session-card .session-id {
149155
font-size: 0.8rem; color: #58a6ff; font-family: monospace;
@@ -153,7 +159,17 @@
153159
}
154160
.session-card .session-meta {
155161
margin-top: 0.375rem; color: #8b949e; font-size: 0.8rem;
156-
display: flex; gap: 1rem; flex-wrap: wrap;
162+
display: flex; gap: 1rem; flex-wrap: wrap; align-items: center;
163+
}
164+
.session-card .session-duration {
165+
font-size: 0.8rem; color: #8b949e;
166+
}
167+
.session-observations {
168+
margin-top: 0.75rem; padding-top: 0.75rem;
169+
border-top: 1px solid #30363d;
170+
}
171+
.session-observations .obs-header {
172+
font-size: 0.8rem; color: #8b949e; margin-bottom: 0.5rem;
157173
}
158174

159175
/* Status badge */
@@ -314,8 +330,15 @@ <h2 id="section-title">Live Feed</h2>
314330
<!-- Sessions -->
315331
<div id="section-sessions" class="section">
316332
<div class="content-inner">
317-
<div style="margin-bottom:1rem">
333+
<div class="filter-row">
318334
<button class="btn btn-secondary" id="refresh-sessions">Refresh</button>
335+
<label>Status:</label>
336+
<select id="sessions-status-filter" style="background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:0.5rem 0.75rem;border-radius:6px;font-size:0.875rem;font-family:inherit;">
337+
<option value="">All</option>
338+
<option value="active">Active</option>
339+
<option value="ended">Ended</option>
340+
</select>
341+
<span id="sessions-count" style="font-size:0.85rem;color:#8b949e"></span>
319342
</div>
320343
<div id="sessions-list">
321344
<div class="placeholder">Loading sessions...</div>
@@ -601,31 +624,124 @@ <h2 id="section-title">Live Feed</h2>
601624

602625
// --- Sessions ---
603626
const sessionsList = document.getElementById('sessions-list');
627+
const sessionsCount = document.getElementById('sessions-count');
628+
const sessionsStatusFilter = document.getElementById('sessions-status-filter');
629+
let allSessions = [];
630+
604631
document.getElementById('refresh-sessions').addEventListener('click', loadSessions);
632+
sessionsStatusFilter.addEventListener('change', renderSessions);
633+
634+
function fmtDuration(startStr, endStr) {
635+
const start = new Date(startStr);
636+
const end = endStr ? new Date(endStr) : new Date();
637+
if (isNaN(start)) return '';
638+
const diffMs = end - start;
639+
const mins = Math.floor(diffMs / 60000);
640+
if (mins < 60) return mins + 'm';
641+
const hours = Math.floor(mins / 60);
642+
const remMins = mins % 60;
643+
if (hours < 24) return hours + 'h ' + remMins + 'm';
644+
const days = Math.floor(hours / 24);
645+
return days + 'd ' + (hours % 24) + 'h';
646+
}
647+
648+
function createSessionCard(s) {
649+
const div = document.createElement('div');
650+
div.className = 'session-card';
651+
div.dataset.sessionId = s.ID;
652+
const isActive = !s.EndedAt;
653+
const badge = isActive
654+
? '<span class="badge badge-active">active</span>'
655+
: '<span class="badge badge-ended">ended</span>';
656+
const duration = fmtDuration(s.StartedAt, s.EndedAt);
657+
const durationSpan = duration ? '<span class="session-duration">' + esc(duration) + '</span>' : '';
658+
div.innerHTML =
659+
'<div class="session-header">' +
660+
'<div class="session-project">' + esc(s.Project || 'No project') + '</div>' +
661+
badge +
662+
durationSpan +
663+
'</div>' +
664+
'<div class="session-id">' + esc(s.ID) + '</div>' +
665+
'<div class="session-meta">' +
666+
'<span>Messages: ' + (s.MessageCount || 0) + '</span>' +
667+
'<span>Started: ' + fmtTime(s.StartedAt) + '</span>' +
668+
(s.EndedAt ? '<span>Ended: ' + fmtTime(s.EndedAt) + '</span>' : '') +
669+
'</div>' +
670+
'<div class="session-observations" style="display:none"></div>';
671+
672+
div.addEventListener('click', function() {
673+
const obsContainer = this.querySelector('.session-observations');
674+
const isExpanded = this.classList.contains('expanded');
675+
if (isExpanded) {
676+
this.classList.remove('expanded');
677+
obsContainer.style.display = 'none';
678+
return;
679+
}
680+
this.classList.add('expanded');
681+
obsContainer.style.display = 'block';
682+
if (obsContainer.dataset.loaded === 'true') return;
683+
loadSessionObservations(s.ID, obsContainer);
684+
});
605685

606-
async function loadSessions() {
607-
sessionsList.innerHTML = '<div class="placeholder">Loading sessions...</div>';
686+
return div;
687+
}
688+
689+
async function loadSessionObservations(sessionId, container) {
690+
container.innerHTML = '<div class="obs-header">Loading observations...</div>';
608691
try {
609-
const resp = await fetch('/api/sessions');
610-
const sessions = await resp.json();
611-
if (!sessions || sessions.length === 0) {
612-
sessionsList.innerHTML = '<div class="placeholder">No active sessions.</div>';
692+
const resp = await fetch('/api/sessions/' + encodeURIComponent(sessionId) + '/observations?limit=50');
693+
if (!resp.ok) {
694+
container.innerHTML = '<div class="obs-header">Failed to load observations.</div>';
695+
container.dataset.loaded = 'true';
613696
return;
614697
}
615-
sessionsList.innerHTML = '';
616-
for (const s of sessions) {
617-
const div = document.createElement('div');
618-
div.className = 'session-card';
619-
div.innerHTML =
620-
'<div class="session-id">' + esc(s.ID) + '</div>' +
621-
'<div class="session-project">' + esc(s.Project || 'No project') + '</div>' +
622-
'<div class="session-meta">' +
623-
'<span><span class="badge badge-active">active</span></span>' +
624-
'<span>Messages: ' + (s.MessageCount || 0) + '</span>' +
625-
'<span>Started: ' + fmtTime(s.StartedAt) + '</span>' +
626-
'</div>';
627-
sessionsList.appendChild(div);
698+
const observations = await resp.json();
699+
if (!observations || observations.length === 0) {
700+
container.innerHTML = '<div class="obs-header">No observations for this session.</div>';
701+
container.dataset.loaded = 'true';
702+
return;
628703
}
704+
container.innerHTML = '<div class="obs-header">' + observations.length + ' observation' + (observations.length !== 1 ? 's' : '') + '</div>';
705+
for (const obs of observations) {
706+
container.appendChild(createEventCard(obs, fmtTime(obs.CreatedAt)));
707+
}
708+
container.dataset.loaded = 'true';
709+
} catch (err) {
710+
container.innerHTML = '<div class="obs-header">Failed to load observations.</div>';
711+
console.error('Session observations error:', err);
712+
}
713+
}
714+
715+
function renderSessions() {
716+
const filter = sessionsStatusFilter.value;
717+
let filtered = allSessions;
718+
if (filter === 'active') filtered = allSessions.filter(s => !s.EndedAt);
719+
else if (filter === 'ended') filtered = allSessions.filter(s => s.EndedAt);
720+
721+
const total = allSessions.length;
722+
const shown = filtered.length;
723+
sessionsCount.textContent = filter
724+
? shown + ' of ' + total + ' session' + (total !== 1 ? 's' : '')
725+
: total + ' session' + (total !== 1 ? 's' : '');
726+
727+
if (filtered.length === 0) {
728+
sessionsList.innerHTML = '<div class="placeholder">' + (filter ? 'No ' + filter + ' sessions.' : 'No sessions yet.') + '</div>';
729+
return;
730+
}
731+
sessionsList.innerHTML = '';
732+
for (const s of filtered) {
733+
sessionsList.appendChild(createSessionCard(s));
734+
}
735+
}
736+
737+
async function loadSessions() {
738+
sessionsList.innerHTML = '<div class="placeholder">Loading sessions...</div>';
739+
sessionsCount.textContent = '';
740+
try {
741+
const resp = await fetch('/api/sessions?all=true&limit=50');
742+
const sessions = await resp.json();
743+
allSessions = sessions || [];
744+
renderSessions();
629745
} catch (err) {
630746
sessionsList.innerHTML = '<div class="placeholder">Failed to load sessions.</div>';
631747
console.error('Sessions error:', err);

internal/console/handlers_session.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,15 @@ func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
3535
}
3636

3737
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
38-
sessions, err := s.db.ListActiveSessions()
38+
var sessions []*db.Session
39+
var err error
40+
41+
if r.URL.Query().Get("all") == "true" {
42+
limit := int(parseID(r.URL.Query().Get("limit")))
43+
sessions, err = s.db.ListAllSessions(limit)
44+
} else {
45+
sessions, err = s.db.ListActiveSessions()
46+
}
3947
if err != nil {
4048
s.logger.Error("list sessions", "error", err)
4149
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
@@ -59,6 +67,18 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) {
5967
writeJSON(w, http.StatusOK, sess)
6068
}
6169

70+
func (s *Server) handleSessionObservations(w http.ResponseWriter, r *http.Request) {
71+
id := chi.URLParam(r, "id")
72+
limit := int(parseID(r.URL.Query().Get("limit")))
73+
observations, err := s.db.ListBySessionID(id, limit)
74+
if err != nil {
75+
s.logger.Error("session observations", "error", err)
76+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
77+
return
78+
}
79+
writeJSON(w, http.StatusOK, observations)
80+
}
81+
6282
func (s *Server) handleEndSession(w http.ResponseWriter, r *http.Request) {
6383
id := chi.URLParam(r, "id")
6484
if err := s.db.EndSession(id); err != nil {

internal/console/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func (s *Server) registerRoutes() {
124124
r.Get("/sessions", s.handleListSessions)
125125
r.Post("/sessions/cleanup", s.handleCleanupSessions)
126126
r.Get("/sessions/{id}", s.handleGetSession)
127+
r.Get("/sessions/{id}/observations", s.handleSessionObservations)
127128
r.Post("/sessions/{id}/end", s.handleEndSession)
128129
r.Post("/sessions/{id}/message-count", s.handleIncrementMessageCount)
129130

internal/db/observations.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,35 @@ func (db *DB) RecentObservations(project string, limit int) ([]*Observation, err
157157
return results, rows.Err()
158158
}
159159

160+
// ListBySessionID returns observations for a given session, ordered most recent first.
161+
func (db *DB) ListBySessionID(sessionID string, limit int) ([]*Observation, error) {
162+
if limit <= 0 {
163+
limit = 50
164+
}
165+
166+
rows, err := db.conn.Query(
167+
`SELECT id, session_id, type, title, text, project, metadata, created_at
168+
FROM observations WHERE session_id = ? ORDER BY created_at DESC LIMIT ?`,
169+
sessionID, limit,
170+
)
171+
if err != nil {
172+
return nil, fmt.Errorf("list observations by session %s: %w", sessionID, err)
173+
}
174+
defer rows.Close()
175+
176+
var results []*Observation
177+
for rows.Next() {
178+
o := &Observation{}
179+
var createdAt string
180+
if err := rows.Scan(&o.ID, &o.SessionID, &o.Type, &o.Title, &o.Text, &o.Project, &o.Metadata, &createdAt); err != nil {
181+
return nil, fmt.Errorf("scan observation: %w", err)
182+
}
183+
o.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
184+
results = append(results, o)
185+
}
186+
return results, rows.Err()
187+
}
188+
160189
// SearchFilter defines parameters for filtered full-text search.
161190
type SearchFilter struct {
162191
Query string

internal/db/sessions.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,36 @@ func (db *DB) ListActiveSessions() ([]*Session, error) {
113113
}
114114
return results, rows.Err()
115115
}
116+
117+
// ListAllSessions returns all sessions (active and ended), ordered by most recent first.
118+
func (db *DB) ListAllSessions(limit int) ([]*Session, error) {
119+
if limit <= 0 {
120+
limit = 50
121+
}
122+
123+
rows, err := db.conn.Query(
124+
`SELECT id, project, started_at, ended_at, message_count, metadata
125+
FROM sessions ORDER BY started_at DESC LIMIT ?`, limit,
126+
)
127+
if err != nil {
128+
return nil, fmt.Errorf("list all sessions: %w", err)
129+
}
130+
defer rows.Close()
131+
132+
var results []*Session
133+
for rows.Next() {
134+
s := &Session{}
135+
var startedAt string
136+
var endedAt sql.NullString
137+
if err := rows.Scan(&s.ID, &s.Project, &startedAt, &endedAt, &s.MessageCount, &s.Metadata); err != nil {
138+
return nil, fmt.Errorf("scan session: %w", err)
139+
}
140+
s.StartedAt, _ = time.Parse("2006-01-02 15:04:05", startedAt)
141+
if endedAt.Valid {
142+
t, _ := time.Parse("2006-01-02 15:04:05", endedAt.String)
143+
s.EndedAt = &t
144+
}
145+
results = append(results, s)
146+
}
147+
return results, rows.Err()
148+
}

0 commit comments

Comments
 (0)