From ec6ffb48873815bacd1420293316a781478dc0db Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Wed, 7 Oct 2026 20:36:51 +0800 Subject: [PATCH] feat(studio): fold feedback conversations and preserve unread replies --- desktop/frontend-next/src/actions.ts | 1 + desktop/frontend-next/src/i18n/en.ts | 4 + desktop/frontend-next/src/styles/feedback.css | 5 +- desktop/frontend-next/src/ui/FeedbackMine.tsx | 38 ++++-- .../ui/FeedbackThread.interaction.test.tsx | 12 +- .../frontend-next/src/ui/FeedbackThread.tsx | 54 +++++--- .../ui/feedback-folding.interaction.test.tsx | 122 ++++++++++++++++++ 7 files changed, 200 insertions(+), 36 deletions(-) create mode 100644 desktop/frontend-next/src/ui/feedback-folding.interaction.test.tsx diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts index e5d06dbbed..d78e01acfa 100644 --- a/desktop/frontend-next/src/actions.ts +++ b/desktop/frontend-next/src/actions.ts @@ -188,6 +188,7 @@ export const ACTIONS: UIAction[] = [ { id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" }, { id: "feedback.another", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" }, + { id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" }, { id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" }, { id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" }, diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index 3183084e48..4abf2ff499 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -138,6 +138,10 @@ export const EN: Record = { "活动": "Activity", "轨迹": "Trajectory", "对话": "Conversation", + "展开对话": "Expand conversation", + "收起对话": "Collapse conversation", + "{n} 条回复": "{n} replies", + "{n} 条新回复": "{n} new replies", "事件轨迹": "Event trail", "运行图": "Run graph", "你说过的话": "What you said", diff --git a/desktop/frontend-next/src/styles/feedback.css b/desktop/frontend-next/src/styles/feedback.css index 547ec08f6f..429831307f 100644 --- a/desktop/frontend-next/src/styles/feedback.css +++ b/desktop/frontend-next/src/styles/feedback.css @@ -196,6 +196,9 @@ } .fbk-thread { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; } +.fbk-thread-body:not([hidden]) { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; width: 100%; } +.fbk-thread-toggle { gap: 6px; flex-wrap: wrap; } +.fbk-thread-toggle .studio-icon { width: 12px; height: 12px; } .fbk-thread ol { display: flex; flex-direction: column; gap: 8px; width: 100%; margin: 0; padding: 0; list-style: none; } .fbk-thread li { display: flex; flex-direction: column; gap: 3px; max-width: min(92%, 560px); padding: 8px 11px; @@ -208,7 +211,7 @@ .fbk-msg-hd { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--fs-meta); } .fbk-msg-hd b { color: var(--text); font-weight: var(--w-med); } .fbk-msg-hd time { margin-left: auto; } -.fbk-new { +.fbk-new, .fbk-thread-new { padding: 0 7px; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) var(--fs-micro)/1.6 var(--ui); } diff --git a/desktop/frontend-next/src/ui/FeedbackMine.tsx b/desktop/frontend-next/src/ui/FeedbackMine.tsx index b45a844202..8eba0e7fa6 100644 --- a/desktop/frontend-next/src/ui/FeedbackMine.tsx +++ b/desktop/frontend-next/src/ui/FeedbackMine.tsx @@ -107,8 +107,7 @@ interface Props { onUnread?: (n: number) => void; } -// Opening the list is what reads the replies: each report with new ones is -// marked seen, and its "new" marks stay until the page is closed. +// A reply is read only in an expanded thread; its "new" mark stays for this visit. export function FeedbackMine({ port, onFile, onUnread }: Props) { const [mine, setMine] = useState(null); const [failure, setFailure] = useState(null); @@ -121,12 +120,24 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) { report.current = onUnread; const itemsRef = useRef([]); const pending = useRef(new Set()); - const tell = () => report.current?.(itemsRef.current.filter((i) => i.needsInput || pending.current.has(i.receipt)).length); - const unfolded = (receipt: string) => - port.feedbackSeen(receipt, newest(itemsRef.current.find((i) => i.receipt === receipt)!)).then(() => { - pending.current.delete(receipt); - tell(); - }).catch(() => {}); + const views = useRef(new Map()); + const tell = useCallback(() => report.current?.(itemsRef.current.filter((i) => i.needsInput || pending.current.has(i.receipt)).length), []); + const markSeen = useCallback(async (item: FeedbackItem) => { + const upTo = newest(item); + await port.feedbackSeen(item.receipt, upTo); + const current = itemsRef.current.find((i) => i.receipt === item.receipt); + if (current && newest(current) <= upTo) pending.current.delete(item.receipt); + tell(); + }, [port, tell]); + const view = (receipt: string, all: boolean | null) => { + if (all === null) { + views.current.delete(receipt); + return; + } + views.current.set(receipt, all); + const item = itemsRef.current.find((i) => i.receipt === receipt)!; + if (pending.current.has(receipt) && (all || !foldedUnread(item))) void markSeen(item).catch(() => {}); + }; const load = useCallback(() => { setLoading(true); @@ -141,17 +152,16 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) { return next; }); itemsRef.current = m.items; - pending.current = new Set(m.items.filter((i) => i.unreadReplies > 0 && foldedUnread(i)).map((i) => i.receipt)); - const shown = m.items.filter((i) => i.unreadReplies > 0 && !pending.current.has(i.receipt)); - const read = await Promise.allSettled(shown.map((i) => port.feedbackSeen(i.receipt, newest(i)))); - shown.forEach((i, k) => read[k]!.status === "rejected" && pending.current.add(i.receipt)); + pending.current = new Set(m.items.filter((i) => i.unreadReplies > 0).map((i) => i.receipt)); + const shown = m.items.filter((i) => i.unreadReplies > 0 && views.current.has(i.receipt) && (views.current.get(i.receipt) || !foldedUnread(i))); + await Promise.allSettled(shown.map(markSeen)); tell(); const fresher = m.items.filter((i) => i.unreadReplies > 0).length; if (fresher > 0) setSaid(t("有 {n} 份反馈收到了新回复。", { n: fresher })); }) .catch((e) => setFailure(feedbackFailure(e))) .finally(() => setLoading(false)); - }, [port]); + }, [port, markSeen, tell]); useEffect(load, [load]); @@ -256,7 +266,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) { ))} } - + ))} diff --git a/desktop/frontend-next/src/ui/FeedbackThread.interaction.test.tsx b/desktop/frontend-next/src/ui/FeedbackThread.interaction.test.tsx index 11603104d4..092c61d8e6 100644 --- a/desktop/frontend-next/src/ui/FeedbackThread.interaction.test.tsx +++ b/desktop/frontend-next/src/ui/FeedbackThread.interaction.test.tsx @@ -38,6 +38,7 @@ describe("the thread under a report", () => { const port = portWith(() => mine([item({ status: "answered", replies: [reply(1, "maintainer", hostile), reply(2, "user", "thanks\nsecond line")] })])); render( {}} />); await screen.findByText("FB-AAAA-0001"); + await userEvent.click(screen.getByRole("button", { name: /展开对话/ })); const msgs = [...row("FB-AAAA-0001").querySelectorAll(".fbk-thread li")]; expect(msgs.map((li) => li.getAttribute("data-author"))).toEqual(["maintainer", "user"]); expect(msgs[0]!.querySelector(".fbk-msg")!.textContent).toBe(hostile); @@ -55,6 +56,7 @@ describe("the thread under a report", () => { await screen.findByText("FB-AAAA-0001"); expect(screen.queryByText("message 3")).toBeNull(); expect(screen.getByText("message 4")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: /展开对话/ })); await userEvent.click(screen.getByRole("button", { name: "显示更早的 3 条" })); expect(screen.getByText("message 1")).toBeTruthy(); expect(screen.queryByRole("button", { name: /显示更早/ })).toBeNull(); @@ -218,7 +220,7 @@ describe("who may reply", () => { }); describe("reading the replies", () => { - it("marks each report with new replies seen, keeps its New mark for this visit, and reports what still waits", async () => { + it("marks an opened report seen, keeps its New mark for this visit, and reports what still waits", async () => { const items = [ item({ receipt: "FB-AAAA-0001", status: "answered", unreadReplies: 1, replies: [reply(1, "maintainer", "old"), reply(2, "user", "ok"), reply(3, "maintainer", "fresh")] }), item({ receipt: "FB-AAAA-0002", status: "needs_info", needsInput: true, replies: [reply(4, "maintainer", "q")] }), @@ -228,6 +230,8 @@ describe("reading the replies", () => { const onUnread = vi.fn(); render( {}} onUnread={onUnread} />); await screen.findByText("FB-AAAA-0001"); + expect(port.feedbackSeen).not.toHaveBeenCalled(); + await userEvent.click(within(row("FB-AAAA-0001")).getByRole("button", { name: /展开对话/ })); await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1)); expect(port.feedbackSeen).toHaveBeenCalledTimes(1); expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 3); @@ -244,6 +248,8 @@ describe("reading the replies", () => { }); const onUnread = vi.fn(); render( {}} onUnread={onUnread} />); + await userEvent.click(await screen.findByRole("button", { name: /展开对话/ })); + await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 1)); await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1)); }); @@ -280,6 +286,8 @@ describe("review fixes", () => { port.feedbackSeen = vi.fn(async () => { throw new Error("disk"); }); const onUnread = vi.fn(); render( {}} onUnread={onUnread} />); + await userEvent.click(await screen.findByRole("button", { name: /展开对话/ })); + await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 1)); await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1)); expect(onUnread).not.toHaveBeenCalledWith(2); }); @@ -318,6 +326,8 @@ describe("review fixes", () => { await screen.findByText("FB-AAAA-0001"); await waitFor(() => expect(onUnread).toHaveBeenCalledWith(1)); expect(port.feedbackSeen).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: /展开对话/ })); + expect(port.feedbackSeen).not.toHaveBeenCalled(); await userEvent.click(screen.getByRole("button", { name: /显示更早/ })); await waitFor(() => expect(port.feedbackSeen).toHaveBeenCalledWith("FB-AAAA-0001", 6)); await waitFor(() => expect(onUnread).toHaveBeenLastCalledWith(0)); diff --git a/desktop/frontend-next/src/ui/FeedbackThread.tsx b/desktop/frontend-next/src/ui/FeedbackThread.tsx index 278ba60f09..5fa3292f58 100644 --- a/desktop/frontend-next/src/ui/FeedbackThread.tsx +++ b/desktop/frontend-next/src/ui/FeedbackThread.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useId, useMemo, useRef, useState } from "react"; import { current, t } from "../i18n"; import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, isUnderReview, type FeedbackItem, type FeedbackReply } from "../port/feedback"; import type { AgentPort } from "../port/port"; @@ -27,13 +27,15 @@ function when(iso: string): string { interface ThreadProps { item: FeedbackItem; fresh: number; - onShowAll?: (receipt: string) => void; + onView?: (receipt: string, all: boolean | null) => void; } // Replies are plain text by contract: they render as text nodes, never linked, // formatted or interpreted, whoever wrote them. -export function FeedbackThread({ item, fresh, onShowAll }: ThreadProps) { +export function FeedbackThread({ item, fresh, onView }: ThreadProps) { const [all, setAll] = useState(false); + const [expanded, setExpanded] = useState(false); + const id = useId(); const replies = item.replies; const hidden = all ? 0 : Math.max(0, replies.length - SHOWN); const shown = replies.slice(hidden); @@ -42,23 +44,35 @@ export function FeedbackThread({ item, fresh, onShowAll }: ThreadProps) { if (replies.length === 0) return null; return (
- {hidden > 0 && ( - - )} -
    - {shown.map((r) => ( -
  1. -
    - {t(AUTHOR_LABEL[r.author])} - {isNew(r) && {t("新")}} - -
    -

    {r.body}

    -
  2. - ))} -
+ +
); } diff --git a/desktop/frontend-next/src/ui/feedback-folding.interaction.test.tsx b/desktop/frontend-next/src/ui/feedback-folding.interaction.test.tsx new file mode 100644 index 0000000000..6a4354c4f0 --- /dev/null +++ b/desktop/frontend-next/src/ui/feedback-folding.interaction.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "./testkit"; +import { FeedbackMine } from "./FeedbackMine"; +import { MockPort } from "../port/mock"; +import type { FeedbackItem, FeedbackMine as Mine } from "../port/feedback"; + +afterEach(cleanup); + +const item = (receipt = "FB-AAAA-0001", unread = 1, count = 2): FeedbackItem => ({ + receipt, category: "bug", titleSnippet: "A report", status: "answered", needsInput: false, underReview: false, + unreadReplies: unread, createdAt: "2026-10-01T08:00:00Z", updatedAt: "2026-10-02T08:00:00Z", + replies: Array.from({ length: count }, (_, i) => ({ id: i + 1, author: "maintainer", body: `Reply ${i + 1}`, createdAt: "2026-10-02T08:00:00Z" })), +}); + +const mine = (items: FeedbackItem[]): Mine => ({ items, offline: false, unread: items.filter((i) => i.unreadReplies > 0 || i.needsInput).length, hasNew: false }); +const row = (receipt = "FB-AAAA-0001") => screen.getByText(receipt).closest("li")!; +const toggle = (receipt = "FB-AAAA-0001") => within(row(receipt)).getByRole("button", { name: /展开对话|收起对话/ }); + +function setup(read: () => Mine) { + const port = new MockPort(); + vi.spyOn(port, "myFeedback").mockImplementation(async () => read()); + const seen = vi.spyOn(port, "feedbackSeen").mockResolvedValue(undefined); + vi.spyOn(port, "replyFeedback"); + const onUnread = vi.fn(); + render(); + return { port, seen, onUnread }; +} + +describe("folded feedback conversations", () => { + it("starts folded, exposes reply counts and leaves hidden replies unread until opened", async () => { + const { seen, onUnread } = setup(() => mine([item()])); + await screen.findByText("FB-AAAA-0001"); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + expect(toggle().textContent).toContain("2 条回复"); + expect(toggle().textContent).toContain("1 条新回复"); + expect(within(row().querySelector(".fbk-thread")!).queryByRole("list")).toBeNull(); + expect(seen).not.toHaveBeenCalled(); + expect(onUnread).toHaveBeenLastCalledWith(1); + + await userEvent.click(toggle()); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); + expect(within(row().querySelector(".fbk-thread")!).getByRole("list")).toBeTruthy(); + await waitFor(() => expect(seen).toHaveBeenCalledWith("FB-AAAA-0001", 2)); + await waitFor(() => expect(onUnread).toHaveBeenLastCalledWith(0)); + await userEvent.click(toggle()); + expect(toggle().textContent).toContain("1 条新回复"); + expect(seen).toHaveBeenCalledTimes(1); + }); + + it("opens and closes one conversation from the keyboard without opening its neighbour", async () => { + setup(() => mine([item(), item("FB-AAAA-0002", 0)])); + await screen.findByText("FB-AAAA-0002"); + toggle().focus(); + await userEvent.keyboard("{Enter}"); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); + expect(toggle("FB-AAAA-0002").getAttribute("aria-expanded")).toBe("false"); + await userEvent.keyboard(" "); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(toggle()); + }); + + it("keeps newly refreshed replies unread while folded, then reads them on opening", async () => { + let count = 2; + const { seen, onUnread } = setup(() => mine([item("FB-AAAA-0001", count - 1, count)])); + await screen.findByText("FB-AAAA-0001"); + count = 3; + await userEvent.click(screen.getByRole("button", { name: "刷新列表" })); + await waitFor(() => expect(toggle().textContent).toContain("2 条新回复")); + expect(seen).not.toHaveBeenCalled(); + expect(onUnread).toHaveBeenLastCalledWith(1); + await userEvent.click(toggle()); + await waitFor(() => expect(seen).toHaveBeenCalledWith("FB-AAAA-0001", 3)); + }); + + it("waits for earlier unread replies too, and preserves the full view through a refresh", async () => { + let count = 6; + const { seen } = setup(() => mine([item("FB-AAAA-0001", count, count)])); + await screen.findByText("FB-AAAA-0001"); + await userEvent.click(toggle()); + expect(seen).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "显示更早的 2 条" })); + await waitFor(() => expect(seen).toHaveBeenCalledWith("FB-AAAA-0001", 6)); + count = 7; + await userEvent.click(screen.getByRole("button", { name: "刷新列表" })); + await waitFor(() => expect(seen).toHaveBeenCalledWith("FB-AAAA-0001", 7)); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); + expect(within(row()).getAllByText(/Reply \d/)).toHaveLength(7); + }); + + it("retains a reply draft when its conversation is folded and expanded again", async () => { + const { port } = setup(() => mine([item()])); + await screen.findByText("FB-AAAA-0001"); + await userEvent.click(screen.getByRole("button", { name: "回复" })); + const box = screen.getByRole("textbox", { name: /回复/ }); + await userEvent.type(box, "My draft"); + await userEvent.click(toggle()); + await userEvent.click(toggle()); + expect(box.value).toBe("My draft"); + expect(port.replyFeedback).not.toHaveBeenCalled(); + }); + + it("does not let an earlier read clear replies that arrived after the thread was folded", async () => { + let count = 2; + const { seen, onUnread } = setup(() => mine([item("FB-AAAA-0001", 1, count)])); + let finish!: () => void; + seen.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await screen.findByText("FB-AAAA-0001"); + await userEvent.click(toggle()); + await userEvent.click(toggle()); + count = 3; + await userEvent.click(screen.getByRole("button", { name: "刷新列表" })); + await waitFor(() => expect(toggle().textContent).toContain("3 条回复")); + await act(async () => finish()); + await waitFor(() => expect(onUnread).toHaveBeenLastCalledWith(1)); + await userEvent.click(toggle()); + await waitFor(() => expect(seen).toHaveBeenCalledWith("FB-AAAA-0001", 3)); + await waitFor(() => expect(onUnread).toHaveBeenLastCalledWith(0)); + }); +});