diff --git a/packages/views/drafts/annotations/annotation-thread-panel.test.tsx b/packages/views/drafts/annotations/annotation-thread-panel.test.tsx index 6fd72120323..074b9c1ac24 100644 --- a/packages/views/drafts/annotations/annotation-thread-panel.test.tsx +++ b/packages/views/drafts/annotations/annotation-thread-panel.test.tsx @@ -20,6 +20,20 @@ vi.mock("@multica/core/drafts", () => ({ useDeleteDraftAnnotation: () => ({ mutate: deleteMutate }), })); +// The expanded thread resolves human senders by member id via useActorName. +// Mock it so the panel renders without a QueryClient/workspace provider — same +// pattern issue-detail and friends use. +vi.mock("@multica/core/workspace/hooks", () => ({ + useActorName: () => ({ + getMemberName: (id: string) => (id === "u-1" ? "Ada Lovelace" : "Unknown"), + getAgentName: () => "Unknown Agent", + getActorName: (type: string, id: string) => + type === "member" && id === "u-1" ? "Ada Lovelace" : "Unknown", + getActorInitials: () => "AL", + getActorAvatarUrl: () => null, + }), +})); + import { AnnotationThreadPanel } from "./annotation-thread-panel"; function makeAnnotation(over: Partial = {}): DraftAnnotation { @@ -65,7 +79,7 @@ function anchored(annotation: DraftAnnotation, over: Partial function renderPanel(props: Partial> = {}) { const onSelect = props.onSelect ?? vi.fn(); - render( + const utils = render( , ); - return { onSelect }; + return { onSelect, container: utils.container }; } beforeEach(() => vi.clearAllMocks()); @@ -129,6 +143,57 @@ describe("AnnotationThreadPanel", () => { expect(updateMutate).toHaveBeenCalledWith({ id: "a-1", patch: { state: "resolved" } }); }); + it("renders the expanded thread message body as markdown (not raw text)", () => { + // Aye's replies are markdown: bold + a list must render as real elements so + // a structured answer reads as prose, not an undifferentiated wall. + const ann = makeAnnotation({ + author_type: "agent", + author_user_id: "", + messages: [ + { + id: "m-1", + annotation_id: "a-1", + author_type: "agent", + author_user_id: "", + body: "**Key risk:** rollback.\n\n1. First point\n2. Second point", + created_at: "2026-01-01T00:00:00Z", + }, + ], + }); + const { container } = renderPanel({ anchored: [anchored(ann)], activeId: "a-1" }); + // Bold renders as , the list as
  • — proof the markdown renderer + // ran instead of dumping the raw string. + expect(container.querySelector("strong")).toHaveTextContent("Key risk:"); + expect(container.querySelectorAll("li").length).toBe(2); + }); + + it("labels the agent sender as Aye on an agent-authored thread message", () => { + const ann = makeAnnotation({ + author_type: "agent", + author_user_id: "", + messages: [ + { + id: "m-1", + annotation_id: "a-1", + author_type: "agent", + author_user_id: "", + body: "Consider rollback.", + created_at: "2026-01-01T00:00:00Z", + }, + ], + }); + renderPanel({ anchored: [anchored(ann)], activeId: "a-1" }); + expect(screen.getByText(enDrafts.turn.agent_name)).toBeInTheDocument(); + }); + + it("labels a human sender by their resolved member name", () => { + // makeAnnotation's default message is authored by member u-1 → "Ada + // Lovelace" per the useActorName mock. + const ann = makeAnnotation(); + renderPanel({ anchored: [anchored(ann)], activeId: "a-1" }); + expect(screen.getByText("Ada Lovelace")).toBeInTheDocument(); + }); + it("adds a reply from the expanded thread", () => { const ann = makeAnnotation(); renderPanel({ anchored: [anchored(ann)], activeId: "a-1" }); diff --git a/packages/views/drafts/annotations/annotation-thread-panel.tsx b/packages/views/drafts/annotations/annotation-thread-panel.tsx index bff36427f1a..5688d988368 100644 --- a/packages/views/drafts/annotations/annotation-thread-panel.tsx +++ b/packages/views/drafts/annotations/annotation-thread-panel.tsx @@ -10,9 +10,11 @@ import { Highlighter, CircleAlert, Trash2, + Bot, } from "lucide-react"; import type { DraftAnnotation, + DraftAnnotationMessage, DraftAnnotationType, DraftAnnotationState, } from "@multica/core/types"; @@ -21,10 +23,13 @@ import { useUpdateDraftAnnotation, useDeleteDraftAnnotation, } from "@multica/core/drafts"; +import { useActorName } from "@multica/core/workspace/hooks"; import { Button } from "@multica/ui/components/ui/button"; import { Badge } from "@multica/ui/components/ui/badge"; import { Textarea } from "@multica/ui/components/ui/textarea"; +import { ActorAvatar } from "@multica/ui/components/common/actor-avatar"; import { cn } from "@multica/ui/lib/utils"; +import { Markdown } from "../../common/markdown"; import { useT, useTimeAgo } from "../../i18n"; import type { AnchoredAnnotation } from "./use-annotation-anchoring"; @@ -74,7 +79,7 @@ export function AnnotationThreadPanel({ const visible = anchored.filter((a) => a.status !== "orphaned"); return ( -
    +
    {t(($) => $.annotations.panel_title)} {openCount > 0 && ( @@ -129,7 +134,6 @@ interface AnnotationCardProps { function AnnotationCard({ wsId, draftId, anchored, active, onSelect }: AnnotationCardProps) { const { t } = useT("drafts"); - const timeAgo = useTimeAgo(); const { annotation, flaggedChanged } = anchored; const Icon = typeIcon(annotation.type); @@ -166,7 +170,10 @@ function AnnotationCard({ wsId, draftId, anchored, active, onSelect }: Annotatio

    “{annotation.quote || t(($) => $.annotations.no_quote)}”

    - {annotation.messages[0] && ( + {/* Collapsed preview: a single plain-text line so the card stays + compact. The full markdown body renders in the expanded thread + below. */} + {!active && annotation.messages[0] && (

    {annotation.messages[0].body}

    )} {flaggedChanged && ( @@ -179,13 +186,17 @@ function AnnotationCard({ wsId, draftId, anchored, active, onSelect }: Annotatio {active && ( -
    - {annotation.messages.slice(1).map((m) => ( -
    -

    {m.body}

    -

    {timeAgo(m.created_at)}

    +
    + {/* The full thread: every message (including the opener) renders as a + conversation row — sender + relative timestamp + markdown body — + so a structured Aye reply reads as prose, not a cramped wall. */} + {annotation.messages.length > 0 && ( +
    + {annotation.messages.map((m) => ( + + ))}
    - ))} + )} {annotation.type !== "highlight" && (
    @@ -248,6 +259,92 @@ function AnnotationCard({ wsId, draftId, anchored, active, onSelect }: Annotatio ); } +/** + * One message in an expanded annotation thread. Renders the sender (Aye vs the + * authoring member), a relative timestamp, and the body as markdown — so a + * structured multi-point reply reads as prose rather than a raw wall of text. + * + * Aye (author_type "agent") is styled distinctly: a robot affordance and a + * primary-accent avatar/name, mirroring how agent assignees are set apart + * elsewhere. We avoid resolving the agent by id (draft-annotation agents carry + * no author_user_id) and label Aye from the shared `turn.agent_name` string. + */ +function ThreadMessage({ message }: { message: DraftAnnotationMessage }) { + const { t } = useT("drafts"); + const timeAgo = useTimeAgo(); + const { getActorName } = useActorName(); + + const isAgent = message.author_type === "agent"; + // Members are resolved by id; "user" is the annotation author-type spelling + // of the workspace "member" actor. Fall back to a neutral "you" label when + // the member isn't in the cache (own message, just-invited author, drift). + const resolvedName = isAgent + ? t(($) => $.turn.agent_name) + : (() => { + const name = getActorName("member", message.author_user_id); + return name === "Unknown" ? t(($) => $.annotations.you) : name; + })(); + + return ( +
    + {isAgent ? ( + + ) : ( + + )} +
    +
    + + {resolvedName} + + {isAgent && ( + + )} + + {timeAgo(message.created_at)} + +
    + {/* Reuse the app's shared markdown renderer in its chat-message mode so + bold/lists/headings/code render with tasteful, token-driven + typography — consistent with how channel + chat messages render. */} + + {message.body} + +
    +
    + ); +} + +/** Up-to-two-letter initials from a display name, for the member avatar. */ +function initialsOf(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); +} + interface OrphanedTrayProps { wsId: string; draftId: string; diff --git a/packages/views/locales/en/drafts.json b/packages/views/locales/en/drafts.json index 3a39468262a..7597f82a3ff 100644 --- a/packages/views/locales/en/drafts.json +++ b/packages/views/locales/en/drafts.json @@ -35,6 +35,7 @@ }, "annotations": { "panel_title": "Annotations", + "you": "You", "toolbar_label": "Annotate selection", "open_count": "Open annotations", "empty": "Select text in the draft to add a comment, suggestion, or highlight.", diff --git a/packages/views/locales/zh-Hans/drafts.json b/packages/views/locales/zh-Hans/drafts.json index f7cfee6101a..ed1f26b41a3 100644 --- a/packages/views/locales/zh-Hans/drafts.json +++ b/packages/views/locales/zh-Hans/drafts.json @@ -35,6 +35,7 @@ }, "annotations": { "panel_title": "批注", + "you": "你", "toolbar_label": "批注所选内容", "open_count": "未处理的批注", "empty": "在 draft 中选中文本即可添加评论、建议或高亮。",