M web/inbox.go => web/inbox.go +34 -8
@@ 7,6 7,7 @@ import (
"time"
"go.bigb.es/auxilia/scribe"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
@@ 20,8 21,18 @@ import (
// mark, so the new rows are exactly the first NewCount — the template draws the
// "since you last looked" divider after them and shows the mark-as-seen action
// only when there is something new to clear.
+//
+// The two listings are not the same kind of thing, which is why only one of them
+// is a chrome.RepoList. The open queue is a plain listing and renders through
+// ecore's "srht-repo-table", so a proposal waiting on the owner looks like a
+// repository on the sibling services. The digest is not a listing: it carries a
+// per-row "new" badge and a divider row inserted between items at NewCount, and
+// a partial over a flat []ListItem can express neither — Meta is plain text, so
+// a badge would come out escaped, and nothing can interleave a row that is not
+// an item. It stays this package's own markup rather than being flattened into
+// something that loses the one thing the page is for.
type inboxData struct {
- Open []proposalRow
+ Open chrome.RepoList
Digest []proposalRow
NewCount int
}
@@ 69,7 80,7 @@ func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
vd := s.view(r, "Review queue")
vd.Data = inboxData{
- Open: proposalRows(open),
+ Open: openQueue(open),
Digest: digestRows,
NewCount: newCount,
}
@@ 97,14 108,29 @@ func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/inbox", http.StatusSeeOther)
}
-// proposalRows turns service proposals into listing rows for the open queue,
-// where nothing is ever "new".
-func proposalRows(ps []service.Proposal) []proposalRow {
- rows := make([]proposalRow, 0, len(ps))
+// emptyQueue is what the open queue says when there is nothing waiting. It is
+// the sentence the page used to carry inline, moved to where the partial reads
+// it from.
+const emptyQueue = "No open proposals. Your queue is clear."
+
+// openQueue turns the open proposals into ecore's listing shape.
+//
+// The title carries the id because a proposal is addressed by number and the
+// number is what an agent quotes back; the space and the agent are Meta, which
+// the table renders as its own columns in order. There is no Updated: a proposal
+// row's useful timestamp is when it was opened, and the read layer does not
+// carry one — see the report on this uplift.
+func openQueue(ps []service.Proposal) chrome.RepoList {
+ list := chrome.RepoList{Empty: emptyQueue}
for _, p := range ps {
- rows = append(rows, proposalRowOf(p))
+ row := proposalRowOf(p)
+ list.Items = append(list.Items, chrome.ListItem{
+ Href: row.Href,
+ Title: "#" + strconv.Itoa(row.ID) + " — " + row.Title,
+ Meta: []string{row.Space, row.Agent},
+ })
}
- return rows
+ return list
}
// digestRows turns the digest proposals into rows, flagging each that
M web/inbox_test.go => web/inbox_test.go +20 -0
@@ 7,6 7,8 @@ import (
"testing"
"time"
+ "github.com/stretchr/testify/assert"
+
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
@@ 56,6 58,24 @@ func TestInboxListsOpenAndDigest(t *testing.T) {
}
}
+// The open queue is drawn by ecore's "srht-repo-table", so the space and the
+// agent ride in the item's Meta rather than in columns this package writes. This
+// is the seam: that both still reach the page, since "who proposed this, and
+// where" is what the queue is scanned for.
+func TestInboxOpenQueueCarriesSpaceAndAgent(t *testing.T) {
+ r := newFakeReader()
+ seedProposal(r, service.Proposal{
+ ID: 3, Space: demoSpace, Title: "Open one", State: core.StateOpen,
+ Agent: "claude-code/a",
+ }, nil)
+ h, _, _ := testServerWith(t, r)
+
+ body := get(t, h, "/inbox", "bigbes").Body.String()
+ assert.Contains(t, body, "#3 — Open one")
+ assert.Contains(t, body, demoSpace.String())
+ assert.Contains(t, body, "claude-code/a")
+}
+
// TestInboxEmpty proves the page renders with no proposals rather than erroring.
func TestInboxEmpty(t *testing.T) {
h, _, _ := testServerWith(t, newFakeReader())
M web/server.go => web/server.go +14 -0
@@ 87,6 87,7 @@ package web
import (
"fmt"
+ "html/template"
"io/fs"
"log/slog"
"net/http"
@@ 214,6 215,19 @@ func New(opts Options) (*Server, error) {
// empty <link>, so an unstyled build stays a presentation failure.
chromeSvc.StyleHref = cssHref
+ // This service ships its own icon, so it overrides chrome's built-in data:
+ // URI with it — resolved rather than spelled, so that the day logo.svg is
+ // hashed or renamed the href follows and an absent one is "" (no <link>)
+ // instead of a 404 on every page load. The glob is exact today; it is a glob
+ // so that a hashed name needs no second edit here.
+ iconHref, err := assets.Resolve(staticFS, "static/logo.*svg", assets.DefaultPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("web: %w", err)
+ }
+ if iconHref != "" {
+ chromeSvc.FaviconHref = template.URL(iconHref)
+ }
+
// A page that defines no "content" is refused here rather than serving a
// 200 around a hole, so this error is a startup failure and not a warning.
set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
M web/templates/inbox.html => web/templates/inbox.html +15 -19
@@ 5,26 5,22 @@
<h3 class="h5">
Waiting on you
- <span class="badge badge-primary">{{len .Data.Open}}</span>
+ <span class="badge badge-primary">{{len .Data.Open.Items}}</span>
</h3>
- {{if .Data.Open}}
- <table class="table">
- <thead>
- <tr><th>Proposal</th><th>Space</th><th>Agent</th></tr>
- </thead>
- <tbody>
- {{range .Data.Open}}
- <tr>
- <td><a href="{{.Href}}">#{{.ID}} — {{.Title}}</a></td>
- <td class="text-muted"><code>{{.Space}}</code></td>
- <td class="text-muted"><code>{{.Agent}}</code></td>
- </tr>
- {{end}}
- </tbody>
- </table>
- {{else}}
- <p class="text-muted">No open proposals. Your queue is clear.</p>
- {{end}}
+ {{/* The listing markup is ecore's, so a proposal waiting on the owner and a
+ repository on a sibling service look like the same kind of thing. The
+ columns after the title are the item's Meta — the space, then the agent
+ — and the partial carries the empty-queue sentence itself.
+
+ What is lost against the hand-written table this replaces is the header
+ row: srht-repo-table draws no headings, because the meaning of the
+ columns it renders is known only to the service supplying them. Here
+ that costs the "Space" and "Agent" labels, which the values say plainly
+ enough on their own (~owner/space, and an agent identity).
+
+ The digest below is deliberately NOT this partial; inboxData's doc
+ comment says what it needs that a flat listing cannot express. */}}
+ {{template "srht-repo-table" .Data.Open}}
<h3 class="h5">
Recently auto-merged
M web/templates/layout.html => web/templates/layout.html +11 -5
@@ 17,11 17,17 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
- <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
- {{/* Empty when this binary was built without `make css`. The link is
- guarded rather than emitted empty: <link href=""> re-requests the page
- it is on, which is a page load per page load. */}}
- {{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
+ {{/* The stylesheet and the favicon, both from sr-ht-ecore's own partial and
+ both guarded there rather than emitted empty: <link href=""> re-requests
+ the page it is on, which is a page load per page load, and the
+ stylesheet is empty in a binary built without `make css`.
+
+ The icon used to be the literal /static/logo.svg written here. It is
+ still that file, but the href is now resolved at startup through
+ assets.Resolve: a path a template asserts is one that 404s on every page
+ load if the file is ever renamed or hashed, where a resolved one is
+ absent once, at startup, in front of whoever can fix it. */}}
+ {{template "srht-head-links" .}}
{{block "head" .}}{{end}}
</head>
<body>
M web/web_test.go => web/web_test.go +13 -0
@@ 769,6 769,19 @@ func TestTrailingSlashRedirectsToTheSpace(t *testing.T) {
// ecore the identity its own authn resolved, and that the page around the
// chrome shows the right thing to that identity.
+// The <head> links come from ecore's own partial, and the icon is this
+// service's: chrome ships a built-in data: URI as its default and New overrides
+// it with the resolved href of the embedded logo. What this pins is the seam —
+// that the override reached the page — and not the partial, which is ecore's.
+func TestPagesLinkThisServicesIcon(t *testing.T) {
+ h, _ := testServer(t)
+ body := get(t, h, "/", "bigbes").Body.String()
+ assert.Contains(t, body, `<link rel="icon" href="/static/logo.svg">`)
+ // This build has no stylesheet, and the partial guards the link rather than
+ // emitting an empty href, which would re-request the page it sits on.
+ assert.NotContains(t, body, `<link rel="stylesheet" href="">`)
+}
+
// The owner's cookie must reach the chrome as an identity: ecore renders the
// login block from the username it is given, so a greeting by name is the proof
// that view() passed one.