oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev 2d8ac686404fe9de15a433179ac816df38b5d20f parent af2936a
6 files changed, +126 −28
  • internal/meet/api.go +1 −1
  • internal/meet/handlers_meetings.go +18 −2
  • internal/meet/icons/icons.go +16 −11
  • internal/meet/store.go +36 −8
  • internal/meet/templates/index.html +3 −2
  • internal/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")