~bigbes/sr-ht-spec

471d9706ec6f3b9bdb5c2726b9f6c4fbf90ddca4 — Eugene Blikh 9 days ago 636dc7a
chrome: the resolved favicon and the queue as a shared table
6 files changed, 107 insertions(+), 32 deletions(-)

M web/inbox.go
M web/inbox_test.go
M web/server.go
M web/templates/inbox.html
M web/templates/layout.html
M web/web_test.go
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.