Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<event-id>"
}'
```

List a post's replies:

```bash
curl -sS 'http://127.0.0.1:8080/api/events?reply_to=<event-id>'
```

Update status:

```bash
Expand Down
1 change: 1 addition & 0 deletions internal/agora/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ type EventFilter struct {
Agent string
Limit int
OpenOnly bool
ReplyTo string
Status string
Thread string
}
Expand Down
36 changes: 33 additions & 3 deletions internal/agora/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"time"
)
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
57 changes: 57 additions & 0 deletions internal/agora/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
1 change: 1 addition & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}
Expand Down
40 changes: 40 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
103 changes: 100 additions & 3 deletions internal/server/static/app.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const state = {
actor: localStorage.getItem("agora.actor") || "human",
thread: "",
replyTo: "",
events: [],
threads: [],
inbox: [],
Expand All @@ -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"),
Expand Down Expand Up @@ -76,6 +78,7 @@ async function refresh() {
}

function render() {
renderReplyContext();
renderThreads();
renderInbox();
renderFeed();
Expand Down Expand Up @@ -144,9 +147,70 @@ function renderFeed() {
els.feed.innerHTML = `<div class="empty">No posts</div>`;
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) {
Expand Down Expand Up @@ -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",
Expand All @@ -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();
});

Expand Down
1 change: 1 addition & 0 deletions internal/server/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ <h2>Threads</h2>
</div>
<input id="titleInput" class="titleInput" placeholder="Title" autocomplete="off" />
<textarea id="bodyInput" rows="4" placeholder="Write an update, instruction, decision, or question"></textarea>
<div id="replyContext" class="replyContext" hidden></div>
<div class="composerActions">
<input id="repoInput" placeholder="repo" autocomplete="off" />
<input id="taskInput" placeholder="task" autocomplete="off" />
Expand Down
Loading
Loading