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
69 changes: 67 additions & 2 deletions packages/views/drafts/annotations/annotation-thread-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): DraftAnnotation {
Expand Down Expand Up @@ -65,7 +79,7 @@ function anchored(annotation: DraftAnnotation, over: Partial<AnchoredAnnotation>

function renderPanel(props: Partial<React.ComponentProps<typeof AnnotationThreadPanel>> = {}) {
const onSelect = props.onSelect ?? vi.fn();
render(
const utils = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<AnnotationThreadPanel
wsId="ws-1"
Expand All @@ -77,7 +91,7 @@ function renderPanel(props: Partial<React.ComponentProps<typeof AnnotationThread
/>
</I18nProvider>,
);
return { onSelect };
return { onSelect, container: utils.container };
}

beforeEach(() => vi.clearAllMocks());
Expand Down Expand Up @@ -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 <strong>, the list as <li> — 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" });
Expand Down
115 changes: 106 additions & 9 deletions packages/views/drafts/annotations/annotation-thread-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
Highlighter,
CircleAlert,
Trash2,
Bot,
} from "lucide-react";
import type {
DraftAnnotation,
DraftAnnotationMessage,
DraftAnnotationType,
DraftAnnotationState,
} from "@multica/core/types";
Expand All @@ -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";

Expand Down Expand Up @@ -74,7 +79,7 @@ export function AnnotationThreadPanel({
const visible = anchored.filter((a) => a.status !== "orphaned");

return (
<div className="flex h-full w-80 flex-col border-l">
<div className="flex h-full w-96 flex-col border-l">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-sm font-medium">{t(($) => $.annotations.panel_title)}</span>
{openCount > 0 && (
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -166,7 +170,10 @@ function AnnotationCard({ wsId, draftId, anchored, active, onSelect }: Annotatio
<p className="line-clamp-1 text-xs text-muted-foreground">
“{annotation.quote || t(($) => $.annotations.no_quote)}”
</p>
{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] && (
<p className="mt-0.5 line-clamp-2 text-sm">{annotation.messages[0].body}</p>
)}
{flaggedChanged && (
Expand All @@ -179,13 +186,17 @@ function AnnotationCard({ wsId, draftId, anchored, active, onSelect }: Annotatio
</button>

{active && (
<div className="mt-2 space-y-2 pl-6">
{annotation.messages.slice(1).map((m) => (
<div key={m.id} className="rounded bg-muted/50 px-2 py-1.5">
<p className="text-sm">{m.body}</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">{timeAgo(m.created_at)}</p>
<div className="mt-3 space-y-3">
{/* 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 && (
<div className="space-y-3">
{annotation.messages.map((m) => (
<ThreadMessage key={m.id} message={m} />
))}
</div>
))}
)}

{annotation.type !== "highlight" && (
<div className="space-y-1.5">
Expand Down Expand Up @@ -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 (
<div className="flex gap-2.5">
{isAgent ? (
<ActorAvatar
name={resolvedName}
initials="A"
isAgent
size={24}
className="mt-0.5 bg-primary/10 text-primary"
/>
) : (
<ActorAvatar
name={resolvedName}
initials={initialsOf(resolvedName)}
size={24}
className="mt-0.5"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span
className={cn(
"text-xs font-medium",
isAgent ? "text-primary" : "text-foreground",
)}
>
{resolvedName}
</span>
{isAgent && (
<Bot className="h-3 w-3 shrink-0 text-primary" aria-hidden />
)}
<span className="text-[11px] text-muted-foreground">
{timeAgo(message.created_at)}
</span>
</div>
{/* 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. */}
<Markdown
mode="minimal"
className="mt-1 text-sm leading-relaxed text-foreground"
>
{message.body}
</Markdown>
</div>
</div>
);
}

/** 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;
Expand Down
1 change: 1 addition & 0 deletions packages/views/locales/en/drafts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions packages/views/locales/zh-Hans/drafts.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"annotations": {
"panel_title": "批注",
"you": "你",
"toolbar_label": "批注所选内容",
"open_count": "未处理的批注",
"empty": "在 draft 中选中文本即可添加评论、建议或高亮。",
Expand Down
Loading