1package tinyclient23import (4 "bytes"5 "context"6 "encoding/json"7 "net/http"8 "net/url"9 "sort"10 "strings"11 "time"12)1314type ChatActivityReader interface {15 ReadChatActivity(context.Context, string, string, string) (any, error)16}1718type chatActivityItem struct {19 Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string20 CreatedAt, Expires int6421 Answer string22 CanDecide, Question, Encrypted bool23 Result *chatActivityResult24}25type chatActivityResult struct{ ID, Content string }26type chatActivityPage struct {27 Items []chatActivityItem28 Endpoint, Room, Actor, Error string29}3031func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {32 items := []chatActivityItem{}33 for _, raw := range collaborationSlice(jobs, "items") {34 item := valueMap(raw)35 id := plainString(item["id"])36 if !eventIDPattern.MatchString(id) {37 continue38 }39 ev := valueMap(item["event"])40 title := firstString(tagValues(ev, "subject"))41 if title == "" {42 title = strings.TrimSpace(plainString(item["content"]))43 }44 if title == "" {45 for _, raw := range collaborationSlice(item, "inputs") {46 input := valueMap(raw)47 if plainString(input["type"]) == "text" {48 title = plainString(input["data"])49 break50 }51 }52 }53 if title == "" {54 title = "Agent task"55 }56 title = truncateActivity(title, 100)57 row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: plainString(item["kind"]), State: plainString(item["state"]), Status: plainString(item["status"]), Title: title, Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"])}58 row.Encrypted, _ = item["encrypted"].(bool)59 if row.Encrypted {60 row.Title = "Encrypted task"61 row.Content = "Open the task in its originating client."62 }63 if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted {64 row.Author = plainString(feedback["provider"])65 row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"]))66 }67 if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {68 row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}69 row.Status = "success"70 if row.Encrypted {71 row.Result.Content = ""72 }73 }74 if row.Status == "" {75 row.Status = "queued"76 }77 items = append(items, row)78 }79 for _, raw := range collaborationSlice(approvals, "items") {80 item := valueMap(raw)81 id := plainString(item["id"])82 if !eventIDPattern.MatchString(id) || actor == "" {83 continue84 }85 asker := plainString(item["asker"])86 can := approvalActorMayDecide(item, actor)87 if !can && asker != actor {88 continue89 }90 title := strings.TrimSpace(plainString(item["subject"]))91 if title == "" {92 title = plainString(item["content"])93 }94 state := plainString(item["state"])95 expires := unixSeconds(item["expires"])96 if state == "open" && expires > 0 && expires <= time.Now().Unix() {97 state = "expired"98 }99 answer := valueMap(item["answer"])100 decision := plainString(answer["decision"])101 items = append(items, chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision})102 }103 sort.SliceStable(items, func(i, j int) bool {104 if items[i].CreatedAt == items[j].CreatedAt {105 return items[i].ID < items[j].ID106 }107 return items[i].CreatedAt < items[j].CreatedAt108 })109 return items110}111func truncateActivity(value string, max int) string {112 runes := []rune(value)113 if len(runes) > max {114 return string(runes[:max]) + "…"115 }116 return value117}118func approvalActorMayDecide(item map[string]any, actor string) bool {119 if actor == "" || plainString(item["asker"]) == actor {120 return false121 }122 for _, key := range stringValues(item["asked"]) {123 if key == actor {124 return true125 }126 }127 return false128}129func stringValues(value any) []string {130 raw, err := json.Marshal(value)131 if err != nil {132 return nil133 }134 var values []string135 if json.Unmarshal(raw, &values) != nil {136 return nil137 }138 return values139}140141func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {142 endpoint := "/chat/activity?room=" + url.QueryEscape(room)143 if root != "" {144 endpoint += "&root=" + url.QueryEscape(root)145 }146 page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}147 reader, ok := a.backend.(ChatActivityReader)148 if !ok {149 return page150 }151 result, err := reader.ReadChatActivity(ctx, actor, room, root)152 if err != nil {153 page.Error = "Agent activity is unavailable."154 return page155 }156 data := valueMap(result)157 page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)158 return page159}160func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {161 w.Header().Set("Cache-Control", "private, no-store")162 actor, err := a.resolveActor(r)163 if err != nil {164 http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)165 return166 }167 room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")168 if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {169 http.Error(w, "Invalid room or thread.", http.StatusBadRequest)170 return171 }172 page := a.chatActivity(r.Context(), actor, room, root)173 if page.Error != "" {174 http.Error(w, page.Error, http.StatusForbidden)175 return176 }177 w.Header().Set("Content-Type", "text/html; charset=utf-8")178 var body bytes.Buffer179 if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {180 http.Error(w, "Activity rendering failed.", 500)181 return182 }183 _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))184}