From eb05954ba2ee978b511bb7502b088a8feffff4cf Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Fri, 26 Jun 2026 07:24:52 +0900 Subject: [PATCH] Add post subthreads --- README.md | 19 +++++ internal/agora/event.go | 1 + internal/agora/store.go | 36 ++++++++- internal/agora/store_test.go | 57 +++++++++++++ internal/server/server.go | 1 + internal/server/server_test.go | 40 +++++++++ internal/server/static/app.js | 103 +++++++++++++++++++++++- internal/server/static/index.html | 1 + internal/server/static/styles.css | 44 ++++++++++ skills/agora-reporting/scripts/agora.py | 14 +++- 10 files changed, 308 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3b6c061..00529da 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,25 @@ List the timeline: curl -sS 'http://127.0.0.1:8080/api/events?limit=50' ``` +Post a reply under another post: + +```bash +curl -sS -X POST http://127.0.0.1:8080/api/events \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "comment", + "actor": "codex-one", + "body": "I will continue in this subthread.", + "reply_to": "" + }' +``` + +List a post's replies: + +```bash +curl -sS 'http://127.0.0.1:8080/api/events?reply_to=' +``` + Update status: ```bash diff --git a/internal/agora/event.go b/internal/agora/event.go index 1ff2a99..46d2d40 100644 --- a/internal/agora/event.go +++ b/internal/agora/event.go @@ -129,6 +129,7 @@ type EventFilter struct { Agent string Limit int OpenOnly bool + ReplyTo string Status string Thread string } diff --git a/internal/agora/store.go b/internal/agora/store.go index 0fd5f9a..28b037d 100644 --- a/internal/agora/store.go +++ b/internal/agora/store.go @@ -8,6 +8,7 @@ import ( "path/filepath" "slices" "sort" + "strings" "sync" "time" ) @@ -45,14 +46,17 @@ func NewStore(path string) (*Store, error) { } func (s *Store) AppendEvent(req CreateEventRequest) (Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.prepareReply(&req); err != nil { + return Event{}, err + } event, err := newEvent(req, time.Now()) if err != nil { return Event{}, err } - s.mu.Lock() - defer s.mu.Unlock() - if err := s.appendRecord(logRecord{Kind: recordEvent, Event: &event}); err != nil { return Event{}, err } @@ -61,6 +65,29 @@ func (s *Store) AppendEvent(req CreateEventRequest) (Event, error) { return event, nil } +func (s *Store) prepareReply(req *CreateEventRequest) error { + replyTo := strings.TrimSpace(req.ReplyTo) + if replyTo == "" { + return nil + } + req.ReplyTo = replyTo + + parentIndex, ok := s.byID[replyTo] + if !ok { + return fmt.Errorf("reply_to event %q not found", replyTo) + } + parentThread := s.events[parentIndex].Thread + thread := strings.TrimSpace(req.Thread) + if thread == "" { + req.Thread = parentThread + return nil + } + if thread != parentThread { + return fmt.Errorf("reply thread %q does not match parent thread %q", thread, parentThread) + } + return nil +} + func (s *Store) UpdateStatus(id string, req StatusUpdateRequest) (Event, error) { status, err := validateStatus(req.Status) if err != nil { @@ -225,6 +252,9 @@ func eventMatches(event Event, filter EventFilter) bool { if filter.Thread != "" && event.Thread != filter.Thread { return false } + if filter.ReplyTo != "" && event.ReplyTo != filter.ReplyTo { + return false + } if filter.Status != "" && event.Status != filter.Status { return false } diff --git a/internal/agora/store_test.go b/internal/agora/store_test.go index 1d5a6a9..e929bb8 100644 --- a/internal/agora/store_test.go +++ b/internal/agora/store_test.go @@ -84,3 +84,60 @@ func TestInboxMatchesTargetsAndMentions(t *testing.T) { t.Fatalf("first inbox type = %q, want %q", inbox[0].Type, TypeQuestion) } } + +func TestStoreCreatesPostReplies(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "events.jsonl")) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + + parent, err := store.AppendEvent(CreateEventRequest{ + Type: TypeSummary, + Actor: "human", + Thread: "release", + Title: "Release plan", + }) + if err != nil { + t.Fatalf("AppendEvent() parent error = %v", err) + } + + reply, err := store.AppendEvent(CreateEventRequest{ + Type: TypeComment, + Actor: "builder", + Body: "I will run the checks.", + ReplyTo: parent.ID, + }) + if err != nil { + t.Fatalf("AppendEvent() reply error = %v", err) + } + if reply.ReplyTo != parent.ID { + t.Fatalf("reply ReplyTo = %q, want %q", reply.ReplyTo, parent.ID) + } + if reply.Thread != parent.Thread { + t.Fatalf("reply Thread = %q, want %q", reply.Thread, parent.Thread) + } + + replies := store.ListEvents(EventFilter{ReplyTo: parent.ID}) + if len(replies) != 1 || replies[0].ID != reply.ID { + t.Fatalf("replies = %#v, want reply %s", replies, reply.ID) + } + + if _, err := store.AppendEvent(CreateEventRequest{ + Type: TypeComment, + Actor: "builder", + Title: "Wrong thread", + Thread: "other", + ReplyTo: parent.ID, + }); err == nil { + t.Fatal("AppendEvent() mismatched reply thread error = nil, want error") + } + + if _, err := store.AppendEvent(CreateEventRequest{ + Type: TypeComment, + Actor: "builder", + Title: "Missing parent", + ReplyTo: "missing", + }); err == nil { + t.Fatal("AppendEvent() missing reply parent error = nil, want error") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8ce9a7e..ec7a725 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -182,6 +182,7 @@ func filterFromQuery(r *http.Request) agora.EventFilter { Agent: query.Get("agent"), Limit: limit, OpenOnly: parseBool(query.Get("open")), + ReplyTo: query.Get("reply_to"), Status: query.Get("status"), Thread: query.Get("thread"), } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 81661cd..9568531 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -53,4 +53,44 @@ func TestEventAPIAndInbox(t *testing.T) { if len(inbox) != 1 || inbox[0].ID != event.ID { t.Fatalf("inbox = %#v, want event %s", inbox, event.ID) } + + replyBody, err := json.Marshal(map[string]string{ + "type": "comment", + "actor": "codex-one", + "body": "I will continue there.", + "reply_to": event.ID, + }) + if err != nil { + t.Fatalf("encode reply body: %v", err) + } + req = httptest.NewRequest(http.MethodPost, "/api/events", bytes.NewReader(replyBody)) + rec = httptest.NewRecorder() + app.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("POST reply status = %d, body = %s", rec.Code, rec.Body.String()) + } + var reply agora.Event + if err := json.Unmarshal(rec.Body.Bytes(), &reply); err != nil { + t.Fatalf("decode reply: %v", err) + } + if reply.ReplyTo != event.ID { + t.Fatalf("reply ReplyTo = %q, want %q", reply.ReplyTo, event.ID) + } + if reply.Thread != event.Thread { + t.Fatalf("reply Thread = %q, want %q", reply.Thread, event.Thread) + } + + req = httptest.NewRequest(http.MethodGet, "/api/events?reply_to="+event.ID, nil) + rec = httptest.NewRecorder() + app.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET replies status = %d, body = %s", rec.Code, rec.Body.String()) + } + var replies []agora.Event + if err := json.Unmarshal(rec.Body.Bytes(), &replies); err != nil { + t.Fatalf("decode replies: %v", err) + } + if len(replies) != 1 || replies[0].ID != reply.ID { + t.Fatalf("replies = %#v, want reply %s", replies, reply.ID) + } } diff --git a/internal/server/static/app.js b/internal/server/static/app.js index 1301845..8aeb175 100644 --- a/internal/server/static/app.js +++ b/internal/server/static/app.js @@ -1,6 +1,7 @@ const state = { actor: localStorage.getItem("agora.actor") || "human", thread: "", + replyTo: "", events: [], threads: [], inbox: [], @@ -15,6 +16,7 @@ const els = { inboxList: document.querySelector("#inboxList"), refresh: document.querySelector("#refreshButton"), repo: document.querySelector("#repoInput"), + replyContext: document.querySelector("#replyContext"), severity: document.querySelector("#severityInput"), targets: document.querySelector("#targetsInput"), task: document.querySelector("#taskInput"), @@ -76,6 +78,7 @@ async function refresh() { } function render() { + renderReplyContext(); renderThreads(); renderInbox(); renderFeed(); @@ -144,9 +147,70 @@ function renderFeed() { els.feed.innerHTML = `
No posts
`; return; } - for (const event of [...state.events].reverse()) { - els.feed.appendChild(renderEvent(event)); + const { roots, childrenByParent } = buildEventTree(state.events); + const latestActivityByID = new Map(); + const sortedRoots = [...roots].sort((a, b) => { + const activityDiff = + latestActivityTime(b, childrenByParent, latestActivityByID) - + latestActivityTime(a, childrenByParent, latestActivityByID); + if (activityDiff !== 0) return activityDiff; + return eventTimestamp(b) - eventTimestamp(a); + }); + for (const event of sortedRoots) { + els.feed.appendChild(renderEventThread(event, childrenByParent)); + } +} + +function eventTimestamp(event) { + const timestamp = Date.parse(event.created_at); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function latestActivityTime(event, childrenByParent, latestActivityByID) { + if (latestActivityByID.has(event.id)) { + return latestActivityByID.get(event.id); + } + + let latest = eventTimestamp(event); + for (const reply of childrenByParent.get(event.id) || []) { + latest = Math.max(latest, latestActivityTime(reply, childrenByParent, latestActivityByID)); + } + latestActivityByID.set(event.id, latest); + return latest; +} + +function buildEventTree(events) { + const eventsByID = new Map(events.map((event) => [event.id, event])); + const childrenByParent = new Map(); + const roots = []; + for (const event of events) { + if (event.reply_to && eventsByID.has(event.reply_to)) { + if (!childrenByParent.has(event.reply_to)) { + childrenByParent.set(event.reply_to, []); + } + childrenByParent.get(event.reply_to).push(event); + continue; + } + roots.push(event); + } + return { roots, childrenByParent }; +} + +function renderEventThread(event, childrenByParent) { + const group = document.createElement("div"); + group.className = "eventThread"; + group.appendChild(renderEvent(event)); + + const replies = childrenByParent.get(event.id) || []; + if (replies.length > 0) { + const replyList = document.createElement("div"); + replyList.className = "eventReplies"; + for (const reply of replies) { + replyList.appendChild(renderEventThread(reply, childrenByParent)); + } + group.appendChild(replyList); } + return group; } function renderEvent(event) { @@ -195,13 +259,43 @@ function addChip(parent, text, kind = "") { } function replyTo(event) { + state.replyTo = event.id; els.type.value = "comment"; els.thread.value = event.thread; els.targets.value = `@${event.actor}`; els.title.value = `Re: ${eventTitle(event)}`; + renderReplyContext(); els.body.focus(); } +function renderReplyContext() { + els.replyContext.replaceChildren(); + if (!state.replyTo) { + els.replyContext.hidden = true; + return; + } + + const parent = state.events.find((event) => event.id === state.replyTo); + const label = document.createElement("span"); + label.textContent = parent + ? `Replying to @${parent.actor}: ${eventTitle(parent)}` + : `Replying to ${state.replyTo}`; + + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "secondary"; + cancel.textContent = "Cancel"; + cancel.addEventListener("click", clearReply); + + els.replyContext.hidden = false; + els.replyContext.append(label, cancel); +} + +function clearReply() { + state.replyTo = ""; + renderReplyContext(); +} + async function setStatus(event, status) { await api(`/api/events/${encodeURIComponent(event.id)}/status`, { method: "POST", @@ -214,22 +308,25 @@ els.composer.addEventListener("submit", async (event) => { event.preventDefault(); state.actor = els.actor.value.trim() || "human"; localStorage.setItem("agora.actor", state.actor); + const replyParent = state.replyTo ? state.events.find((item) => item.id === state.replyTo) : null; await api("/api/events", { method: "POST", body: JSON.stringify({ actor: state.actor, body: els.body.value, repo: els.repo.value, + reply_to: state.replyTo, severity: els.severity.value, targets: splitTargets(els.targets.value), task: els.task.value, - thread: els.thread.value, + thread: replyParent ? replyParent.thread : els.thread.value, title: els.title.value, type: els.type.value, }), }); els.title.value = ""; els.body.value = ""; + state.replyTo = ""; await refresh(); }); diff --git a/internal/server/static/index.html b/internal/server/static/index.html index c087e48..46116ed 100644 --- a/internal/server/static/index.html +++ b/internal/server/static/index.html @@ -76,6 +76,7 @@

Threads

+
diff --git a/internal/server/static/styles.css b/internal/server/static/styles.css index 0fc4513..16b442f 100644 --- a/internal/server/static/styles.css +++ b/internal/server/static/styles.css @@ -33,6 +33,10 @@ textarea { font: inherit; } +[hidden] { + display: none !important; +} + button { border: 1px solid var(--accent); border-radius: 6px; @@ -249,6 +253,23 @@ h3 { gap: 12px; } +.replyContext { + align-items: center; + border: 1px solid #cfe5e1; + border-radius: 6px; + background: #f2fbf9; + color: var(--muted); + display: flex; + gap: 10px; + justify-content: space-between; + padding: 8px 10px; +} + +.replyContext span { + min-width: 0; + overflow-wrap: anywhere; +} + .feedHeader { margin-top: 4px; } @@ -262,6 +283,19 @@ h3 { gap: 12px; } +.eventThread { + display: grid; + gap: 10px; +} + +.eventReplies { + border-left: 2px solid var(--line); + display: grid; + gap: 10px; + margin-left: 22px; + padding-left: 14px; +} + .event { display: grid; gap: 10px; @@ -348,4 +382,14 @@ h3 { .composerActions { grid-template-columns: 1fr; } + + .eventReplies { + margin-left: 8px; + padding-left: 10px; + } + + .replyContext { + align-items: flex-start; + flex-direction: column; + } } diff --git a/skills/agora-reporting/scripts/agora.py b/skills/agora-reporting/scripts/agora.py index f6953d4..dc42715 100755 --- a/skills/agora-reporting/scripts/agora.py +++ b/skills/agora-reporting/scripts/agora.py @@ -43,12 +43,13 @@ def main() -> int: post.add_argument("--type", required=True, choices=sorted(EVENT_TYPES)) post.add_argument("--title", required=True) post.add_argument("--body", default="") - post.add_argument("--thread", default=os.environ.get("AGORA_THREAD", "general")) + post.add_argument("--thread") post.add_argument("--actor", default=default_actor()) post.add_argument("--target", action="append", default=[]) post.add_argument("--repo", default=default_repo()) post.add_argument("--task", default=os.environ.get("AGORA_TASK", "")) post.add_argument("--severity", default="") + post.add_argument("--reply-to", default="") post.add_argument("--link", action="append", default=[], metavar="NAME=URL") post.set_defaults(func=cmd_post) @@ -76,13 +77,14 @@ def cmd_post(args: argparse.Namespace) -> int: payload = { "type": args.type, "actor": args.actor, - "thread": args.thread, + "thread": post_thread(args), "title": args.title, "body": args.body, "targets": split_targets(args.target), "repo": args.repo, "task": args.task, "severity": args.severity, + "reply_to": args.reply_to, "links": parse_links(args.link), } event = request_json("POST", "/api/events", compact(payload)) @@ -90,6 +92,14 @@ def cmd_post(args: argparse.Namespace) -> int: return 0 +def post_thread(args: argparse.Namespace) -> str: + if args.thread is not None: + return args.thread + if args.reply_to: + return "" + return os.environ.get("AGORA_THREAD", "general") + + def cmd_inbox(args: argparse.Namespace) -> int: query = urllib.parse.urlencode({"limit": str(args.limit)}) events = request_json("GET", f"/api/agents/{urllib.parse.quote(args.agent)}/inbox?{query}")