8000 feat(timeline): edit your comments and resolve threads from the timeline by adeelahmad · Pull Request #654 · 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-comment-edit.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,2 @@
    Comments on the timeline can be edited in place (Enter saves, Escape cancels), and a thread can be resolved or
    reopened there, as on an asset's page.
    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, 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` |
    | Reactions and comments on timeline activities, shared by everyone who sees them, each reaction saying who gave it, comments edited and threads resolved in place, 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`, `your comment is edited in place, and a thread you can resolve is resolved and reopened from the timeline` |
    | 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
    4 changes: 3 additions & 1 deletion docs/timeline.md
    Original file line number Diff line number Diff line change
    Expand Up @@ -100,6 +100,9 @@ them, and each one is an activity of its own, so an automation (or an agent) can
    - **Comments** open from the comment count on an opened item (or **Comment** when there are none): the threads with
    who wrote each and when, **Reply** on a thread, and a box where Enter sends (Shift+Enter makes a new line). Anyone
    who sees the activity can comment, up to 1,000 each; its writer, or an owner of the namespace, deletes a comment.
    **Edit** on your own comment opens its text in the box (Enter saves, Escape leaves it as it was), and it then says
    "edited". **Resolve the thread** (on its first comment, for whoever started it or an editor of the namespace)
    marks it "Resolved by Sam", and **Reopen the thread** takes that back; a resolved thread stays in place, dimmed.
    Each comment is a `comment.added` activity. A comment on an asset's arrival (`create-recording-<id>`) is a comment
    on the asset itself: the same thread shows on the asset's page and here, and a comment made on the page shows here.
    - Comments and reactions on an activity meant for one person (`lens.audience` owner) stay with that person, as the
    Expand Down Expand Up @@ -259,6 +262,5 @@ 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.
    - 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.
    48 changes: 48 additions & 0 deletions nextjs-frontend/__tests__/timeline-ui.test.tsx
    Original file line number Diff line number Diff line change
    Expand Up @@ -20,6 +20,7 @@ jest.mock("@/app/openapi-client", () => ({
    listActivityComments: jest.fn(),
    commentOnActivity: jest.fn(),
    deleteActivityComment: jest.fn(),
    updateActivityComment: jest.fn(),
    listActivityMutes: jest.fn(),
    muteActivities: jest.fn(),
    activityCaused: jest.fn(),
    Expand Down Expand Up @@ -215,6 +216,53 @@ test("reactions and comments: a line sums its items', a 👍 goes to the server,
    );
    });

    test("your comment is edited in place, and a thread you can resolve is resolved and reopened from the timeline", async () => {
    const mineC = { ...COMMENT, mine: true, can_resolve: true, can_delete: true };
    m(Activities.listActivityComments).mockReturnValue(ok([mineC]));
    m(Activities.updateActivityComment).mockImplementation(({ body }) => ok({ ...mineC, ...body }));
    wrap(<TimelinePage />);
    const rows = within(await screen.findByRole("list", { name: "Timeline" })).getAllByTestId("timeline-row");
    fireEvent.click(within(rows[1]).getByRole("button", { name: "Show details" }));
    fireEvent.click(within(rows[1]).getByRole("button", { name: "1 comment" }));
    await within(rows[1]).findByText("Nice one");
    // Edit opens the text in a box; Enter saves it
    fireEvent.click(within(rows[1]).getByRole("button", { name: "Edit" }));
    const box = within(rows[1]).getByRole("textbox", { name: "Save" });
    expect(box).toHaveValue("Nice one");
    fireEvent.change(box, { target: { value: "Nice one, thanks" } });
    fireEvent.keyDown(box, { key: "Enter" });
    await waitFor(() =>
    expect(m(Activities.updateActivityComment)).toHaveBeenCalledWith(
    expect.objectContaining({ path: { activity_id: "u1", cid: 7 }, body: { text: "Nice one, thanks" } }),
    ),
    );
    await waitFor(() => expect(within(rows[1]).queryByRole("textbox", { name: "Save" })).not.toBeInTheDocument());
    // Escape leaves an edit without saving
    fireEvent.click(within(rows[1]).getByRole("button", { name: "Edit" }));
    fireEvent.keyDown(within(rows[1]).getByRole("textbox", { name: "Save" }), { key: "Escape" });
    expect(within(rows[1]).queryByRole("textbox", { name: "Save" })).not.toBeInTheDocument();
    expect(m(Activities.updateActivityComment)).toHaveBeenCalledTimes(1);
    // resolve, then the thread says so and offers Reopen
    m(Activities.listActivityComments).mockReturnValue(
    ok([{ ...mineC, resolved: true, resolved_by_name: "Adeel", resolved_at: new Date(NOW).toISOString() }]),
    );
    fireEvent.click(within(rows[1]).getByRole("button", { name: "Resolve the thread" }));
    await waitFor(() =>
    expect(m(Activities.updateActivityComment)).toHaveBeenLastCalledWith(
    expect.objectContaining({ path: { activity_id: "u1", cid: 7 }, body: { resolved: true } }),
    ),
    );
    expect(await within(rows[1]).findByRole("listitem", { name: "Resolved thread" })).toHaveTextContent(
    "Resolved by Adeel",
    );
    fireEvent.click(within(rows[1]).getByRole("button", { name: "Reopen the thread" }));
    await waitFor(() =>
    expect(m(Activities.updateActivityComment)).toHaveBeenLastCalledWith(
    expect.objectContaining({ body: { resolved: false } }),
    ),
    );
    });

    test("the list groups a run of emails into one line that opens to its items, and shows what you reviewed", async () => {
    wrap(<TimelinePage />);
    const list = await screen.findByRole("list", { name: "Timeline" });
    Expand Down
    101 changes: 81 additions & 20 deletions nextjs-frontend/components/timeline/social.tsx
    Original file line number Diff line number Diff line change
    @@ -1,10 +1,10 @@
    "use client";

    import { MessageSquare, Reply, SmilePlus, Sparkles, Trash2 } from "lucide-react";
    import { Check, MessageSquare, Pencil, Reply, RotateCcw, SmilePlus, Sparkles, Trash2 } from "lucide-react";
    import { useState, type KeyboardEvent } from "react";

    import type { Comment, Reaction } from "@/app/openapi-client/types.gen";
    import { threads, type Thread } from "@/components/recording/comments-model";
    import { resolvedBy, threads, type Thread } from "@/components/recording/comments-model";
    import { writer } from "@/components/recording/notes-model";
    import type { Activity } from "@/components/timeline/model";
    import { EMOJIS, EMOJI_LABEL, commentsLabel, linked, reactedBy } from "@/components/timeline/social-model";
    Expand Down Expand Up @@ -134,17 +134,19 @@ function Composer({
    placeholder,
    pending,
    autoFocus,
    initial = "",
    onSend,
    onCancel,
    }: {
    label: string;
    placeholder: string;
    pending: boolean;
    autoFocus?: boolean;
    initial?: string;
    onSend: (text: string) => Promise<unknown>;
    onCancel?: () => void;
    }) {
    const [text, setText] = useState("");
    const [text, setText] = useState(initial);
    const send = () => {
    if (!text.trim() || pending) return;
    void onSend(text.trim()).then(() => setText(""));
    Expand Down Expand Up @@ -181,12 +183,19 @@ function CommentLine({
    replies,
    onReply,
    onDelete,
    onEdit,
    onResolve,
    pending,
    }: {
    c: Comment;
    replies: number;
    onReply?: () => void;
    onDelete: () => void;
    onEdit: (text: string) => Promise<unknown>;
    onResolve?: (resolved: boolean) => void;
    pending: boolean;
    }) {
    const [editing, setEditing] = useState(false);
    return (
    <li className={cn("flex flex-col gap-0.5 py-1", c.parent != null && "ml-5 border-l border-border pl-3")}>
    <span className="flex items-center gap-2 text-[12px] text-fg-muted">
    Expand All @@ -197,6 +206,20 @@ function CommentLine({
    <span title={c.created_at ?? undefined}>{relative(c.created_at)}</span>
    {c.edited_at && <span>· edited</span>}
    <span className="ml-auto flex items-center gap-0.5">
    {onResolve && c.can_resolve && (
    <IconButton
    label={c.resolved ? "Reopen the thread" : "Resolve the thread"}
    size={24}
    => onResolve(!c.resolved)}
    >
    {c.resolved ? <RotateCcw className="size-3.5" /> : <Check className="size-3.5" />}
    </IconButton>
    )}
    {c.mine && !c.by_agent && (
    <IconButton label="Edit" size={24} => setEditing(true)}>
    <Pencil className="size-3.5" />
    </IconButton>
    )}
    {onReply && (
    <IconButton label="Reply" size={24} >
    <Reply className="size-3.5" />
    Expand All @@ -213,19 +236,31 @@ function CommentLine({
    )}
    </span>
    </span>
    <p className="whitespace-pre-wrap text-[13px] text-fg">
    {c.by_agent
    ? linked(c.text).map((part, i) =>
    part.url ? (
    <a key={i} href={part.url} className="text-fg-accent underline-offset-2 hover:underline">
    {part.text}
    </a>
    ) : (
    <span key={i}>{part.text}</span>
    ),
    )
    : c.text}
    </p>
    {editing ? (
    <Composer
    label="Save"
    placeholder="Edit your comment… (Enter saves, Escape cancels)"
    pending={pending}
    autoFocus
    initial={c.text}
    => (text === c.text ? Promise.resolve() : onEdit(text)).then(() => setEditing(false))}
    => setEditing(false)}
    />
    ) : (
    <p className="whitespace-pre-wrap text-[13px] text-fg">
    {c.by_agent
    ? linked(c.text).map((part, i) =>
    part.url ? (
    <a key={i} href={part.url} className="text-fg-accent underline-offset-2 hover:underline">
    {part.text}
    </a>
    ) : (
    <span key={i}>{part.text}</span>
    ),
    )
    : c.text}
    </p>
    )}
    </li>
    );
    }
    Expand All @@ -234,30 +269,54 @@ function ThreadView({
    t,
    onReply,
    onDelete,
    onEdit,
    onResolve,
    replying,
    setReplying,
    pending,
    }: {
    t: Thread;
    onReply: (text: string, parent: number) => Promise<unknown>;
    onDelete: (c: Comment, replies: number) => void;
    onEdit: (c: Comment, text: string) => Promise<unknown>;
    onResolve: (c: Comment, resolved: boolean) => void;
    replying: boolean;
    setReplying: (on: boolean) => void;
    pending: boolean;
    }) {
    const done = resolvedBy(t.root);
    return (
    <li className="flex flex-col">
    <li
    className={cn("flex flex-col", t.root.resolved && "opacity-80")}
    aria-label={t.root.resolved ? "Resolved thread" : "Thread"}
    >
    <ul className="flex flex-col">
    <CommentLine
    c={t.root}
    replies={t.replies.length}
    => setReplying(true)}
    => onDelete(t.root, t.replies.length)}
    => onEdit(t.root, text)}
    => onResolve(t.root, on)}
    pending={pending}
    />
    {t.replies.map((r) => (
    <CommentLine key={r.id} c={r} replies={0} => onDelete(r, 0)} />
    <CommentLine
    key={r.id}
    c={r}
    replies={0}
    => onDelete(r, 0)}
    => onEdit(r, text)}
    pending={pending}
    />
    ))}
    </ul>
    {done && (
    <span className="ml-5 inline-flex w-fit items-center gap-1 text-[11.5px] font-semibold text-green-dark">
    <Check className="size-3" aria-hidden /> {done}
    {t.root.resolved_at ? ` · ${relative(t.root.resolved_at)}` : ""}
    </span>
    )}
    {replying && (
    <div className="ml-5 pl-3">
    <Composer
    Expand All @@ -278,11 +337,11 @@ function ThreadView({
    * activity reads and joins it; on an asset's arrival it is the asset's own comments. */
    export function CommentThread({ a, bump }: { a: Activity; bump: (id: string, by: number) => void }) {
    const q = useActivityComments(a.id, true);
    const { create, remove } = useActivityCommentActions(a.id, bump);
    const { create, remove, update } = useActivityCommentActions(a.id, bump);
    const [replying, setReplying] = useState<number | null>(null);
    const list = q.data ?? [];
    const all = threads(list);
    const pending = create.isPending;
    const pending = create.isPending || update.isPending;
    const send = (text: string, parent?: number) => create.mutateAsync({ text, parent: parent ?? null });
    return (
    <div className="flex flex-col gap-1.5" aria-label="Comments">
    Expand All @@ -299,6 +358,8 @@ export function CommentThread({ a, bump }: { a: Activity; bump: (id: string, by:
    t={t}
    >
    replies) => remove.mutate({ cid: c.id, replies })}
    text) => update.mutateAsync({ cid: c.id, text })}
    resolved) => update.mutate({ cid: c.id, resolved })}
    replying={replying === t.root.id}
    setReplying={(on) => setReplying(on ? t.root.id : null)}
    pending={pending}
    Expand Down
    Loading
    Loading
    0