list: the robot badge and the principal on an agent's meetings
meetingView gains CreatedByPrincipal/ByAgent (store.meetingViewOfCached, memoised per list); index.html shows a lucide bot icon plus a ui/badge and 'by <principal>' beside an agent-made meeting's title, in both the Upcoming and Ended lists. Un-skips TestAPIWritesAreMarkedAsTheAgents. Folds in the Task 3 review addendum: title length is now counted in runes on both the form and the API (one shared titleTooLong), the list/GET/end responses are pinned against carrying a stray link or admitted key, and the list row's six-key set is pinned.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
6 files changed,
+126
−28
internal/meet/api.go+1 −1internal/meet/handlers_meetings.go+18 −2internal/meet/icons/icons.go+16 −11internal/meet/store.go+36 −8internal/meet/templates/index.html+3 −2internal/meettest/api_test.go+52 −4
diff --git a/internal/meet/api.go b/internal/meet/api.go| index ff9e897..774aa16 100644 |
| --- a/internal/meet/api.go |
| +++ b/internal/meet/api.go |
| @@ -254,7 +254,7 @@ func (a *app) apiCreateMeeting(w http.ResponseWriter, r *http.Request) { |
| } |
| // Copied out field by field, never bound onto a model. |
| title := strings.TrimSpace(req.Title) |
| - if title == "" || len(title) > maxTitleLen { |
| + if title == "" || titleTooLong(title) { |
| a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "title") |
| return |
| } |
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go| index c65614a..b85721b 100644 |
| --- a/internal/meet/handlers_meetings.go |
| +++ b/internal/meet/handlers_meetings.go |
| @@ -5,12 +5,22 @@ import ( |
| "net/url" |
| "strings" |
| "time" |
| + "unicode/utf8" |
| "github.com/go-chi/chi/v5" |
| ) |
| const maxTitleLen = 200 |
| +// titleTooLong is the one length rule a title is ever measured |
| +// against, shared by the create form and the API (api.go's |
| +// apiCreateMeeting): 200 characters, counted as runes so the two |
| +// surfaces agree on what "200 characters" means for a title that is |
| +// not plain ASCII — a byte count would let the API refuse a title the |
| +// screen's own copy ("Keep the title under 200 characters.") promises |
| +// is fine. |
| +func titleTooLong(title string) bool { return utf8.RuneCountInString(title) > maxTitleLen } |
| + |
| // linkURL is built from configuration, never from the Host header. |
| func (a *app) linkURL(token string) string { |
| return a.cfg.Origin + "/j/" + token |
| @@ -60,10 +70,16 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star |
| type row struct { |
| Sqid, Title, Starts string |
| Ended, LinkPresent bool |
| + ByAgent bool |
| + CreatedByPrincipal string |
| } |
| var upcoming, ended []row |
| for _, v := range views { |
| - rw := row{Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt), Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent} |
| + rw := row{ |
| + Sqid: v.Sqid, Title: v.Title, Starts: a.fmtStart(v.StartsAt), |
| + Ended: v.EndedAt != nil, LinkPresent: v.LinkPresent, |
| + ByAgent: v.ByAgent, CreatedByPrincipal: v.CreatedByPrincipal, |
| + } |
| if rw.Ended { |
| ended = append(ended, rw) |
| } else { |
| @@ -83,7 +99,7 @@ func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) { |
| switch { |
| case title == "": |
| titleErr = "Give the meeting a title." |
| - case len(title) > maxTitleLen: |
| + case titleTooLong(title): |
| titleErr = "Keep the title under 200 characters." |
| } |
| start, startErr := a.parseStart(r.Form) |
diff --git a/internal/meet/icons/icons.go b/internal/meet/icons/icons.go| index 5338e66..b10d722 100644 |
| --- a/internal/meet/icons/icons.go |
| +++ b/internal/meet/icons/icons.go |
| @@ -21,17 +21,22 @@ import "html/template" |
| var icons = map[string]template.HTML{ |
| "alert-triangle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`, |
| - "check": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`, |
| - "check-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>`, |
| - "chevron-down": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>`, |
| - "help-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`, |
| - "info": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`, |
| - "kebab": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>`, |
| - "menu": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`, |
| - "plus": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`, |
| - "search": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`, |
| - "x": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`, |
| - "x-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`, |
| + // class carries "lucide-bot" alongside the app's own "icon" so the |
| + // drive surface's robot badge (index.html, §8.1) can be told apart |
| + // from an ordinary badge by markup alone — every other slug here |
| + // needs no such hook and stays plain "icon". |
| + "bot": `<svg class="icon lucide-bot" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>`, |
| + "check": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`, |
| + "check-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>`, |
| + "chevron-down": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>`, |
| + "help-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`, |
| + "info": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>`, |
| + "kebab": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>`, |
| + "menu": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`, |
| + "plus": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`, |
| + "search": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`, |
| + "x": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`, |
| + "x-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`, |
| } |
| // Icon renders one icon by its Lucide slug. An unknown slug renders |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index a63a837..2e13f1b 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -85,8 +85,10 @@ func (s *store) ensurePerson(ctx context.Context, address string) (Person, error |
| type meetingView struct { |
| Meeting |
| - Title string |
| - LinkPresent bool |
| + Title string |
| + LinkPresent bool |
| + CreatedByPrincipal string |
| + ByAgent bool |
| } |
| func (s *store) title(m Meeting) string { |
| @@ -142,9 +144,14 @@ func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) { |
| if err := s.g.WithContext(ctx).Order("ended_at IS NOT NULL, starts_at IS NULL, starts_at ASC, created_at DESC").Find(&ms).Error; err != nil { |
| return nil, err |
| } |
| + // labels is shared across the whole list so a run of meetings from |
| + // the same actor (one agent's usual shape) resolves creatorLabel |
| + // once rather than once per row — the N+1 Task 3's report flagged |
| + // for exactly this move. |
| + labels := make(map[string]string, len(ms)) |
| out := make([]meetingView, 0, len(ms)) |
| for _, m := range ms { |
| - v, err := s.meetingViewOf(ctx, m) |
| + v, err := s.meetingViewOfCached(ctx, m, labels) |
| if err != nil { |
| return nil, err |
| } |
| @@ -154,17 +161,38 @@ func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) { |
| } |
| // meetingViewOf is one row as every reader wants it: the meeting, its |
| -// title opened, and whether a live link exists. Factored out of |
| -// listMeetings so the drive surface's single-meeting read (§8.2) |
| -// answers link_present from the same query the list does, rather than |
| -// growing a second opinion about what "present" means. |
| +// title opened, whether a live link exists, and who made it — an |
| +// agent's principal (§8.1's robot badge and operator name "on every |
| +// write") or a person's display name, resolved through creatorLabel. |
| +// Factored out of listMeetings so the drive surface's single-meeting |
| +// read (§8.2) answers link_present from the same query the list does, |
| +// rather than growing a second opinion about what "present" means. |
| func (s *store) meetingViewOf(ctx context.Context, m Meeting) (meetingView, error) { |
| + return s.meetingViewOfCached(ctx, m, map[string]string{}) |
| +} |
| + |
| +// meetingViewOfCached is meetingViewOf's body, taking a creator-label |
| +// cache a caller can share across many rows. meetingViewOf itself |
| +// passes a throwaway map, since a single row has nothing to share a |
| +// cache with; listMeetings passes one map for the whole list. |
| +func (s *store) meetingViewOfCached(ctx context.Context, m Meeting, labels map[string]string) (meetingView, error) { |
| var n int64 |
| if err := s.g.WithContext(ctx).Model(&MeetingLink{}). |
| Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil { |
| return meetingView{}, err |
| } |
| - return meetingView{Meeting: m, Title: s.title(m), LinkPresent: n > 0}, nil |
| + by, ok := labels[m.CreatedBy] |
| + if !ok { |
| + by = s.creatorLabel(ctx, m.CreatedBy) |
| + labels[m.CreatedBy] = by |
| + } |
| + return meetingView{ |
| + Meeting: m, |
| + Title: s.title(m), |
| + LinkPresent: n > 0, |
| + CreatedByPrincipal: by, |
| + ByAgent: strings.HasPrefix(m.CreatedBy, "agent:"), |
| + }, nil |
| } |
| func (s *store) meetingBySqid(ctx context.Context, sqid string) (Meeting, bool, error) { |
diff --git a/internal/meet/templates/index.html b/internal/meet/templates/index.html| index 1ed42de..feb4ef9 100644 |
| --- a/internal/meet/templates/index.html |
| +++ b/internal/meet/templates/index.html |
| @@ -18,7 +18,8 @@ |
| <div rst-list> |
| {{range .Upcoming}} |
| <article> |
| - <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a></h3> |
| + <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a>{{if .ByAgent}} {{icon "bot"}}{{template "badge" dict "Label" "Agent"}}{{end}}</h3> |
| + {{if .ByAgent}}<p>by {{.CreatedByPrincipal}}</p>{{end}} |
| <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p> |
| <p><a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p> |
| </article> |
| @@ -32,7 +33,7 @@ |
| <details> |
| <summary>Ended meetings ({{len .Ended}})</summary> |
| <div rst-list> |
| -{{range .Ended}}<article><h3>{{.Title}}</h3><p>Ended</p></article>{{end}} |
| +{{range .Ended}}<article><h3>{{.Title}}{{if .ByAgent}} {{icon "bot"}}{{template "badge" dict "Label" "Agent"}}{{end}}</h3>{{if .ByAgent}}<p>by {{.CreatedByPrincipal}}</p>{{end}}<p>Ended</p></article>{{end}} |
| </div> |
| </details> |
| {{end}} |
diff --git a/internal/meettest/api_test.go b/internal/meettest/api_test.go| index 9749297..5ded266 100644 |
| --- a/internal/meettest/api_test.go |
| +++ b/internal/meettest/api_test.go |
| @@ -281,11 +281,15 @@ func TestAPIListsCreatesLinksAndEnds(t *testing.T) { |
| t.Fatal("the link carries the agent token rather than a link token") |
| } |
| - // The meeting, before anyone is in the room. |
| + // The meeting, before anyone is in the room. GET never carries a |
| + // link — that only ever rides on create and replace. |
| got := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) |
| if got["admitted"] != float64(0) { |
| t.Fatalf("admitted %#v, want 0", got["admitted"]) |
| } |
| + if _, present := got["link"]; present { |
| + t.Fatalf("GET carries a link: %v", got) |
| + } |
| // A member joins through the browser and is admitted on standing. |
| s := c.stream("/m/" + sqid + "/stream") |
| @@ -309,11 +313,15 @@ func TestAPIListsCreatesLinksAndEnds(t *testing.T) { |
| t.Fatalf("the replaced link still works: %d", rec.Code) |
| } |
| - // Ending: the meeting comes back ended, and without a live count. |
| + // Ending: the meeting comes back ended, and without a live count or |
| + // a link — end never returns one. |
| ended := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK) |
| if _, present := ended["admitted"]; present { |
| t.Fatalf("an ended meeting still reports admitted: %v", ended) |
| } |
| + if _, present := ended["link"]; present { |
| + t.Fatalf("the end response carries a link: %v", ended) |
| + } |
| em := apiMeeting(t, ended) |
| if em["ended_at"] == nil { |
| t.Fatalf("ended_at is null after end: %v", em) |
| @@ -322,6 +330,17 @@ func TestAPIListsCreatesLinksAndEnds(t *testing.T) { |
| t.Fatalf("link_present %v after end, want false", em["link_present"]) |
| } |
| + // GET on the now-ended meeting still has no admitted count and no |
| + // link (the guard is m.EndedAt == nil, checked again independently |
| + // of the end response above). |
| + afterEnd := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) |
| + if _, present := afterEnd["admitted"]; present { |
| + t.Fatalf("GET on an ended meeting still reports admitted: %v", afterEnd) |
| + } |
| + if _, present := afterEnd["link"]; present { |
| + t.Fatalf("GET on an ended meeting carries a link: %v", afterEnd) |
| + } |
| + |
| for _, target := range []string{"/api/v1/meetings/" + sqid + "/end", "/api/v1/meetings/" + sqid + "/links"} { |
| body := apiBody(t, api(t, x, http.MethodPost, target, token, ""), http.StatusConflict) |
| if body["error"] != "ended" { |
| @@ -342,6 +361,20 @@ func TestAPIListsCreatesLinksAndEnds(t *testing.T) { |
| if row["ended_at"] == nil { |
| t.Fatalf("the listed row is not ended: %v", row) |
| } |
| + if _, present := row["link"]; present { |
| + t.Fatalf("a list row carries a link: %v", row) |
| + } |
| + // The meeting object is exactly these six keys — never more, never |
| + // a link riding along on the list. |
| + wantKeys := []string{"sqid", "title", "starts_at", "ended_at", "link_present", "created_by"} |
| + if len(row) != len(wantKeys) { |
| + t.Fatalf("list row has %d keys, want %d: %v", len(row), len(wantKeys), row) |
| + } |
| + for _, k := range wantKeys { |
| + if _, ok := row[k]; !ok { |
| + t.Fatalf("list row is missing %q: %v", k, row) |
| + } |
| + } |
| if strings.Contains(list["meetings"].([]any)[0].(map[string]any)["title"].(string), "mt_") { |
| t.Fatal("a token leaked into the title") |
| } |
| @@ -394,6 +427,23 @@ func TestAPIBodyRules(t *testing.T) { |
| t.Fatalf("an absent starts_at came back as %v", apiMeeting(t, created)["starts_at"]) |
| } |
| + // The 200-character boundary is runes, not bytes (matching the |
| + // create screen's own "characters" copy, handlers_meetings.go's |
| + // titleTooLong): 200 multibyte characters are accepted even though |
| + // they are 600 bytes, and 201 of them are refused exactly as 201 |
| + // ASCII characters are. |
| + wideOK := strings.Repeat("字", 200) |
| + createdWide := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + string(mustJSON(t, map[string]string{"title": wideOK}))), http.StatusCreated) |
| + if apiString(t, apiMeeting(t, createdWide), "title") != wideOK { |
| + t.Fatal("a 200-rune title came back changed") |
| + } |
| + wideRefused := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + string(mustJSON(t, map[string]string{"title": strings.Repeat("字", 201)}))), http.StatusBadRequest) |
| + if wideRefused["error"] != "bad-request" { |
| + t.Fatalf("a 201-rune title: %v, want bad-request", wideRefused) |
| + } |
| + |
| // A title is data, not markup: it is stored and listed as a plain |
| // JSON string, escaped by the encoder and never by an HTML escaper. |
| const scriptish = `<script>alert("x")</script>` |
| @@ -508,8 +558,6 @@ func TestAPIRateLimitsPerAgent(t *testing.T) { |
| // list the member reads, with the robot badge and the principal. The |
| // assertion belongs to this file; the badge itself is Task 4's. |
| func TestAPIWritesAreMarkedAsTheAgents(t *testing.T) { |
| - t.Skip("Task 4: the badge and the principal on the HTML list are not implemented yet") |
| - |
| x := newHarness(t, "paul@example.com") |
| c := x.browser(t) |
| c.signIn("paul@example.com") |