Skip to content

Commit bddb6c7

Browse files
upsetbitclaude
andauthored
feat(panel): subagent grouping on Sessions list (#229)
* feat(proto): add top_level_only to ListRequest Adds a bool top_level_only flag to ListRequest so callers can restrict results to sessions whose parent_session_id IS NULL — needed by the panel's subagent-grouped Sessions view so child rows don't double up as top-level rows. Additive change; default behavior unchanged. Refs #220 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(server): honor top_level_only on Sessions.List When ListRequest.TopLevelOnly is set, restrict the result set to sessions with parent_session_id IS NULL. Applies to both the page query and total_count so pagination math reflects the filtered set. Refs #220 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(panel): subagent grouping on Sessions list Adds a "Group subagents" toggle in the filter bar (default ON; off via ?group_subagents=off). When grouped, the listing requests top-level sessions only and fetches each parent's children via ListChildren, attaching them to the row view model. The table renders a chevron + child count in the new leading expand column for parents with children; clicking expands an indented block of subdued child rows immediately below the parent via Alpine local state. Sort/filter rules apply to the parent set; children inherit position under their parent. FTS search disables grouping for the request — a search hit may live inside a child, and hiding children behind a closed parent would mask the result. cloneListRequest now also copies Agents and ProjectMatches (existing narrowing was silently dropped on cost-sort and would have been on the new top_level_only path too). Closes #220 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 08029de commit bddb6c7

6 files changed

Lines changed: 413 additions & 171 deletions

File tree

gen/go/prosa/v1/sessions.pb.go

Lines changed: 151 additions & 136 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/panel/assets/css/components/sessions-table.css

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,94 @@
165165
.sessions-pagination a:hover {
166166
text-decoration: underline;
167167
}
168+
169+
/* Subagent expand column + child rows ------------------------------- */
170+
171+
.sessions-table th.cell-expand,
172+
.sessions-table td.cell-expand {
173+
width: 36px;
174+
padding-left: var(--space-3);
175+
padding-right: 0;
176+
text-align: left;
177+
}
178+
179+
.row-expand {
180+
display: inline-flex;
181+
align-items: center;
182+
gap: 4px;
183+
padding: 2px 4px;
184+
background: transparent;
185+
border: 0;
186+
color: var(--text-3);
187+
font: inherit;
188+
font-size: var(--text-xs);
189+
cursor: pointer;
190+
border-radius: var(--radius-sm);
191+
}
192+
193+
.row-expand:hover {
194+
color: var(--text-1);
195+
background: var(--bg-elev-2);
196+
}
197+
198+
.row-expand-caret {
199+
display: inline-block;
200+
font-size: 0.85em;
201+
transition: transform var(--duration-fast) var(--ease);
202+
}
203+
204+
.row-expand-caret.is-open {
205+
transform: rotate(90deg);
206+
}
207+
208+
.row-expand-count {
209+
font-variant-numeric: tabular-nums;
210+
color: var(--text-3);
211+
}
212+
213+
.sessions-table tbody tr.session-row-child {
214+
background: var(--bg-elev-1);
215+
}
216+
217+
.sessions-table tbody tr.session-row-child:hover {
218+
background: var(--bg-elev-2);
219+
}
220+
221+
.sessions-table tbody tr.session-row-child td {
222+
color: var(--text-2);
223+
}
224+
225+
.row-child-indent {
226+
display: inline-block;
227+
margin-right: var(--space-2);
228+
color: var(--text-3);
229+
}
230+
231+
/* Filter-bar inline toggle (e.g. Group subagents) ------------------- */
232+
233+
.filter-toggle {
234+
display: inline-flex !important;
235+
align-items: center;
236+
gap: var(--space-2);
237+
padding: 0 var(--space-3);
238+
height: 32px;
239+
background: var(--bg-elev-2);
240+
border: 1px solid var(--divider);
241+
border-radius: var(--radius-sm);
242+
color: var(--text-2);
243+
font-size: var(--text-xs) !important;
244+
text-transform: none !important;
245+
letter-spacing: 0 !important;
246+
cursor: pointer;
247+
user-select: none;
248+
}
249+
250+
.filter-toggle input[type="checkbox"] {
251+
margin: 0;
252+
cursor: pointer;
253+
}
254+
255+
.filter-toggle:has(input[type="checkbox"]:checked) {
256+
color: var(--text-1);
257+
border-color: var(--accent-soft);
258+
}

internal/panel/handlers_sessions.go

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ const sessionsPageLimit = 50
3333
// sessionRow is one row of the Sessions table, pre-formatted for the
3434
// template so the view stays declarative. Cost is "$x.xx" or "n/a",
3535
// Tokens* are comma-grouped strings, StartedAt* are display timestamps.
36+
// Children, when non-empty, are subagent rows rendered indented under
37+
// this parent; IsChild marks rows rendered inside a parent's expansion
38+
// (used only when group_subagents=off so the flat list can still flag
39+
// them visually).
3640
type sessionRow struct {
3741
Id string
3842
Agent string
@@ -51,6 +55,8 @@ type sessionRow struct {
5155
StartedAtFull string
5256
StartedRel string
5357
OpenURL string
58+
IsChild bool
59+
Children []sessionRow
5460
}
5561

5662
// handleSessions renders the Sessions surface: FTS search, multi-select
@@ -88,6 +94,15 @@ func (p *Panel) handleSessions(w http.ResponseWriter, r *http.Request) {
8894
sortDirRaw := q.Get("dir")
8995
activeSort, activeDir := resolveSessionsSort(sortBy, sortDirRaw)
9096
queryStr := strings.TrimSpace(q.Get("q"))
97+
// Group subagents (default ON). Off via ?group_subagents=off. When
98+
// on, the listing is filtered to top-level sessions; children are
99+
// attached to each parent for inline expansion. FTS search disables
100+
// grouping because the search hit may be inside a child.
101+
// Read the last value for group_subagents so the hidden+checkbox
102+
// pattern in the form template works: hidden=off, checkbox=on. When
103+
// the checkbox is unchecked only "off" is sent; checked sends both
104+
// in order so the last value ("on") wins. Default ON when absent.
105+
groupSubagents := lastValueOrDefault(q["group_subagents"], "on") != "off" && queryStr == ""
91106

92107
// Page (1-based) → offset.
93108
page, _ := strconv.Atoi(q.Get("page"))
@@ -96,10 +111,11 @@ func (p *Panel) handleSessions(w http.ResponseWriter, r *http.Request) {
96111
}
97112

98113
baseReq := &prosav1.ListRequest{
99-
Since: timestamppb.New(since),
100-
Until: timestamppb.New(until),
101-
DeviceNames: devices,
102-
Query: queryStr,
114+
Since: timestamppb.New(since),
115+
Until: timestamppb.New(until),
116+
DeviceNames: devices,
117+
Query: queryStr,
118+
TopLevelOnly: groupSubagents,
103119
}
104120
// Push the full multi-select to the server so narrowing happens before
105121
// pagination. Filtering only the current page client-side (the old
@@ -180,6 +196,25 @@ func (p *Panel) handleSessions(w http.ResponseWriter, r *http.Request) {
180196
for _, s := range sessions {
181197
rows = append(rows, buildSessionRow(s, r.URL, deviceLookup))
182198
}
199+
// When grouping is on, fan out ListChildren for each parent row so
200+
// the template can render an expandable indented block. Failures
201+
// degrade silently to a parent without children — the row is still
202+
// usable on its own.
203+
if groupSubagents {
204+
for i := range rows {
205+
childResp, childErr := p.clients.Sessions.ListChildren(r.Context(),
206+
connect.NewRequest(&prosav1.ListChildrenRequest{ParentId: rows[i].Id}))
207+
if childErr != nil {
208+
slog.Warn("sessions.listChildren failed", "id", rows[i].Id, "err", childErr)
209+
continue
210+
}
211+
for _, child := range childResp.Msg.Sessions {
212+
childRow := buildSessionRow(child, r.URL, deviceLookup)
213+
childRow.IsChild = true
214+
rows[i].Children = append(rows[i].Children, childRow)
215+
}
216+
}
217+
}
183218

184219
// URL helpers: BaseQuery preserves the current filter set so links
185220
// can append `&sort=` or `&page=` without re-encoding. SortURLs map
@@ -229,6 +264,7 @@ func (p *Panel) handleSessions(w http.ResponseWriter, r *http.Request) {
229264
"ActiveFilters": activeFilters,
230265
"ClearFiltersURL": clearURL,
231266
"WindowLabel": windowLabel(lastRaw),
267+
"GroupSubagents": groupSubagents,
232268
}
233269

234270
// Side panel inline render when ?session=<id> — same pattern as
@@ -320,6 +356,16 @@ func removeFromMulti(q url.Values, key, value string) {
320356
}
321357
}
322358

359+
// lastValueOrDefault returns the last entry in vals, or the default
360+
// when vals is empty. Used for hidden+checkbox form pairs where the
361+
// checkbox's value should win when present.
362+
func lastValueOrDefault(vals []string, def string) string {
363+
if len(vals) == 0 {
364+
return def
365+
}
366+
return vals[len(vals)-1]
367+
}
368+
323369
// pickMulti returns every non-empty value for key, trimmed.
324370
func pickMulti(q url.Values, key string) []string {
325371
vals := q[key]
@@ -608,10 +654,17 @@ func cloneListRequest(in *prosav1.ListRequest) *prosav1.ListRequest {
608654
SortDir: in.SortDir,
609655
Limit: in.Limit,
610656
Offset: in.Offset,
657+
TopLevelOnly: in.TopLevelOnly,
611658
}
612659
if len(in.DeviceNames) > 0 {
613660
out.DeviceNames = append([]string(nil), in.DeviceNames...)
614661
}
662+
if len(in.Agents) > 0 {
663+
out.Agents = append([]string(nil), in.Agents...)
664+
}
665+
if len(in.ProjectMatches) > 0 {
666+
out.ProjectMatches = append([]string(nil), in.ProjectMatches...)
667+
}
615668
return out
616669
}
617670

internal/panel/templates/sessions.html

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,19 @@ <h1>Sessions</h1>
138138
</div>
139139
</label>
140140

141+
<label class="filter-toggle">
142+
<input type="hidden" name="group_subagents" value="off">
143+
<input type="checkbox" name="group_subagents" value="on" {{if .GroupSubagents}}checked{{end}}>
144+
<span>Group subagents</span>
145+
</label>
146+
141147
<noscript><button class="btn" type="submit">Apply</button></noscript>
142148
</form>
143149

144150
<table class="sessions-table">
145151
<thead>
146152
<tr>
153+
<th class="cell-expand" aria-label="expand subagents"></th>
147154
{{if index .Cols "agent"}}<th><a href="{{index .SortURLs "agent"}}"><span class="sort-arrow">{{index .SortArrows "agent"}}</span>Agent</a></th>{{end}}
148155
{{if index .Cols "project"}}<th><a href="{{index .SortURLs "project"}}"><span class="sort-arrow">{{index .SortArrows "project"}}</span>Project</a></th>{{end}}
149156
{{if index .Cols "first_prompt"}}<th class="cell-prompt">First message</th>{{end}}
@@ -154,44 +161,111 @@ <h1>Sessions</h1>
154161
<th class="cell-started"><a href="{{index .SortURLs "started_at"}}"><span class="sort-arrow">{{index .SortArrows "started_at"}}</span>Started</a></th>
155162
</tr>
156163
</thead>
157-
<tbody>
158-
{{range .Sessions}}
159-
<tr class="session-row" data-href="{{.OpenURL}}" data-detail="/sessions/{{.Id}}">
160-
{{if index $.Cols "agent"}}<td>{{agentBadge .Agent}}</td>{{end}}
161-
{{if index $.Cols "project"}}
162-
<td class="cell-project">
163-
{{if .ProjectURL}}
164-
<a class="project-link" href="{{.ProjectURL}}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
165-
{{if eq .ProjectProvider "github"}}{{template "icon-github"}}{{else if eq .ProjectProvider "gitlab"}}{{template "icon-gitlab"}}{{end}}
166-
<span>{{.ProjectLabel}}</span>
167-
</a>
168-
{{else}}
169-
{{.ProjectLabel}}
170-
{{end}}
171-
</td>
172-
{{end}}
173-
{{if index $.Cols "first_prompt"}}
174-
<td class="cell-prompt">
175-
<a href="{{.OpenURL}}" title="{{.FirstPromptFull}}"
176-
hx-get="/sessions/{{.Id}}"
177-
hx-target="#side-panel"
178-
hx-swap="innerHTML"
179-
hx-push-url="{{.OpenURL}}">{{or .FirstPrompt "(no prompt)"}}</a>
164+
{{$cols := .Cols}}
165+
{{range $session := .Sessions}}
166+
{{if $session.Children}}
167+
<tbody class="session-group" x-data="{ open: false }">
168+
<tr class="session-row session-row-parent"
169+
data-href="{{$session.OpenURL}}" data-detail="/sessions/{{$session.Id}}">
170+
<td class="cell-expand">
171+
<button type="button" class="row-expand"
172+
@click.stop="open = !open"
173+
:aria-expanded="open.toString()"
174+
:title="open ? 'Hide subagents' : 'Show subagents'">
175+
<span class="row-expand-caret" :class="{ 'is-open': open }" aria-hidden="true"></span>
176+
<span class="row-expand-count">{{len $session.Children}}</span>
177+
</button>
180178
</td>
179+
{{if index $cols "agent"}}<td>{{agentBadge $session.Agent}}</td>{{end}}
180+
{{if index $cols "project"}}
181+
<td class="cell-project">
182+
{{if $session.ProjectURL}}
183+
<a class="project-link" href="{{$session.ProjectURL}}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
184+
{{if eq $session.ProjectProvider "github"}}{{template "icon-github"}}{{else if eq $session.ProjectProvider "gitlab"}}{{template "icon-gitlab"}}{{end}}
185+
<span>{{$session.ProjectLabel}}</span>
186+
</a>
187+
{{else}}{{$session.ProjectLabel}}{{end}}
188+
</td>
189+
{{end}}
190+
{{if index $cols "first_prompt"}}
191+
<td class="cell-prompt">
192+
<a href="{{$session.OpenURL}}" title="{{$session.FirstPromptFull}}"
193+
hx-get="/sessions/{{$session.Id}}" hx-target="#side-panel" hx-swap="innerHTML" hx-push-url="{{$session.OpenURL}}">{{or $session.FirstPrompt "(no prompt)"}}</a>
194+
</td>
195+
{{end}}
196+
{{if index $cols "tokens"}}<td class="cell-tokens" title="{{$session.TokensTotalFull}}">{{$session.TokensTotal}}</td>{{end}}
197+
{{if index $cols "cost"}}<td class="cell-tokens">{{$session.Cost}}</td>{{end}}
198+
{{if index $cols "device"}}<td>{{$session.Device}}</td>{{end}}
199+
{{if index $cols "id"}}<td class="text-mono">{{$session.Id}}</td>{{end}}
200+
<td class="cell-started"><time title="{{$session.StartedAtFull}}">{{$session.StartedRel}}</time></td>
201+
</tr>
202+
{{range $child := $session.Children}}
203+
<tr class="session-row session-row-child"
204+
data-href="{{$child.OpenURL}}" data-detail="/sessions/{{$child.Id}}"
205+
x-show="open">
206+
<td class="cell-expand"></td>
207+
{{if index $cols "agent"}}<td><span class="row-child-indent" aria-hidden="true"></span>{{agentBadge $child.Agent}}</td>{{end}}
208+
{{if index $cols "project"}}
209+
<td class="cell-project">
210+
{{if $child.ProjectURL}}
211+
<a class="project-link" href="{{$child.ProjectURL}}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
212+
{{if eq $child.ProjectProvider "github"}}{{template "icon-github"}}{{else if eq $child.ProjectProvider "gitlab"}}{{template "icon-gitlab"}}{{end}}
213+
<span>{{$child.ProjectLabel}}</span>
214+
</a>
215+
{{else}}{{$child.ProjectLabel}}{{end}}
216+
</td>
217+
{{end}}
218+
{{if index $cols "first_prompt"}}
219+
<td class="cell-prompt">
220+
<a href="{{$child.OpenURL}}" title="{{$child.FirstPromptFull}}"
221+
hx-get="/sessions/{{$child.Id}}" hx-target="#side-panel" hx-swap="innerHTML" hx-push-url="{{$child.OpenURL}}">{{or $child.FirstPrompt "(no prompt)"}}</a>
222+
</td>
223+
{{end}}
224+
{{if index $cols "tokens"}}<td class="cell-tokens" title="{{$child.TokensTotalFull}}">{{$child.TokensTotal}}</td>{{end}}
225+
{{if index $cols "cost"}}<td class="cell-tokens">{{$child.Cost}}</td>{{end}}
226+
{{if index $cols "device"}}<td>{{$child.Device}}</td>{{end}}
227+
{{if index $cols "id"}}<td class="text-mono">{{$child.Id}}</td>{{end}}
228+
<td class="cell-started"><time title="{{$child.StartedAtFull}}">{{$child.StartedRel}}</time></td>
229+
</tr>
181230
{{end}}
182-
{{if index $.Cols "tokens"}}<td class="cell-tokens" title="{{.TokensTotalFull}}">{{.TokensTotal}}</td>{{end}}
183-
{{if index $.Cols "cost"}}<td class="cell-tokens">{{.Cost}}</td>{{end}}
184-
{{if index $.Cols "device"}}<td>{{.Device}}</td>{{end}}
185-
{{if index $.Cols "id"}}<td class="text-mono">{{.Id}}</td>{{end}}
186-
<td class="cell-started"><time title="{{.StartedAtFull}}">{{.StartedRel}}</time></td>
187-
</tr>
231+
</tbody>
188232
{{else}}
233+
<tbody>
234+
<tr class="session-row" data-href="{{$session.OpenURL}}" data-detail="/sessions/{{$session.Id}}">
235+
<td class="cell-expand"></td>
236+
{{if index $cols "agent"}}<td>{{agentBadge $session.Agent}}</td>{{end}}
237+
{{if index $cols "project"}}
238+
<td class="cell-project">
239+
{{if $session.ProjectURL}}
240+
<a class="project-link" href="{{$session.ProjectURL}}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
241+
{{if eq $session.ProjectProvider "github"}}{{template "icon-github"}}{{else if eq $session.ProjectProvider "gitlab"}}{{template "icon-gitlab"}}{{end}}
242+
<span>{{$session.ProjectLabel}}</span>
243+
</a>
244+
{{else}}{{$session.ProjectLabel}}{{end}}
245+
</td>
246+
{{end}}
247+
{{if index $cols "first_prompt"}}
248+
<td class="cell-prompt">
249+
<a href="{{$session.OpenURL}}" title="{{$session.FirstPromptFull}}"
250+
hx-get="/sessions/{{$session.Id}}" hx-target="#side-panel" hx-swap="innerHTML" hx-push-url="{{$session.OpenURL}}">{{or $session.FirstPrompt "(no prompt)"}}</a>
251+
</td>
252+
{{end}}
253+
{{if index $cols "tokens"}}<td class="cell-tokens" title="{{$session.TokensTotalFull}}">{{$session.TokensTotal}}</td>{{end}}
254+
{{if index $cols "cost"}}<td class="cell-tokens">{{$session.Cost}}</td>{{end}}
255+
{{if index $cols "device"}}<td>{{$session.Device}}</td>{{end}}
256+
{{if index $cols "id"}}<td class="text-mono">{{$session.Id}}</td>{{end}}
257+
<td class="cell-started"><time title="{{$session.StartedAtFull}}">{{$session.StartedRel}}</time></td>
258+
</tr>
259+
</tbody>
260+
{{end}}
261+
{{else}}
262+
<tbody>
189263
<tr><td colspan="99" class="empty">
190264
nothing matched these filters.
191265
{{if .ClearFiltersURL}}<a href="{{.ClearFiltersURL}}">Clear all filters</a> to start over.{{end}}
192266
</td></tr>
193-
{{end}}
194-
</tbody>
267+
</tbody>
268+
{{end}}
195269
</table>
196270

197271
<div class="sessions-pagination">

internal/server/handlers/sessions_query.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ func (h *SessionsHandler) List(ctx context.Context, req *connect.Request[prosav1
100100
args = append(args, req.Msg.DeviceName)
101101
idx++
102102
}
103+
if req.Msg.TopLevelOnly {
104+
conds = append(conds, "s.parent_session_id IS NULL")
105+
}
103106
// FTS branch: when query is set, JOIN turns and filter on the
104107
// tsvector. Reuse the same Postgres operator Search uses for parity.
105108
ftsQuery := strings.TrimSpace(req.Msg.Query)

0 commit comments

Comments
 (0)