8000 feat(timeline): say who gave each reaction, on hover by adeelahmad · Pull Request #651 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
2 changes: 2 additions & 0 deletions changelog.d/timeline-who-reacted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Hovering a reaction on the timeline says who gave it ("You, Sam and 3 others"), and the API, MCP and the page
carry up to five names of the others who gave each one.
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Everything that happens is kept as W3C Activity Streams activities, read by agents through the API, MCP and the assistant | [test_events.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_events.py) `test_activities_have_one_shape`; [test_activities.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_activities.py) `test_the_timeline_lists_new_assets_newest_first`, `test_agents_read_the_timeline_too`; [test_news_feed_source.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_news_feed_source.py) `test_a_news_feed_as_a_source` |
| Ask the assistant about a timeline item from the item (its button, or `a` on its line), in the chat beside the page, with the item's id, line and fields as what the question is about | [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `asking about an item hands the page chat its id, line and fields, by button or the a key`; [page-chat.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/page-chat.test.tsx) `opens with what a page's own Ask about this hands it, and sends it as the highlighted part` |
| The Timeline page and Latest on Home, marking what you've seen | [test_timeline_marks.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_marks.py) `test_marks_are_set_cleared_and_filter_the_timeline`; [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `Home's Latest card lists what you haven't reviewed` |
| Reactions and comments on timeline activities, shared by everyone who sees them, through the API, MCP and the page | [test_timeline_social.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_social.py) `test_reactions_are_shared_counted_and_taken_back`, `test_comments_on_an_activity_are_a_thread_everyone_who_sees_it_joins`, `test_comments_on_a_logged_activity_follow_its_audience`, `test_agents_react_and_comment_too`; [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `reactions and comments: a line sums its items', a 👍 goes to the server, and Enter sends a comment` |
| Reactions and comments on timeline activities, shared by everyone who sees them, each reaction saying who gave it, through the API, MCP and the page | [test_timeline_social.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_social.py) `test_reactions_are_shared_counted_and_taken_back`, `test_comments_on_an_activity_are_a_thread_everyone_who_sees_it_joins`, `test_comments_on_a_logged_activity_follow_its_audience`, `test_agents_react_and_comment_too`, `test_a_reaction_names_who_else_gave_it_earliest_first_up_to_five`; [timeline-social-model.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-social-model.test.ts) `a reaction's hover says who gave it: you first, then up to five names, then how many more`; [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `reactions and comments: a line sums its items', a 👍 goes to the server, and Enter sends a comment` |
| Tagging @lens in a comment on the timeline or an asset gets the agent's answer in the thread, as the person who tagged it, with every change waiting for their approval, off with `ai.answer_comments` | [test_comment_agent.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_comment_agent.py) `test_the_agent_answers_a_tagged_comment_in_the_thread`, `test_without_a_model_or_within_a_used_up_budget_the_reply_says_so`; [timeline-social-model.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-social-model.test.ts) `the agent's reply links its addresses` |
| Automations act on the stream, on a schedule, at a time or when their own webhook is called | [test_automation_runs.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_automation_runs.py) `test_each_new_activity_runs_each_automation_it_matches_once`, `test_a_schedule_runs_its_automation_when_its_timer_fires`, `test_a_call_to_a_webhook_runs_its_automation`; [test_timer_triggers.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_timer_triggers.py) `test_an_at_trigger_fires_once_then_reads_done` |
| They test conditions, then tell you, ask the model or the agent, use a tool, call a web address with sealed credentials, run Lens work, or change assets and notes | [test_automation_runs.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_automation_runs.py) `test_the_if_limits_and_dry_runs`; [test_actions_messages.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_actions_messages.py) `test_notify_tells_me_in_my_chat_app`; [test_actions_ai.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_actions_ai.py) `test_ask_model_answers_in_text_or_as_the_fields_a_schema_names`, `test_ask_agent_works_on_what_happened_and_saves_the_conversation`, `test_call_tool_reads_as_the_person_and_changes_only_by_asking_them`; [test_actions_web.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_actions_web.py) `test_credentials_in_headers_are_sealed_to_the_automation_and_the_host_and_never_shown`; [test_actions_run.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_actions_run.py) `test_run_workflow_queues_it_on_the_asset_as_the_automation`, `test_run_pipeline_processes_the_asset_again`; [test_actions_lens.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_actions_lens.py) `test_set_field_is_kept_in_the_assets_history_by_the_automation`, `test_write_note_writes_a_page_adds_to_a_things_page_or_notes_an_asset` |
Expand Down
7 changes: 4 additions & 3 deletions docs/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ them, and each one is an activity of its own, so an automation (or an agent) can

- **Reactions** come from a palette, 👍 ❤️ 🎉 👀 😂 😮 😢 🙏. An opened item shows its reactions as chips with how
many gave each (yours are highlighted; a click on one of yours takes it back) and a **React** button with the
palette. A closed line sums its items' reactions, and a line of several shows them with "3 comments". Every new
palette. Hovering a chip says who gave it: "You, Sam and 3 others", you first, then up to five others by name,
earliest first. A closed line sums its items' reactions, and a line of several shows them with "3 comments". Every new
reaction is a `reaction.added` activity (noisy, so the timeline leaves it out unless asked for by kind), by the
person, with the activity it is on as its object.
- **Comments** open from the comment count on an opened item (or **Comment** when there are none): the threads with
Expand All @@ -103,7 +104,7 @@ them, and each one is an activity of its own, so an automation (or an agent) can
- Comments and reactions on an activity meant for one person (`lens.audience` owner) stay with that person, as the
activity does.
- `POST /api/v1/activities/{id}/react` with `{"emoji": "👍"}` (and `"on": false` to take it back) answers the
reactions on it now, `[{emoji, count, mine}]`; `GET`, `POST`, `PATCH` and `DELETE /api/v1/activities/{id}/comments`
reactions on it now, `[{emoji, count, mine, who}]` (`who`: up to five of the others who gave it, by name); `GET`, `POST`, `PATCH` and `DELETE /api/v1/activities/{id}/comments`
are the thread, as [comments on a resource](api.md#comments) are. `GET /api/v1/activities` carries everyone's
reactions on the page in `reactions` and each item's comment count in `comments`, by id.

Expand Down Expand Up @@ -257,6 +258,6 @@ per source page; they go when the person is removed. Nothing of this costs a mod
- Saved filter views.
- Marks on activities whose rows are gone (an event log entry past `events.keep_days`, a deleted asset) stay until
the person is removed; a tidy could drop them. Reactions and comments on such activities likewise.
- Who gave each reaction, on hover; editing and resolving comments from the timeline (the API has both).
- Editing and resolving comments from the timeline (the API has both).
- The agent's reply in a thread is plain text with links; the answer's excerpts could open at their moment, as in
chat. Tagging a namespace's own assistant by its name, with its memory.
3 changes: 2 additions & 1 deletion fastapi_backend/app/api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3897,7 +3897,8 @@ def mute_activities(
"React to an activity",
"Put an emoji on an activity of the timeline, as on a post in a feed, for the person you act for: one of 👍 ❤️ 🎉 👀 "
"😂 😮 😢 🙏; on false takes theirs back. Everyone who sees the activity sees the reactions, and a new one is a "
"reaction.added activity automations can act on. The answer is the reactions on it now (emoji, count, mine). "
"reaction.added activity automations can act on. The answer is the reactions on it now (emoji, count, mine, and "
"who else gave it). "
"Needs a token with the write scope.",
Arg("id", "string", "the activity's id, like create-recording-7", required=True, max_length=120),
Arg("emoji", "string", "the emoji, from the palette", required=True, enum=event_reactions.EMOJIS),
Expand Down
40 changes: 30 additions & 10 deletions fastapi_backend/app/domain/event_reactions.py
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@

import hashlib

from . import events, store
from . import events, events_content, store

R = store.R
EMOJIS = ("👍", "❤️", "🎉", "👀", "😂", "😮", "😢", "🙏")
WHO = 5 # how many of the others who gave a reaction are named on it
LABELS = {
"👍": "thumbs up",
"❤️": "heart",
Expand Down Expand Up @@ -84,33 +85,52 @@ def check(emoji):
return emoji


def _tally(rows, account):
"""{activity: [{emoji, count, mine}]} from rows of (activity, account, emojis), each list in the palette's order."""
def _tally(db, rows, account):
"""{activity: [{emoji, count, mine, who}]} from rows of (activity, account, emojis, at), each list in the palette's
order; `who` names up to WHO of the others who gave it, earliest first (then by account, for the same moment);
`mine` says the person asking did."""
counts: dict[str, dict[str, int]] = {}
mine: dict[str, set[str]] = {}
for r in rows:
gave: dict[str, dict[str, list]] = {}
for r in sorted(rows, key=lambda r: (str(r.get("at") or ""), r["account"] if isinstance(r.get("account"), int) else 0)):
for e in r.get("emojis") or []:
if e not in EMOJIS:
continue
counts.setdefault(r["activity"], {})[e] = counts.get(r["activity"], {}).get(e, 0) + 1
if account is not None and r.get("account") == account:
mine.setdefault(r["activity"], set()).add(e)
return {aid: [{"emoji": e, "count": n[e], "mine": e in mine.get(aid, set())} for e in EMOJIS if e in n] for aid, n in counts.items()}
else:
gave.setdefault(r["activity"], {}).setdefault(e, []).append(r.get("account"))
names = events_content.actors(db, [(a, None) for g in gave.values() for ids in g.values() for a in ids[:WHO]])
return {
aid: [
{
"emoji": e,
"count": n[e],
"mine": e in mine.get(aid, set()),
"who": [names[(a, None)]["name"] for a in gave.get(aid, {}).get(e, [])[:WHO]],
}
for e in EMOJIS
if e in n
]
for aid, n in counts.items()
}


def of(db, account, ids):
"""{activity id: [{emoji, count, mine}]} of everyone's reactions on these activities, in one query; an activity
with none isn't in it. `mine`: the person asking gave that one."""
"""{activity id: [{emoji, count, mine, who}]} of everyone's reactions on these activities, in one query (and one
for the names); an activity with none isn't in it. `mine`: the person asking gave that one; `who`: up to WHO
names of the others who did, earliest first."""
ids = sorted({i for i in ids if isinstance(i, str)})
if not ids:
return {}
rows = db.rows("SELECT activity, account, emojis FROM event_reaction WHERE activity IN $ids", ids=ids)
return _tally(rows, account)
rows = db.rows("SELECT activity, account, emojis, at FROM event_reaction WHERE activity IN $ids", ids=ids)
return _tally(db, rows, account)


def react(db, account, activity, emoji, on=True):
"""Give the emoji to the activity (or take it back with `on` false), for `account`; the reactions on it after,
[{emoji, count, mine}]. Whether anything changed is the second value, so the caller logs it only then."""
[{emoji, count, mine, who}]. Whether anything changed is the second value, so the caller logs it only then."""
emoji = check(emoji)
row = db.one("SELECT emojis FROM $r", r=_ref(account, activity))
have = [e for e in (row or {}).get("emojis") or [] if e in EMOJIS]
Expand Down
3 changes: 2 additions & 1 deletion fastapi_backend/app/schemas/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ class ActivityMark(ResponseModel):


class Reaction(ResponseModel):
"""One emoji on an activity: how many people gave it, and whether you did."""
"""One emoji on an activity: how many people gave it, whether you did, and who else did."""

emoji: str
count: int
mine: bool = False
who: list[str] = Field(default_factory=list, description="up to five of the others who gave it, by name, earliest first")


class ActivityPage(ResponseModel):
Expand Down
32 changes: 26 additions & 6 deletions fastapi_backend/tests/api/test_timeline_social.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,29 @@ def test_reactions_are_shared_counted_and_taken_back(client, db, cfg, folder):
assert client.get(URL, headers=hr).json()["reactions"] == {}
# the admin and the viewer both 👍 the first episode; the viewer ❤️s it too
got = client.post(f"{URL}/{pods1}/react", json={"emoji": "👍"}, headers=hr).json()
assert got == {"reactions": [{"emoji": "👍", "count": 1, "mine": True}]}
assert got == {"reactions": [{"emoji": "👍", "count": 1, "mine": True, "who": []}]}
client.post(f"{URL}/{pods1}/react", json={"emoji": "👍"}, headers=hv)
got = client.post(f"{URL}/{pods1}/react", json={"emoji": "❤️"}, headers=hv).json()["reactions"]
assert got == [{"emoji": "👍", "count": 2, "mine": True}, {"emoji": "❤️", "count": 1, "mine": True}]
# everyone sees the same counts on the page, each with their own `mine`
assert got == [
{"emoji": "👍", "count": 2, "mine": True, "who": ["root"]},
{"emoji": "❤️", "count": 1, "mine": True, "who": []},
]
# everyone sees the same counts on the page, each with their own `mine`, and who else gave each
page = client.get(URL, params=ASSETS, headers=hr).json()
assert list(page["reactions"]) == [pods1]
assert page["reactions"][pods1] == [{"emoji": "👍", "count": 2, "mine": True}, {"emoji": "❤️", "count": 1, "mine": False}]
assert page["reactions"][pods1] == [
{"emoji": "👍", "count": 2, "mine": True, "who": ["vi"]},
{"emoji": "❤️", "count": 1, "mine": False, "who": ["vi"]},
]
assert client.get(URL, params=ASSETS, headers=hv).json()["reactions"][pods1][1]["mine"] is True
# giving one again changes nothing; taking it back drops it, and the row goes when nothing is left
assert client.post(f"{URL}/{pods1}/react", json={"emoji": "👍"}, headers=hr).json()["reactions"][0]["count"] == 2
assert db.one("SELECT count() AS n FROM event_reaction GROUP ALL")["n"] == 2
got = client.post(f"{URL}/{pods1}/react", json={"emoji": "👍", "on": False}, headers=hr).json()["reactions"]
assert got == [{"emoji": "👍", "count": 1, "mine": False}, {"emoji": "❤️", "count": 1, "mine": False}]
assert got == [
{"emoji": "👍", "count": 1, "mine": False, "who": ["vi"]},
{"emoji": "❤️", "count": 1, "mine": False, "who": ["vi"]},
]
assert db.values("SELECT VALUE account FROM event_reaction") == [env["vi"]]
# each new reaction is a reaction.added activity on the stream (noisy: asked for by kind), by the person
liked = client.get(URL, params={"kinds": "reaction.added"}, headers=hr).json()["items"]
Expand Down Expand Up @@ -159,7 +168,7 @@ def test_agents_react_and_comment_too(client, db, cfg, folder):
pods1, pods2, call = env["ids"]
hv = env["hv"]
got = tool(client, hv, "react_to_activity", id=pods1, emoji="🎉")
assert got == {"reactions": [{"emoji": "🎉", "count": 1, "mine": True}]}
assert got == {"reactions": [{"emoji": "🎉", "count": 1, "mine": True, "who": []}]}
assert tool(client, hv, "react_to_activity", id=pods1, emoji="🎉", on=False) == {"reactions": []}
assert "not found" in tool_error(client, hv, "react_to_activity", id=call, emoji="🎉")
c = tool(client, hv, "comment_on_activity", id=pods1, text="Listen to this one")
Expand All @@ -171,3 +180,14 @@ def test_agents_react_and_comment_too(client, db, cfg, folder):
assert "text is required" in tool_error(client, hv, "comment_on_activity", id=pods1, text=" ")
assert tool(client, hv, "timeline", kinds=["asset.added"])["comments"] == {pods1: 2}
assert event_reactions.check("👍") == "👍"


def test_a_reaction_names_who_else_gave_it_earliest_first_up_to_five(db, cfg, folder):
[first, *_] = [f"create-recording-{i}" for i in seed(db, cfg, folder)]
people = [make_user(db, f"p{n}@x.io", f"person password {n}") for n in range(7)]
for p in people:
event_reactions.react(db, p, first, "🎉")
[mine] = event_reactions.of(db, people[0], [first])[first]
assert mine == {"emoji": "🎉", "count": 7, "mine": True, "who": ["p1", "p2", "p3", "p4", "p5"]}
[theirs] = event_reactions.of(db, None, [first])[first]
assert theirs["mine"] is False and theirs["who"] == ["p0", "p1", "p2", "p3", "p4"]
22 changes: 19 additions & 3 deletions nextjs-frontend/__tests__/timeline-social-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { applyReaction, commentsLabel, linked, sumReactions } from "@/components/timeline/social-model";
import { applyReaction, commentsLabel, linked, reactedBy, sumReactions } from "@/components/timeline/social-model";

test("a reaction moves the count by one and keeps the palette's order", () => {
const list = [{ emoji: "❤️", count: 2, mine: false }];
Expand All @@ -23,14 +23,30 @@ test("a line sums its items' reactions, yours when you gave it on any", () => {
[],
]),
).toEqual([
{ emoji: "👍", count: 3, mine: true },
{ emoji: "🎉", count: 1, mine: false },
{ emoji: "👍", count: 3, mine: true, who: [] },
{ emoji: "🎉", count: 1, mine: false, who: [] },
]);
expect(
sumReactions([
[{ emoji: "👍", count: 2, mine: false, who: ["Sam", "Vi"] }],
[{ emoji: "👍", count: 1, mine: false, who: ["Sam"] }],
]),
).toEqual([{ emoji: "👍", count: 3, mine: false, who: ["Sam", "Vi"] }]);
expect(commentsLabel(0)).toBe("");
expect(commentsLabel(1)).toBe("1 comment");
expect(commentsLabel(4)).toBe("4 comments");
});

test("a reaction's hover says who gave it: you first, then up to five names, then how many more", () => {
expect(reactedBy({ emoji: "👍", count: 1, mine: true, who: [] })).toBe("You");
expect(reactedBy({ emoji: "👍", count: 2, mine: true, who: ["Sam"] })).toBe("You and Sam");
expect(reactedBy({ emoji: "👍", count: 3, mine: false, who: ["Sam", "Vi"] })).toBe("Sam, Vi and 1 other");
expect(reactedBy({ emoji: "👍", count: 9, mine: true, who: ["A", "B", "C", "D", "E"] })).toBe(
"You, A, B, C, D, E and 3 others",
);
expect(reactedBy({ emoji: "👍", count: 2, mine: false })).toBe("2 others");
});

test("the agent's reply links its addresses", () => {
expect(linked("Done.\nDecide in Lens: Write the note\nhttps://lens.local/approvals?id=3 (soon)")).toEqual([
{ text: "Done.\nDecide in Lens: Write the note\n" },
Expand Down
Loading
Loading
0