8000 feat(timeline): open one item by its address, and link comments, reactions and approvals to it by adeelahmad · Pull Request #646 · 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
3 changes: 3 additions & 0 deletions changelog.d/timeline-open-item.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The timeline opens one item by its id (`/timeline?item=<id>`), above the rest with its details and actions and a way
back. A comment or a reaction on an item, and an approval's "because", now link to that item rather than to the
timeline as a whole.
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Muting a source, a person or a kind on the timeline, for good or until a time, for the person alone, with nothing deleted; through the API, MCP, the assistant (asking first after outside text) and the page | [test_timeline_mutes.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_mutes.py) `test_a_mute_hides_an_actor_or_a_kind_for_the_person_alone`, `test_agents_mute_too_and_the_assistant_asks_first_after_outside_text`; jest [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `muting a source takes everything from it out, lists it as muted, and Undo or the chip brings it back`, `the menu beside Mute snoozes a source for a day, or mutes the item's kind` |
| An asset moved to another namespace is `asset.moved`, a Move seen where it is now, and a sensor a person pauses, ignores, resumes or sets back to new is `sensor.changed`, an Update with the status it had before, seen in its namespace or by admins; a change that changes nothing logs nothing | [test_timeline_verbs.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_verbs.py) `test_moving_an_asset_is_a_move_seen_where_it_is_now`, `test_switching_a_sensor_is_an_update_with_what_it_was` |
| An opened timeline item shows what it caused, the runs it started of the automations the person manages and the approvals those asked for, with Approve and Decline on waiting ones; `GET /activities/{id}/caused`, `approvals?activity=`, MCP `activity_caused` and the assistant | [test_timeline_caused.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_caused.py) `test_an_item_lists_what_it_caused_and_the_waiting_approval_is_decided_there`, `test_agents_read_what_an_item_caused_too`; jest [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `an opened item shows what it caused, and a waiting approval is approved there` |
| One timeline item by its id in the address (`/timeline?item=<id>`), opened above the timeline with its actions and a way back, which is where a comment on an item, a reaction and an approval's "because" link | jest [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `the address can name one item, opened above the timeline, with a way back`; [test_timeli 10000 ne_social.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_social.py) `test_comments_on_a_logged_activity_follow_its_audience` |
| Every entity's own page at `/wiki/<namespace>/<Name>` | [test_entities.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_entities.py) `test_wiki_addresses`; [wiki-page.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/wiki-page.test.tsx); [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `entity-find` |
| Entities, topics and people edited and merged from their lists, the graph or an asset, with Undo instead of a confirm | [test_entities.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_entities.py) `test_suggestions_and_merge_undo`; [entities-curate.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/entities-curate.test.tsx); [topics-curate.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/topics-curate.test.tsx); [recording-entities-edit.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/recording-entities-edit.test.tsx); [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `entity-merge`, `graph-merge`, `speaker-rename` |
| Names get their real types (people, places, organisations, events, works) by rules, without a model; older Terms are typed on upgrade | [test_name_types.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_name_types.py) `test_names_get_the_type_their_words_give`, `test_analysis_types_people_places_and_events`, `test_the_upgrade_types_the_old_terms_and_keeps_what_people_chose` |
Expand Down
5 changes: 4 additions & 1 deletion docs/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ unless you ask for them by kind.
automation (what it did or caused). Filters live in the address, so a view can be linked, and the last set you used
comes back the next time you open the page in this browser. **Clear** drops them all.
- On a phone the list is one column and the filters open in a sheet from **Filters**.
- One item: `/timeline?item=<id>` (an activity's id, like `create-recording-7`) opens that item above the timeline,
with its details, marks, reactions, comments and actions, and **Back to the timeline** under it. That is the link
a comment on an item, a reaction and an approval's "because" give to the item they are about, and the link an
agent gives a person to an item it read with `timeline`. One you can't see says "No such item".

### Reviewing

Expand Down Expand Up @@ -254,6 +258,5 @@ per source page; they go when the person is removed. Nothing of this costs a mod
- 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).
- A `comment.added` on an activity links to the timeline, not yet to the item itself.
- 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.
2 changes: 1 addition & 1 deletion fastapi_backend/app/domain/approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def _why(event):
"kind": event.get("kind"),
"summary": event.get("summary"),
"published": event.get("published"),
"url": (event.get("object") or {}).get("url"),
"url": (event.get("object") or {}).get("url") or f"/timeline?item={event['id']}",
}
)

Expand Down
3 changes: 3 additions & 0 deletions fastapi_backend/app/domain/event_reactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"properties": {
"id": {"type": "string", "title": "The activity it is on", "description": "lens:activity/<its id>"},
"name": {"type": "string", "title": "That activity's summary"},
"url": {"type": "string", "title": "Link", "description": "the item on the timeline"},
"emoji": {"type": "string", "title": "The emoji", "enum": list(EMOJIS)},
"activity": {"type": "string", "title": "The activity's id"},
},
Expand All @@ -58,6 +59,7 @@
"type": "Activity",
"id": "lens:activity/create-recording-7",
"name": "Work Gmail received “Parents' evening”",
"url": "/timeline?item=create-recording-7",
"emoji": "👍",
"activity": "create-recording-7",
},
Expand Down Expand Up @@ -139,6 +141,7 @@ def log(db, a, actor, emoji):
"type": "Activity",
"id": f"lens:activity/{a['id']}",
"name": a.get("summary") or a["id"],
"url": f"/timeline?item={a['id']}",
"emoji": emoji,
"activity": a["id"],
},
Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/app/domain/events_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -1261,7 +1261,7 @@ def _comment_activities(db, rows):
"type": "Note",
"id": f"lens:comment/{r['id']}",
"content": _text(r.get("text"), CONTENT_MAX) or "",
"url": f"/resources/{r.get('recording')}" if r.get("recording") is not None else "/timeline",
"url": f"/resources/{r.get('recording')}" if r.get("recording") is not None else f"/timeline?item={r.get('activity')}",
"inReplyTo": f"lens:comment/{r['parent']}" if reply else None,
"quote": _text(r.get("quote")),
"t0": _num(r.get("t0")),
Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/tests/api/test_timeline_social.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def test_comments_on_a_logged_activity_follow_its_audience(client, db, cfg, fold
assert client.get(URL, params={"kinds": "test_thing.happened"}, headers=hv).json()["comments"] == {shared: 1}
added = client.get(URL, params={"kinds": "comment.added"}, headers=hv).json()["items"][0]
assert added["target"] == {"type": "Activity", "id": f"lens:activity/{shared}", "name": "Lens noted x"}
assert added["object"]["url"] == "/timeline"
assert added["object"]["url"] == f"/timeline?item={shared}" # the item itself, opened on the timeline
# a comment on an activity only its owner sees is seen by the owner alone, as a comment and on the stream
assert client.get(f"{URL}/{private}/comments", headers=hv).status_code == 404
client.post(f"{URL}/{private}/comments", json={"text": "Just us"}, headers=hr)
Expand Down
22 changes: 22 additions & 0 deletions nextjs-frontend/__tests__/timeline-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jest.mock("@/app/openapi-client", () => ({
listActivityMutes: jest.fn(),
muteActivities: jest.fn(),
activityCaused: jest.fn(),
getActivity: jest.fn(),
},
Chats: { decideApproval: jest.fn() },
}));
Expand Down Expand Up @@ -139,6 +140,10 @@ beforeEach(() => {
m(Activities.listActivityComments).mockReturnValue(ok([COMMENT]));
m(Activities.listActivityMutes).mockReturnValue(ok({ mutes: [] }));
m(Activities.activityCaused).mockReturnValue(ok({ runs: [], approvals: [] }));
m(Activities.getActivity).mockImplementation(({ path }) => {
const a = ITEMS.find((x) => x.id === path.activity_id);
return a ? ok(a) : Promise.reject(new Error("not found"));
});
m(Activities.muteActivities).mockImplementation(({ body }) =>
ok({
mutes: body.on
Expand Down Expand Up @@ -511,3 +516,20 @@ test("the menu beside Mute snoozes a source for a day, or mutes the item's kind"
expect(Date.parse(until) - Date.now()).toBeGreaterThan(23 * 3_600_000);
expect(await screen.findByText("Muted Work Gmail for a day")).toBeInTheDocument();
});

test("the address can name one item, opened above the timeline, with a way back", async () => {
search = "item=u1";
wrap(<TimelinePage />);
const screen.findByRole("region", { name: "This item" });
expect(m(Activities.getActivity)).toHaveBeenCalledWith(expect.objectContaining({ path: { activity_id: "u1" } }));
// opened already: its details and actions are there, and the whole timeline still lists below
expect(await within(one).findByRole("button", { name: "Hide" })).toBeInTheDocument();
expect(within(one).getByRole("link", { name: "Adeel received “Mail u1”" })).toHaveAttribute("href", "/resources/u1");
expect(within(one).getByRole("link", { name: "Back to the timeline" })).toHaveAttribute("href", "/timeline");
expect(within(await screen.findByRole("list", { name: "Timeline" })).getAllByTestId("timeline-row")).toHaveLength(2);
expect(replace).not.toHaveBeenCalled();
// one that isn't there, or not yours, says so
search = "item=nope";
wrap(<TimelinePage />);
expect(await screen.findByText("No such item")).toBeInTheDocument();
});
2 changes: 1 addition & 1 deletion nextjs-frontend/components/approvals/inbox-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function InboxCard({
const expires = expiresLine(a.expires_at);
const lines = paramLines(a.intent?.params);
const why = a.why;
const whyHref = why?.url || (why?.activity ? `/timeline?q=${encodeURIComponent(why.summary ?? "")}` : null);
const whyHref = why?.url || (why?.activity ? `/timeline?item=${encodeURIComponent(why.activity)}` : null);

return (
<div
Expand Down
78 changes: 78 additions & 0 deletions nextjs-frontend/components/timeline/one-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useState, type ComponentProps } from "react";

import { Activities } from "@/app/openapi-client";
import type { StreamActivity } from "@/app/openapi-client/types.gen";
import { TimelineRow } from "@/components/timeline/item";
import { group, type Activity } from "@/components/timeline/model";
import { EmptyState, Skeleton } from "@/components/ui/states";
import { data, useApiClient } from "@/lib/api/browser";

/** One activity by its id (GET /api/v1/activities/{id}), for `/timeline?item=<id>`: the link a comment, a reaction
* or an approval gives to the item it is about. */
export function useOneItem(id: string) {
const client = useApiClient();
return useQuery({
queryKey: ["timeline-item", id],
queryFn: () => data(Activities.getActivity({ client, path: { activity_id: id } })) as Promise<StreamActivity>,
enabled: !!id,
staleTime: 10_000,
});
}

type RowProps = ComponentProps<typeof TimelineRow>;

/** The item the address names, opened above the timeline, with the same marks, reactions, comments and actions as
* any line, and a way back to the whole timeline. */
export function OneItem({
id,
a,
error,
back,
...row
}: {
id: string;
a: Activity | undefined;
error: Error | null;
/** Where "Back to the timeline" goes. */
back: string;
} & Pick<
RowProps,
| "namespaces"
| "labels"
| "markOf"
| "onMark"
| "onHide"
| "onMute"
| "reactionsOf"
| "commentsOf"
| "caused"
| "social"
>) {
const [open, setOpen] = useState(true);
const g = a ? group([a])[0] : null;
return (
<section aria-label="This item" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-fg-secondary">
<span className="font-semibold">This item</span>
<Link href={back} className="inline-flex items-center gap-1 text-fg-accent hover:underline">
<ArrowLeft className="size-3.5" aria-hidden />
Back to the timeline
</Link>
</div>
{error ? (
<EmptyState title="No such item">{error.message}</EmptyState>
) : g ? (
<ul className="flex flex-col rounded-md border border-border bg-surface px-3">
<TimelineRow group={g} open={open} => setOpen((o) => !o)} focused={false} {...row} />
</ul>
) : (
<Skeleton className="h-12" aria-label={`Loading ${id}`} />
)}
</section>
);
}
29 changes: 29 additions & 0 deletions nextjs-frontend/components/timeline/timeline-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { askAbout } from "@/components/page-chat/page-chat-model";
import { Caused } from "@/components/timeline/caused";
import { FiltersBar, type Actor } from "@/components/timeline/filters";
import { TimelineRow, groupMark, type MuteOpts } from "@/components/timeline/item";
import { OneItem, useOneItem } from "@/components/timeline/one-item";
import {
askQuote,
filterCount,
Expand Down Expand Up @@ -83,6 +84,7 @@ export function TimelinePage() {
const params = useSearchParams();
const qs = params.toString();
const filters = useMemo(() => fromParams(new URLSearchParams(qs)), [qs]);
const itemId = params.get("item") ?? "";
const { namespaces } = useArchive();
const phone = useMediaQuery("(max-width: 767px)");
const [sheet, setSheet] = useState(false);
Expand All @@ -106,6 +108,7 @@ export function TimelinePage() {
);

const timeline = useTimeline(filters);
const >
const kinds = useKinds();
const pages = timeline.data?.pages;
const query = useMemo(() => toQuery(filters), [filters]);
Expand Down Expand Up @@ -342,6 +345,32 @@ export function TimelinePage() {
))}
</div>
)}
{itemId && (
<OneItem
id={itemId}
a={one.data}
error={one.error as Error | null}
namespaces={{ ...Object.fromEntries((namespaces ?? []).map((n) => [String(n.id), n.name])), ...nsNames }}
labels={labels}
markOf={marks.of}
>
>
>
reactionsOf={reactions.of}
commentsOf={counts.of}
caused={(a) => <Caused a={a} />}
social={(a) => (
<Social
a={a}
reactions={reactions.of(a.id)}
on) => reactions.react(a.id, emoji, on)}
comments={counts.of(a.id)}
bump={counts.bump}
/>
)}
back={pathname}
/>
)}
{phone && (
<Drawer open={sheet} title="Filters" width={360}>
<div className="p-4">{bar(true)}</div>
Expand Down
Loading
0