clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat(tinyclient): start and cancel long tasks from the room chat

Dami committed c1cb426
The chat activity panel gains an Ask an agent form that publishes a
kind 43001 request to one of the room's agents, scoped to the thread
when the panel is, and a Cancel button on open cards the signed-in
requester owns that publishes a kind 43005. Cards show the status, the
newest progress line, assignees, results with artifact links and
errors, and keep rendering without JavaScript.
commit
c1cb426afbb03b7357c41bd09742865bd03e1484
parent
fc78fb98e165ce877adcac1af1537ad73060d2ed
author
Dami <dami.etoile@gmail.com>
8 files changed, +318 -14
Mdocs/agents.md2+-
Mscripts/chat-activity.test.mjs71++++++++++++++++++++++++++++++
Mtinyclient/chat-activity.go63+++++++++++++++++++++++++--
Mtinyclient/chat-activity.html4+-
Mtinyclient/chat-activity.js61++++++++++++++++++++++++--
Mtinyclient/chat_activity_http_test.go66+++++++++++++++++++++++++++-
Mtinyclient/chat_activity_test.go50+++++++++++++++++++++
Mtinyclient/style.css15++++++
M docs/agents.md +1 -1
@@ -266,7 +266,7 @@ Agents can participate in room conversations using the same Nostr events as peop
266266 267267 An agent may publish ephemeral kind 20001 presence and kind 20002 typing events when its grant includes those kinds. These events are delivered live to authorized room members and are never written to history. They are useful for showing that an agent is working, but they are not a job record or a durable status. See Buzz's [Nostr event conventions](https://github.com/block/buzz/blob/main/NOSTR.md) for a compatible client reference.268268 269-Job requests appear as compact task cards above the room conversation, with the newest progress line, the result and its artifact links, the error or the cancel. Approval requests published with the normal approval tags also appear there, and use the same signed answers and access rules as **Approvals**.269+Job requests appear as compact task cards above the room conversation, each with its status, the newest progress line, the result and its artifact links, the error or the cancel. Choose **Ask an agent** above the cards to publish a request from the room page: pick one of the room's agent members, or enter an agent's public key when the room lists none, add an optional subject and the task text. A request made from a thread page attaches to that thread. The requester's open cards carry **Cancel task**. Asking and cancelling need JavaScript and a connected signer. Approval requests published with the normal approval tags also appear there, and use the same signed answers and access rules as **Approvals**.270270 271271 The relay uses the same six job kinds, 43001 to 43006, that Buzz reserves for long tasks, so a Buzz client and the relay's own pages read the same events. Buzz leaves the tags open; the relay's tags are listed under [Long tasks](#long-tasks).272272
M scripts/chat-activity.test.mjs +71 -0
@@ -152,3 +152,74 @@ test("combined answers still require freeform permission on the current question
152152 assert.equal(published.length,0);153153 }154154 });155+156+// The request form and the cancel button share the host, the signer check157+// and the publish path with decisions; these cover the events they sign.158+const agent = "d".repeat(64), second = "e".repeat(64), root = "f".repeat(64);159+function requestForm({root: thread = null} = {}) {160+ const {definitions, document, published, signer} = setup();161+ const host = new (definitions["chat-activity"])(); host.localName = "chat-activity"; host.parent = document;162+ host.refresh = async force => { host.refreshed = force; };163+ const request = new (definitions["chat-request"])(); request.localName = "chat-request"; request.parent = host;164+ for (const [name, value] of [["room", "room"], ["actor", actor]]) request.setAttribute(name, value);165+ if (thread) request.setAttribute("root", thread);166+ return {request, host, published, signer};167+}168+169+test("request publishes a kind 43001 with room, agent, subject and thread root tags", async () => {170+ const {request, host, published} = requestForm({root});171+ const form = {elements: {agent: {value: agent.toUpperCase()}, subject: {value: " Build the preview "}, content: {value: " Use the dark palette. "}}, reset() { this.wasReset = true; }};172+ await request.submit(form);173+ assert.equal(published.length, 1);174+ assert.equal(published[0].kind, 43001);175+ assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["h", "room"], ["p", agent], ["subject", "Build the preview"], ["e", root, "", "root"]]);176+ assert.equal(published[0].content, "Use the dark palette.");177+ assert.equal(typeof published[0].created_at, "number");178+ assert.equal(form.wasReset, true);179+ assert.equal(host.refreshed, true);180+ assert.equal(request.message, "Task sent.");181+});182+183+test("request outside a thread omits subject and root tags and validates its fields", async () => {184+ const {request, published, signer} = requestForm();185+ await request.submit({elements: {agent: {value: agent}, subject: {value: ""}, content: {value: "Transcribe the call."}}});186+ assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["h", "room"], ["p", agent]]);187+ await assert.rejects(request.submit({elements: {agent: {value: "not-a-key"}, content: {value: "x"}}}), /public key/);188+ await assert.rejects(request.submit({elements: {agent: {value: agent}, subject: {value: " "}, content: {value: " "}}}), /Write the task/);189+ await assert.rejects(request.submit({elements: {agent: {value: agent}, subject: {value: "s".repeat(201)}, content: {value: "x"}}}), /subject is too long/);190+ signer.getPublicKey = async () => author;191+ await assert.rejects(request.submit({elements: {agent: {value: agent}, content: {value: "x"}}}), /Connect the signer/);192+ assert.equal(published.length, 1);193+});194+195+function cancelButton(listing) {196+ const {definitions, document, template, published} = setup();197+ const host = new (definitions["chat-activity"])(); host.localName = "chat-activity"; host.parent = document;198+ host.read = async () => "fresh"; host.schedule = () => { host.scheduled = true; }; host.refresh = async force => { host.refreshed = force; };199+ template.content = {querySelectorAll: selector => selector === "chat-cancel" ? listing : []};200+ const cancel = new (definitions["chat-cancel"])(); cancel.localName = "chat-cancel"; cancel.parent = host;201+ for (const [name, value] of [["event", request], ["room", "room"], ["actor", actor], ["assignees", agent + " " + second]]) cancel.setAttribute(name, value);202+ return {cancel, host, published};203+}204+205+test("cancel revalidates the open card and publishes a kind 43005 naming each assignee", async () => {206+ const allowed = new Node("chat-cancel");207+ for (const [name, value] of [["event", request], ["room", "room"], ["actor", actor], ["assignees", agent + " " + second + " short"]]) allowed.setAttribute(name, value);208+ const {cancel, host, published} = cancelButton([allowed]);209+ await cancel.submit({elements: {}});210+ assert.equal(published.length, 1);211+ assert.equal(published[0].kind, 43005);212+ assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["e", request], ["h", "room"], ["p", agent], ["p", second]]);213+ assert.equal(published[0].content, "");214+ assert.equal(host.refreshed, true);215+ assert.equal(cancel.message, "Task cancelled.");216+});217+218+test("cancel refuses a task that is no longer open for this account", async () => {219+ const stale = new Node("chat-cancel");220+ for (const [name, value] of [["event", request], ["room", "room"], ["actor", author]]) stale.setAttribute(name, value);221+ const {cancel, host, published} = cancelButton([stale]);222+ await assert.rejects(cancel.submit({elements: {}}), /no longer open/);223+ assert.equal(host.scheduled, true);224+ assert.equal(published.length, 0);225+});
M tinyclient/chat-activity.go +59 -4
@@ -26,6 +26,8 @@ type chatActivityItem struct {
2626 Options []chatActivityOption2727 Progress string2828 Result, Error *chatActivityResult29+ Assignees []string30+ CanCancel bool2931 }3032 type chatActivityOption struct{ ID, Label string }3133 type chatActivityResult struct {
@@ -34,9 +36,14 @@ type chatActivityResult struct {
3436 }3537 type chatActivityLink struct{ Href, Label string }3638 type chatActivityPage struct {37- Items []chatActivityItem38- Endpoint, Room, Actor, Error string39+ Items []chatActivityItem40+ Endpoint, Room, Root, Actor, Error string41+ // Ask renders the request form: the backend serves long tasks and the42+ // viewer is signed in. Agents lists the room's agent members for it.43+ Ask bool44+ Agents []chatActivityAgent3945 }46+type chatActivityAgent struct{ PubKey, Operator string }4047 4148 func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {4249 items := []chatActivityItem{}
@@ -54,6 +61,11 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
5461 title = "Agent task"5562 }5663 row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: "43001", State: plainString(item["state"]), Status: plainString(item["status"]), Title: truncateActivity(title, 100), Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: unixSeconds(item["expires"])}64+ for _, assignee := range stringValues(item["assignees"]) {65+ if eventIDPattern.MatchString(assignee) {66+ row.Assignees = append(row.Assignees, assignee)67+ }68+ }5769 if progress := valueMap(item["progress"]); len(progress) > 0 {5870 row.Progress = strings.TrimSpace(plainString(progress["content"]))5971 }
@@ -74,6 +86,7 @@ func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatA
7486 if row.Status == "" {7587 row.Status = "queued"7688 }89+ row.CanCancel = actor != "" && row.Author == actor && row.State == "open"7790 items = append(items, row)7891 }7992 for _, raw := range collaborationSlice(approvals, "items") {
@@ -223,12 +236,23 @@ func stringValues(value any) []string {
223236 return values224237 }225238 239+// chatActivity renders the room page's panel: the cards plus the request240+// form with the room's agent members.226241 func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {242+ page := a.chatActivityCards(ctx, actor, room, root)243+ if _, ok := a.backend.(ChatActivityReader); ok && actor != "" && page.Error == "" {244+ page.Ask = true245+ page.Agents = a.roomAgents(ctx, actor, room)246+ }247+ return page248+}249+250+func (a *App) chatActivityCards(ctx context.Context, actor, room, root string) chatActivityPage {227251 endpoint := "/chat/activity?room=" + url.QueryEscape(room)228252 if root != "" {229253 endpoint += "&root=" + url.QueryEscape(root)230254 }231- page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}255+ page := chatActivityPage{Endpoint: endpoint, Room: room, Root: root, Actor: actor}232256 reader, ok := a.backend.(ChatActivityReader)233257 if !ok {234258 return page
@@ -242,6 +266,37 @@ func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatAc
242266 page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)243267 return page244268 }269+270+// roomAgents lists the agent members the viewer may see in a room, from the271+// same room read the room page uses, sorted by key so the form is stable.272+func (a *App) roomAgents(ctx context.Context, actor, room string) []chatActivityAgent {273+ var members []any274+ if reader := a.roomsReader(); reader != nil {275+ page, err := reader.ReadRoom(ctx, actor, room, "", 1)276+ if err != nil {277+ return nil278+ }279+ members = roomMembers(roomPageValueFromContract(page))280+ } else {281+ raw, _ := json.Marshal(map[string]any{"id": room, "limit": 1})282+ result, err := a.backend.Query(ctx, "browseroom", []json.RawMessage{raw}, actor)283+ if err != nil {284+ return nil285+ }286+ members = roomMembers(result)287+ }288+ var agents []chatActivityAgent289+ for _, row := range members {290+ member := valueMap(row)291+ pubkey := plainString(member["pubkey"])292+ if isAgent, _ := member["agent"].(bool); !isAgent || !eventIDPattern.MatchString(pubkey) {293+ continue294+ }295+ agents = append(agents, chatActivityAgent{PubKey: pubkey, Operator: plainString(member["operator"])})296+ }297+ sort.Slice(agents, func(i, j int) bool { return agents[i].PubKey < agents[j].PubKey })298+ return agents299+}245300 func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {246301 w.Header().Set("Cache-Control", "private, no-store")247302 actor, err := a.resolveActor(r)
@@ -254,7 +309,7 @@ func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {
254309 http.Error(w, "Invalid room or thread.", http.StatusBadRequest)255310 return256311 }257- page := a.chatActivity(r.Context(), actor, room, root)312+ page := a.chatActivityCards(r.Context(), actor, room, root)258313 if page.Error != "" {259314 http.Error(w, page.Error, http.StatusForbidden)260315 return
M tinyclient/chat-activity.html +2 -2
@@ -1,8 +1,8 @@
1-{{define "chatActivity"}}<chat-activity id="chat-activity" endpoint="{{.Endpoint}}" room="{{.Room}}" actor="{{.Actor}}" fx-action="{{.Endpoint}}" fx-trigger="tiny:activity-refresh" fx-target="#chat-activity-cards" fx-swap="morph"><div id="chat-activity-cards">{{template "chatActivityCards" .}}</div></chat-activity>{{end}}1+{{define "chatActivity"}}<chat-activity id="chat-activity" endpoint="{{.Endpoint}}" room="{{.Room}}" actor="{{.Actor}}" fx-action="{{.Endpoint}}" fx-trigger="tiny:activity-refresh" fx-target="#chat-activity-cards" fx-swap="morph">{{if .Ask}}<details id="chat-ask"><summary>Ask an agent</summary><chat-request room="{{.Room}}" actor="{{.Actor}}"{{with .Root}} root="{{.}}"{{end}}><label>Agent {{if .Agents}}<select name="agent" required>{{range .Agents}}<option value="{{.PubKey}}">{{shortID .PubKey}}{{if .Operator}} ({{shortID .Operator}}'s agent){{else}} (agent){{end}}</option>{{end}}</select>{{else}}<input name="agent" required pattern="[0-9a-f]{64}" maxlength="64" placeholder="agent public key, 64 hex characters" spellcheck="false" autocomplete="off">{{end}}</label><label>Subject <input name="subject" maxlength="200" placeholder="optional"></label><label>Task <textarea name="content" required maxlength="8000" rows="3" placeholder="What should the agent do?"></textarea></label><button type="submit" data-primary>Ask</button></chat-request><noscript><p>Publishing needs JavaScript and a connected signer.</p></noscript></details>{{end}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div></chat-activity>{{end}}22 {{define "chatActivityResponse"}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div>{{end}}33 {{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">44 {{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if eq .Interaction "approval"}}Approval request{{else if eq .Interaction "confirmation"}}Confirmation{{else if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}55 <p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals/{{.ID}}">Open request</a></p>66 {{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}" interaction="{{.Interaction}}" selection="{{if .Multiple}}multiple{{else if .Native}}{{if .Options}}single{{else}}text{{end}}{{end}}"{{if .Freeform}} freeform{{end}}{{if .Question}} question{{end}}{{if .Native}} native{{end}}{{if .Multiple}} multiple{{end}}>{{if .Native}}{{$multiple := .Multiple}}{{$freeform := .Freeform}}{{if .Options}}<fieldset><legend>{{if $multiple}}Choose one or more options{{else}}Choose an option{{end}}</legend>{{range .Options}}<label><input type="{{if $multiple}}checkbox{{else}}radio{{end}}" name="option" value="{{.ID}}"> {{.Label}}</label>{{end}}</fieldset>{{end}}{{if $freeform}}<label>{{if .Options}}Add details or write your own answer{{else}}Your answer{{end}} <textarea name="custom-answer" maxlength="8000" rows="2"></textarea></label>{{end}}<button type="submit" data-primary>Submit</button>{{else if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit" data-primary>Reply</button>{{else}}<button type="submit" name="reaction" value="+" data-primary>Approve</button><button type="submit" name="reaction" value="-" data-danger>Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}7-{{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>from {{template "nostrName" .Author}}</small></p>{{if ne .Title .Content}}{{with .Content}}<div>{{chatMarkdown .}}</div>{{end}}{{end}}{{with .Progress}}<p>{{.}}</p>{{end}}{{with .Result}}<h4>Result</h4>{{if .Content}}<div>{{chatMarkdown .Content}}</div>{{end}}{{if .Artifacts}}<ul>{{range .Artifacts}}<li><a href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}{{with .Error}}<h4>Error</h4><p>{{.Content}}</p>{{end}}{{if eq .State "cancelled"}}<p>Cancelled by the requester.</p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}7+{{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>from {{template "nostrName" .Author}}{{if .Assignees}} for {{range $i, $p := .Assignees}}{{if $i}}, {{end}}{{template "nostrName" $p}}{{end}}{{end}}</small></p>{{if ne .Title .Content}}{{with .Content}}<div>{{chatMarkdown .}}</div>{{end}}{{end}}{{if eq .State "open"}}{{with .Progress}}<p data-progress>{{.}}</p>{{end}}{{end}}{{with .Result}}<h4>Result</h4>{{if .Content}}<div>{{chatMarkdown .Content}}</div>{{end}}{{if .Artifacts}}<ul>{{range .Artifacts}}<li><a href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}{{with .Error}}<h4>Error</h4><p data-error>{{.Content}}</p>{{end}}{{if eq .State "cancelled"}}<p>Cancelled by the requester.</p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p>{{if .CanCancel}}<chat-cancel event="{{.ID}}" room="{{.Room}}" actor="{{$.Actor}}" assignees="{{range $i, $p := .Assignees}}{{if $i}} {{end}}{{$p}}{{end}}"><button type="submit" data-danger>Cancel task</button></chat-cancel>{{end}}</details>{{end}}88 </article>{{end}}{{end}}{{end}}
M tinyclient/chat-activity.js +56 -5
@@ -1,6 +1,20 @@
11 (() => {22 "use strict";33 const tiny=globalThis.tiny;4+ const isHex64=value=>typeof value==="string" && /^[0-9a-f]{64}$/.test(value);5+ const now=()=>Math.floor(Date.now()/1000);6+ // requireSigner confirms the connected signer still belongs to the account7+ // the card or form was rendered for before anything is signed.8+ const requireSigner=async actor=>{9+ const signer=tiny.signer();10+ if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account.");11+ };12+ // freshElement re-reads the panel and returns the matching element when13+ // the request still waits for this account, so a stale card cannot sign.14+ const freshElement=async(host,name,id,actor)=>{15+ const fresh=document.createElement("template"); fresh.innerHTML=await host.read();16+ return [...fresh.content.querySelectorAll(name)].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor) || null;17+ };418 class ChatActivity extends HTMLElement {519 connectedCallback() {620 if (this.running) return;
@@ -52,7 +66,7 @@
5266 const opened=new Set([...this.querySelectorAll("details[open][data-task]")].map(el=>el.dataset.task));5367 morph(cfg.target,cfg.text);5468 cfg.target.querySelectorAll("details[data-task]").forEach(el=>{el.open=opened.has(el.dataset.task);});55- cfg.target.querySelectorAll("chat-decision").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}});69+ cfg.target.querySelectorAll("chat-decision,chat-cancel").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}});5670 this.version=cfg.text;5771 }5872 }
@@ -60,10 +74,9 @@
6074 connectedCallback() { if(this.form) return; super.connectedCallback(); }6175 async submit(form) {6276 const host=this.closest("chat-activity"),id=this.getAttribute("event"),author=this.getAttribute("pubkey"),actor=this.getAttribute("actor"),room=this.getAttribute("room");63- const signer=tiny.signer();64- if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account.");65- const fresh=document.createElement("template"); fresh.innerHTML=await host.read();66- const allowed=[...fresh.content.querySelectorAll("chat-decision")].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor && el.getAttribute("pubkey")===author);77+ await requireSigner(actor);78+ const current=await freshElement(host,"chat-decision",id,actor);79+ const allowed=current && current.getAttribute("pubkey")===author ? current : null;6780 if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); }6881 let unsigned;6982 if(this.hasAttribute("native")) {
@@ -96,5 +109,43 @@
96109 await host.refresh(true);97110 }98111 }112+ // ChatRequest publishes a kind 43001 job request for the room, and for113+ // the thread when the panel is scoped to one.114+ class ChatRequest extends tiny.ui.FormElement {115+ connectedCallback() { if(this.form) return; super.connectedCallback(); }116+ async submit(form) {117+ const host=this.closest("chat-activity"),actor=this.getAttribute("actor"),room=this.getAttribute("room"),root=this.getAttribute("root");118+ const agent=(form.elements.agent?.value || "").trim().toLowerCase();119+ const subject=(form.elements.subject?.value || "").trim(), content=(form.elements.content?.value || "").trim();120+ if(!isHex64(agent)) throw Error("Choose an agent or enter its 64-character public key.");121+ if(!content && !subject) throw Error("Write the task first.");122+ if(subject.length > 200) throw Error("The subject is too long.");123+ if(content.length > 8000) throw Error("The task is too long.");124+ await requireSigner(actor);125+ const tags=[["h",room],["p",agent]];126+ if(subject) tags.push(["subject",subject]);127+ if(root) { if(!isHex64(root)) throw Error("The thread address is missing."); tags.push(["e",root,"","root"]); }128+ this.report("Signing…"); await tiny.signing.publish({kind:43001,created_at:now(),tags,content});129+ form.reset?.(); this.report("Task sent.");130+ await host?.refresh(true);131+ }132+ }133+ // ChatCancel publishes a kind 43005 cancel for an open task the account134+ // requested, naming each key the request asked.135+ class ChatCancel extends tiny.ui.FormElement {136+ connectedCallback() { if(this.form) return; super.connectedCallback(); }137+ async submit() {138+ const host=this.closest("chat-activity"),id=this.getAttribute("event"),actor=this.getAttribute("actor"),room=this.getAttribute("room");139+ await requireSigner(actor);140+ const allowed=await freshElement(host,"chat-cancel",id,actor);141+ if(!allowed) { host.schedule(); throw Error("This task is no longer open."); }142+ const tags=[["e",id],["h",room]];143+ for(const assignee of (allowed.getAttribute("assignees") || "").split(/\s+/)) if(isHex64(assignee)) tags.push(["p",assignee]);144+ this.report("Signing…"); await tiny.signing.publish({kind:43005,created_at:now(),tags,content:""});145+ this.report("Task cancelled."); this.querySelectorAll("button").forEach(el=>el.disabled=true);146+ await host.refresh(true);147+ }148+ }99149 customElements.define("chat-activity",ChatActivity); customElements.define("chat-decision",ChatDecision);150+ customElements.define("chat-request",ChatRequest); customElements.define("chat-cancel",ChatCancel);100151 })();
M tinyclient/chat_activity_http_test.go +64 -2
@@ -2,6 +2,7 @@ package tinyclient
22 33 import (44 "context"5+ "encoding/json"56 "errors"67 "net/http"78 "net/http/httptest"
@@ -11,6 +12,8 @@ import (
1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"1213 )1314 15+var activityOpenTask = strings.Repeat("7", 64)16+1417 type activityHTTPBackend struct {1518 roomsBackend1619 seenActor, seenRoom, seenRoot string
@@ -23,7 +26,8 @@ func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, r
2326 return nil, errors.New("private details that must stay hidden")2427 }2528 job := map[string]any{"id": roomThread, "requester": roomOwner, "assignees": []any{roomAgent}, "subject": "Build the preview", "content": "Build it with the dark palette.", "state": "done", "status": "done", "created_at": 1, "result": map[string]any{"id": roomReply, "provider": roomAgent, "content": "Preview is up.", "artifacts": []any{map[string]any{"type": "r", "value": "https://preview.example/site"}, map[string]any{"type": "e", "value": roomReply}}}}26- return map[string]any{"jobs": []any{job}, "approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview", "type": "question", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}}}}, nil29+ open := map[string]any{"id": activityOpenTask, "requester": roomOwner, "assignees": []any{roomAgent, roomGuest}, "subject": "Transcribe the call", "state": "open", "status": "running", "created_at": 2, "progress": map[string]any{"id": strings.Repeat("8", 64), "provider": roomAgent, "content": "Halfway through"}}30+ return map[string]any{"jobs": []any{job, open}, "approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview", "type": "question", "interaction": "approval", "selection": "single", "options": []any{map[string]any{"id": "yes", "label": "Yes"}, map[string]any{"id": "no", "label": "No"}}}}}, nil2731 }2832 2933 func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
@@ -41,7 +45,8 @@ func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
4145 if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {4246 t.Fatalf("request context: %d %v %#v body=%s", res.Code, res.Header(), b, res.Body.String())4347 }44- for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals/` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`, `data-activity="job"`, `data-state="done"`, `<strong>Build the preview</strong>`, `data-status="done"`, `<h4>Result</h4>`, `href="https://preview.example/site"`, `href="/r/team/e/` + roomReply + `">Event `, `href="/r/team/e/` + roomThread + `">Open task</a>`} {48+ for _, want := range []string{`<chat-decision`, `type="radio"`, `value="yes"`, `interaction="approval"`, `selection="single"`, `<strong>Approval request</strong>`, `href="/r/team/approvals/` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`, `data-activity="job"`, `data-state="done"`, `<strong>Build the preview</strong>`, `data-status="done"`, `<h4>Result</h4>`, `href="https://preview.example/site"`, `href="/r/team/e/` + roomReply + `">Event `, `href="/r/team/e/` + roomThread + `">Open task</a>`,49+ `data-status="running">running</small>`, `<p data-progress>Halfway through</p>`, ` for <nostr-name pubkey="` + roomAgent + `"`, `<chat-cancel event="` + activityOpenTask + `" room="general" actor="` + roomOwner + `" assignees="` + roomAgent + ` ` + roomGuest + `"><button type="submit" data-danger>Cancel task</button></chat-cancel>`} {4550 if !strings.Contains(body, want) {4651 t.Errorf("missing %q in %s", want, body)4752 }
@@ -49,12 +54,18 @@ func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
4954 if strings.Contains(body, "class=") || strings.Contains(body, "<script>bad()") {5055 t.Fatal("unsafe markup")5156 }57+ if strings.Contains(body, "<chat-request") || strings.Contains(body, `id="chat-ask"`) {58+ t.Fatal("refresh response carried the request form")59+ }5260 actor = roomGuest5361 res = httptest.NewRecorder()5462 app.ServeHTTP(res, req)5563 if strings.Contains(res.Body.String(), "chat-decision") || strings.Contains(res.Body.String(), "Review the preview") {5664 t.Fatal("approval shown to unaddressed actor")5765 }66+ if strings.Contains(res.Body.String(), "<chat-cancel") || !strings.Contains(res.Body.String(), `data-event="`+activityOpenTask+`"`) {67+ t.Fatal("cancel offered to a viewer who did not request the task")68+ }5869 b.denied = true5970 res = httptest.NewRecorder()6071 app.ServeHTTP(res, req)
@@ -82,3 +93,54 @@ func TestChatActivityEndpointRejectsInvalidRoutesAndWrites(t *testing.T) {
8293 })8394 }8495 }96+97+func TestRoomPageRendersTheRequestFormAndCancelWithoutJavaScript(t *testing.T) {98+ b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}99+ actor := roomOwner100+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})101+ if err != nil {102+ t.Fatal(err)103+ }104+ body := roomsPage(t, app, "/rooms/general")105+ form := `<details id="chat-ask"><summary>Ask an agent</summary><chat-request room="general" actor="` + roomOwner + `"><label>Agent <select name="agent" required><option value="` + roomAgent + `">` + roomAgent[:12] + ` (` + roomOwner[:12] + `'s agent)</option></select></label>`106+ for _, want := range []string{form, `<input name="subject" maxlength="200"`, `<textarea name="content" required maxlength="8000"`, `<button type="submit" data-primary>Ask</button></chat-request><noscript><p>Publishing needs JavaScript and a connected signer.</p></noscript></details><div id="chat-activity-cards">`, `<chat-cancel event="` + activityOpenTask + `"`} {107+ if !strings.Contains(body, want) {108+ t.Errorf("room page missing %q", want)109+ }110+ }111+ if strings.Contains(body, `<input name="agent"`) {112+ t.Error("room with agent members rendered the free key input")113+ }114+ thread := roomsPage(t, app, "/rooms/general/thread/"+roomThread)115+ if !strings.Contains(thread, `<chat-request room="general" actor="`+roomOwner+`" root="`+roomThread+`">`) {116+ t.Error("thread page request form is not scoped to the thread")117+ }118+ actor = ""119+ guest := roomsPage(t, app, "/rooms/general")120+ if strings.Contains(guest, `id="chat-ask"`) || strings.Contains(guest, "<chat-request") || strings.Contains(guest, "<chat-cancel") {121+ t.Error("signed-out room page offered the form or cancel")122+ }123+}124+125+// A backend whose room read lists no agents falls back to a key input.126+type activityNoAgentsBackend struct{ activityHTTPBackend }127+128+func (b *activityNoAgentsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {129+ result, err := b.activityHTTPBackend.Query(ctx, method, params, actor)130+ if page, ok := result.(map[string]any); ok && method == "browseroom" {131+ page["members"] = []any{map[string]any{"pubkey": roomOwner, "role": "owner"}}132+ }133+ return result, err134+}135+136+func TestRoomPageRequestFormAcceptsAKeyWhenNoAgentIsListed(t *testing.T) {137+ b := &activityNoAgentsBackend{activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}}138+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return roomOwner, nil }})139+ if err != nil {140+ t.Fatal(err)141+ }142+ body := roomsPage(t, app, "/rooms/general")143+ if !strings.Contains(body, `<label>Agent <input name="agent" required pattern="[0-9a-f]{64}" maxlength="64"`) || strings.Contains(body, `<select name="agent"`) {144+ t.Errorf("key input missing: %s", body[strings.Index(body, "chat-ask"):][:400])145+ }146+}
M tinyclient/chat_activity_test.go +50 -0
@@ -1,8 +1,11 @@
11 package tinyclient22 33 import (4+ "context"45 "strings"56 "testing"7+8+ "github.com/FelineStateMachine/tinyrelay/internal/policy"69 )710 811 func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) {
@@ -125,3 +128,50 @@ func TestChatActivityJobCardsCarryResultsArtifactsAndErrors(t *testing.T) {
125128 t.Fatalf("queued card = %#v", card)126129 }127130 }131+132+func TestChatActivityOnlyTheRequesterMayCancelAnOpenTask(t *testing.T) {133+ actor, other := strings.Repeat("a", 64), strings.Repeat("b", 64)134+ jobs := map[string]any{"items": []any{135+ map[string]any{"id": strings.Repeat("1", 64), "requester": actor, "assignees": []any{other, "short", strings.Repeat("c", 64)}, "subject": "Mine, open", "created_at": 1},136+ map[string]any{"id": strings.Repeat("2", 64), "requester": actor, "assignees": []any{other}, "subject": "Mine, done", "state": "done", "status": "done", "created_at": 2},137+ map[string]any{"id": strings.Repeat("3", 64), "requester": other, "assignees": []any{actor}, "subject": "Theirs, running", "state": "open", "status": "running", "created_at": 3},138+ }}139+ items := chatActivityView(jobs, nil, actor, "/chat/activity", "general")140+ if len(items) != 3 {141+ t.Fatalf("got %d cards", len(items))142+ }143+ if card := items[0]; !card.CanCancel || len(card.Assignees) != 2 || card.Assignees[0] != other || card.Assignees[1] != strings.Repeat("c", 64) {144+ t.Fatalf("open own task = %#v", card)145+ }146+ if items[1].CanCancel || items[2].CanCancel {147+ t.Fatalf("done or foreign task cancellable: %#v %#v", items[1], items[2])148+ }149+ if anonymous := chatActivityView(jobs, nil, "", "/chat/activity", "general"); anonymous[0].CanCancel {150+ t.Fatal("signed-out viewer may cancel")151+ }152+}153+154+func TestChatActivityListsRoomAgentsForTheRequestForm(t *testing.T) {155+ b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}156+ app, err := New(b, Options{})157+ if err != nil {158+ t.Fatal(err)159+ }160+ page := app.chatActivity(context.Background(), roomOwner, "general", roomThread)161+ if !page.Ask || page.Root != roomThread || len(page.Agents) != 1 || page.Agents[0] != (chatActivityAgent{PubKey: roomAgent, Operator: roomOwner}) {162+ t.Fatalf("request form data = %#v", page)163+ }164+ if guest := app.chatActivity(context.Background(), "", "general", ""); guest.Ask || guest.Agents != nil {165+ t.Fatalf("signed-out viewer offered the form: %#v", guest)166+ }167+ if refresh := app.chatActivityCards(context.Background(), roomOwner, "general", ""); refresh.Ask || refresh.Agents != nil {168+ t.Fatalf("refresh carried form data: %#v", refresh)169+ }170+ plain, err := New(&roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}, Options{})171+ if err != nil {172+ t.Fatal(err)173+ }174+ if page := plain.chatActivity(context.Background(), roomOwner, "general", ""); page.Ask {175+ t.Fatalf("form offered without a task backend: %#v", page)176+ }177+}
M tinyclient/style.css +15 -0
@@ -641,6 +641,21 @@ chat-activity details>div pre{white-space:pre-wrap;overflow-wrap:anywhere;paddin
641641 chat-activity [data-status=processing],chat-activity [data-status=partial]{color:var(--bark)}642642 chat-activity [data-status=success]{color:var(--accent)}643643 chat-activity [data-status=error]{color:var(--alert)}644+chat-activity [data-status=accepted],chat-activity [data-status=running]{color:var(--bark)}645+chat-activity [data-status=done]{color:var(--accent)}646+chat-activity [data-status=failed],chat-activity [data-status=cancelled]{color:var(--alert)}647+chat-activity p[data-progress]{color:var(--bark)}648+chat-activity p[data-error]{color:var(--alert)}649+/* The request form folds above the cards; cancel sits under an open task. */650+#chat-ask{margin:10px 0;padding:8px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface)}651+#chat-ask>summary{font:13px/1.6 var(--sans);cursor:pointer}652+chat-request{display:block;padding:6px 0 4px}653+chat-request label{display:block;margin:6px 0;font:13px/1.5 var(--sans)}654+chat-request input,chat-request select,chat-request textarea{display:block;width:100%;min-width:0;max-width:100%;box-sizing:border-box;margin-top:4px}655+chat-request button{margin-top:6px;min-height:44px}656+chat-request output,chat-cancel output{display:block;font:12px var(--mono);margin-top:6px;color:var(--muted)}657+chat-request output[data-error],chat-cancel output[data-error]{color:var(--alert)}658+chat-cancel{display:block;margin-top:8px}chat-cancel button{min-height:44px}644659 chat-decision{display:block;margin-top:10px}chat-decision button{margin-right:8px;min-height:44px}645660 chat-decision fieldset{min-width:0;margin:0 0 12px;padding:8px;border:1px solid var(--line);border-radius:8px}646661 chat-decision legend{padding:0 4px;color:var(--muted)}