From 945508c1b5b8c087a1e3e980938a166d042373e0 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 9 May 2026 02:45:32 -0700 Subject: [PATCH 1/5] test: coverage for sidechain agent types, stats, grouping, matching - TestRenderMessages_SidechainShowsAgentTypeAndStats: verifies agent type name (Explore), description, token count, tool count, and lines added from toolUseResult appear in rendered sidechain headers - TestGroupSidechainsByAgent: groups by agentId, preserves order, skips empty agentId - TestMatchSidechainsToToolUse: positional matching, agent type/description enrichment from tool_use input - TestMatchSidechainsToToolUse_NoGroups: nil safety - TestMatchSidechainsToToolUse_MoreDispatchesThanGroups: no panic when dispatch count exceeds sidechain file count - TestParseToolUseResult_Object/String/NoAgentID/Empty: json.RawMessage handling for string vs object toolUseResult Export ToolStats type for test access. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/parser/session_extra_test.go | 45 ++++++++++ internal/parser/types.go | 4 +- internal/web/sidechain_filter_test.go | 117 +++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/internal/parser/session_extra_test.go b/internal/parser/session_extra_test.go index 78039a7..253e01b 100644 --- a/internal/parser/session_extra_test.go +++ b/internal/parser/session_extra_test.go @@ -1,6 +1,7 @@ package parser import ( + "encoding/json" "os" "path/filepath" "testing" @@ -415,3 +416,47 @@ func containsHelper(s, substr string) bool { } return false } + +func TestParseToolUseResult_Object(t *testing.T) { + raw := json.RawMessage(`{"agentId":"abc123","agentType":"Explore","status":"completed","totalTokens":22351,"totalToolUseCount":12,"totalDurationMs":144629}`) + result := parseToolUseResult(raw) + if result == nil { + t.Fatal("expected non-nil result for valid object") + } + if result.AgentID != "abc123" { + t.Errorf("AgentID = %q, want abc123", result.AgentID) + } + if result.AgentType != "Explore" { + t.Errorf("AgentType = %q, want Explore", result.AgentType) + } + if result.TotalTokens != 22351 { + t.Errorf("TotalTokens = %d, want 22351", result.TotalTokens) + } +} + +func TestParseToolUseResult_String(t *testing.T) { + raw := json.RawMessage(`"Error: File does not exist"`) + result := parseToolUseResult(raw) + if result != nil { + t.Error("string toolUseResult should return nil") + } +} + +func TestParseToolUseResult_NoAgentID(t *testing.T) { + raw := json.RawMessage(`{"status":"completed","stdout":"hello"}`) + result := parseToolUseResult(raw) + if result != nil { + t.Error("toolUseResult without agentId should return nil") + } +} + +func TestParseToolUseResult_Empty(t *testing.T) { + result := parseToolUseResult(nil) + if result != nil { + t.Error("nil raw should return nil") + } + result = parseToolUseResult(json.RawMessage{}) + if result != nil { + t.Error("empty raw should return nil") + } +} diff --git a/internal/parser/types.go b/internal/parser/types.go index deb1ead..f22010c 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -189,10 +189,10 @@ type SubAgentResultData struct { TotalToolUseCount int `json:"totalToolUseCount"` TotalDurationMs int `json:"totalDurationMs"` Usage *usageData `json:"usage"` - ToolStats *toolStats `json:"toolStats"` + ToolStats *ToolStats `json:"toolStats"` } -type toolStats struct { +type ToolStats struct { ReadCount int `json:"readCount"` SearchCount int `json:"searchCount"` BashCount int `json:"bashCount"` diff --git a/internal/web/sidechain_filter_test.go b/internal/web/sidechain_filter_test.go index a562bf1..6670356 100644 --- a/internal/web/sidechain_filter_test.go +++ b/internal/web/sidechain_filter_test.go @@ -29,7 +29,21 @@ func buildSessionWithSidechains() *parser.Session { }}, }, } - mainUser.Children = []*parser.Message{mainAssistant} + mainToolResult := &parser.Message{ + UUID: "tr1", + Kind: parser.KindToolResult, + Timestamp: t0.Add(5 * time.Second), + Content: []parser.ContentBlock{{Type: "tool_result", ToolID: "t1", ToolResult: "done"}}, + SubAgentResult: &parser.SubAgentResultData{ + AgentID: "agent-abc", + AgentType: "Explore", + TotalTokens: 22351, + TotalToolUseCount: 12, + TotalDurationMs: 144629, + ToolStats: &parser.ToolStats{BashCount: 8, EditFileCount: 4, LinesAdded: 484}, + }, + } + mainUser.Children = []*parser.Message{mainAssistant, mainToolResult} scUser := &parser.Message{ UUID: "sc-u1", @@ -206,6 +220,107 @@ func TestRenderMessages_SidechainStatsPreserved(t *testing.T) { } } +func TestRenderMessages_SidechainShowsAgentTypeAndStats(t *testing.T) { + session := buildSessionWithSidechains() + var b strings.Builder + renderMessages(&b, session.RootMessages, 0, false, false, true) + html := b.String() + + if !strings.Contains(html, "Explore") { + t.Error("sidechain header should show agent type name 'Explore'") + } + if !strings.Contains(html, "find auth callers") { + t.Error("sidechain header should show description") + } + if !strings.Contains(html, "22.4k tokens") || !strings.Contains(html, "12 tools") { + t.Errorf("sidechain header should show stats from toolUseResult, got html containing sidechain-meta") + } + if !strings.Contains(html, "+484 lines") { + t.Error("sidechain header should show lines added from toolStats") + } +} + +func TestGroupSidechainsByAgent(t *testing.T) { + msgs := []*parser.Message{ + {UUID: "m1"}, + {UUID: "sc1", IsSidechain: true, AgentID: "a1"}, + {UUID: "sc2", IsSidechain: true, AgentID: "a1"}, + {UUID: "sc3", IsSidechain: true, AgentID: "a2"}, + {UUID: "m2"}, + {UUID: "sc4", IsSidechain: true, AgentID: "a2"}, + {UUID: "sc5", IsSidechain: true, AgentID: ""}, // no agentId — skip + } + groups := groupSidechainsByAgent(msgs) + if len(groups) != 2 { + t.Fatalf("want 2 groups, got %d", len(groups)) + } + if groups[0].AgentID != "a1" || len(groups[0].Messages) != 2 { + t.Errorf("group 0: agentID=%q msgs=%d, want a1/2", groups[0].AgentID, len(groups[0].Messages)) + } + if groups[1].AgentID != "a2" || len(groups[1].Messages) != 2 { + t.Errorf("group 1: agentID=%q msgs=%d, want a2/2", groups[1].AgentID, len(groups[1].Messages)) + } +} + +func TestMatchSidechainsToToolUse(t *testing.T) { + mainMsgs := []*parser.Message{ + {UUID: "u1", Kind: parser.KindUserPrompt}, + {UUID: "a1", Kind: parser.KindAssistant, Content: []parser.ContentBlock{ + {Type: "tool_use", ToolName: "Agent", ToolID: "t1", ToolInput: map[string]any{ + "subagent_type": "Explore", + "description": "search codebase", + }}, + {Type: "tool_use", ToolName: "Agent", ToolID: "t2", ToolInput: map[string]any{ + "subagent_type": "linus-rants", + "description": "review naming", + }}, + }}, + } + groups := []sidechainGroup{ + {AgentID: "agent-1", Messages: []*parser.Message{{UUID: "sc1"}}}, + {AgentID: "agent-2", Messages: []*parser.Message{{UUID: "sc2"}}}, + } + scMap := matchSidechainsToToolUse(mainMsgs, groups) + if len(scMap) != 2 { + t.Fatalf("want 2 matched groups, got %d", len(scMap)) + } + g1 := scMap["t1"] + if g1.AgentType != "Explore" { + t.Errorf("t1 AgentType = %q, want Explore", g1.AgentType) + } + if g1.Description != "search codebase" { + t.Errorf("t1 Description = %q, want 'search codebase'", g1.Description) + } + g2 := scMap["t2"] + if g2.AgentType != "linus-rants" { + t.Errorf("t2 AgentType = %q, want linus-rants", g2.AgentType) + } +} + +func TestMatchSidechainsToToolUse_NoGroups(t *testing.T) { + scMap := matchSidechainsToToolUse(nil, nil) + if scMap != nil { + t.Error("nil groups should return nil map") + } +} + +func TestMatchSidechainsToToolUse_MoreDispatchesThanGroups(t *testing.T) { + mainMsgs := []*parser.Message{ + {UUID: "a1", Kind: parser.KindAssistant, Content: []parser.ContentBlock{ + {Type: "tool_use", ToolName: "Agent", ToolID: "t1"}, + {Type: "tool_use", ToolName: "Agent", ToolID: "t2"}, + {Type: "tool_use", ToolName: "Agent", ToolID: "t3"}, + }}, + } + groups := []sidechainGroup{ + {AgentID: "a1", Messages: []*parser.Message{{UUID: "sc1"}}}, + } + scMap := matchSidechainsToToolUse(mainMsgs, groups) + if len(scMap) != 1 { + t.Errorf("should match only 1 group (not panic), got %d", len(scMap)) + } +} + func TestRenderMessages_MainOnlySession(t *testing.T) { t0 := time.Date(2026, 4, 15, 10, 0, 0, 0, time.UTC) user := &parser.Message{ From 6e7245e931fbce4d010c7d2999dedfb3fb7536cd Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sat, 9 May 2026 02:52:50 -0700 Subject: [PATCH 2/5] =?UTF-8?q?style(web):=20sidechain=20nav/rail=20UX=20?= =?UTF-8?q?=E2=80=94=20semantic=20tokens,=20adaptive=20rail,=20bigger=20sa?= =?UTF-8?q?tellites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiln audit fixes: Nav sidebar: replace side-stripe accent (anti-pattern) with background tint using --event-subagent token. Smaller font, tabular-nums count. Sidechain group: replace hardcoded #86c + side-stripe with semantic color-mix tints from --event-subagent. Flex header with auto-margin meta. Remove border-left accent. Timeline rail: spine gets data-tick-count + --tick-count CSS var for height-adaptive clamping (short sessions compact, long sessions fill viewport). Satellite markers 3px -> 5px so color hue is perceptible per Weber's law. All hardcoded #86c replaced with var(--event-subagent) — one place to change agent accent across nav, rail, turns, and sidechain groups. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/web/static/style.css | 70 +++++++++++++++++++++-------------- internal/web/timeline.go | 2 +- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 0d4a689..c1a7bdd 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1207,11 +1207,18 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } .nav-compact .nav-icon { color: #fa0; } .nav-sidechain { - border-left: 3px solid #86c; + background: color-mix(in srgb, var(--event-subagent) 8%, transparent); margin: 4px 0; - opacity: 0.85; + padding-left: 10px; + font-size: 0.85em; +} +.nav-sidechain .nav-icon { color: var(--event-subagent); } +.nav-sidechain .nav-count { + font-size: 0.8em; + color: var(--text-muted); + margin-left: auto; + font-variant-numeric: tabular-nums; } -.nav-sidechain .nav-icon { color: #86c; } .nav-user { padding-left: 8px; font-weight: 500; } .nav-user .nav-icon { color: var(--user-border); } @@ -1356,11 +1363,11 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } * - 120ms grace period on mouseleave forgives slight drift. */ .timeline-rail { position: fixed; - right: 14px; /* gutter for browser scrollbar + visual breathing room */ + right: 14px; top: 56px; bottom: 24px; - width: 24px; /* invisible hit target; visual notch strip is centered */ - padding: 14px 8px 14px 14px; /* extra left-side hit zone for forgiving entry */ + width: 24px; + padding: 14px 8px 14px 14px; box-sizing: content-box; z-index: 150; background: transparent; @@ -1368,6 +1375,12 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } transition: width 0.18s ease, padding 0.18s ease; cursor: crosshair; } +/* Height-adaptive: short sessions get a compact rail instead of + stretching 5 ticks across the full viewport. JS sets --tick-count + on the spine; the rail clamps to a natural height. */ +.timeline-rail .timeline-spine[data-tick-count] { + max-height: calc(var(--tick-count, 100) * 12px); +} .timeline-rail:hover { width: 52px; padding-left: 28px; @@ -1506,8 +1519,8 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } position: absolute; right: calc(100% + 3px); top: 50%; - width: 3px; - height: 3px; + width: 5px; + height: 5px; border-radius: 50%; opacity: 0; pointer-events: none; @@ -2069,36 +2082,39 @@ body.watching .tail-spinner { display: flex; align-items: center; gap: 8px; } .turn-agent { margin-left: 16px; padding-left: 12px; - border-left: 2px solid #86c; + padding-left: 12px; + border-left: 2px solid var(--event-subagent); opacity: 0.9; } .turn-agent .turn-header { background: transparent; } -.turn-agent .turn-icon { color: #86c; } -.turn-agent .turn-role { display: inline; color: #86c; } +.turn-agent .turn-icon { color: var(--event-subagent); } +.turn-agent .turn-role { display: inline; color: var(--event-subagent); } -/* Sub-agent transcript sections */ -.sidechain-sections { margin-top: 32px; } +/* Sub-agent transcript sections — inline after dispatching tool_use */ .sidechain-group { - margin: 12px 0; - border: 1px solid var(--border, #e5e7eb); - border-left: 3px solid #86c; - border-radius: 6px; + margin: 8px 0 12px; + background: color-mix(in srgb, var(--event-subagent) 5%, var(--bg)); + border: 1px solid color-mix(in srgb, var(--event-subagent) 20%, var(--border)); + border-radius: var(--radius); } .sidechain-header { - padding: 10px 14px; + padding: 8px 12px; cursor: pointer; - font-weight: 500; - color: var(--text-secondary, #6b7280); + font-size: 0.875rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; } -.sidechain-header:hover { background: var(--bg-hover, #f9fafb); } -.sidechain-icon { color: #86c; margin-right: 6px; } +.sidechain-header:hover { background: color-mix(in srgb, var(--event-subagent) 10%, var(--bg)); } +.sidechain-icon { color: var(--event-subagent); flex-shrink: 0; } .sidechain-meta { - font-size: 0.85em; - font-weight: 400; - color: var(--text-tertiary, #9ca3af); - margin-left: 8px; + font-size: 0.8em; + color: var(--text-muted); + margin-left: auto; + font-variant-numeric: tabular-nums; } -.sidechain-body { padding: 0 14px 14px; } +.sidechain-body { padding: 4px 12px 12px; } /* Compacted context - minimal separator */ .turn-compacted { diff --git a/internal/web/timeline.go b/internal/web/timeline.go index 0fd7a71..420191f 100644 --- a/internal/web/timeline.go +++ b/internal/web/timeline.go @@ -412,7 +412,7 @@ func renderTimelineRail(b *strings.Builder, session *parser.Session) { } b.WriteString(`