Unverified 提交 ed87736d authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Plan annotator (#3043)

close #2989 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3043" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 347451d9
......@@ -9,6 +9,63 @@ import { Timeout } from "../../constants";
export class PreviewPanel {
constructor(public page: Page) {}
getPlanContent() {
return this.page.getByTestId("plan-content");
}
getPlanSelectionCommentButton() {
return this.page.getByRole("button", { name: "Add comment" });
}
getPlanCommentsButton() {
return this.page.getByRole("button", { name: "View comments" });
}
getPlanAnnotationMarks() {
return this.page.locator("mark[data-annotation-id]");
}
async selectTextInPlan(selectedText: string) {
const planContent = this.getPlanContent();
await expect(planContent).toBeVisible({ timeout: Timeout.MEDIUM });
await planContent.evaluate((container, text) => {
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
(node.textContent ?? "").trim().length > 0
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT,
},
);
let currentNode: Text | null;
while ((currentNode = walker.nextNode() as Text | null)) {
const startOffset = currentNode.textContent?.indexOf(text) ?? -1;
if (startOffset === -1) {
continue;
}
const range = document.createRange();
range.setStart(currentNode, startOffset);
range.setEnd(currentNode, startOffset + text.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
(currentNode.parentElement ?? container).dispatchEvent(
new MouseEvent("mouseup", { bubbles: true }),
);
return;
}
throw new Error(`Could not find "${text}" in plan content`);
}, selectedText);
}
async selectPreviewMode(
mode:
| "code"
......
......@@ -88,3 +88,116 @@ testSkipIfWindows("plan mode - questionnaire flow", async ({ po }) => {
// Snapshot the messages
await po.snapshotMessages();
});
testSkipIfWindows(
"plan mode - add and review plan annotations",
async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.chatActions.selectChatMode("plan");
await po.sendPrompt("tc=local-agent/accept-plan");
await expect(
po.page.getByRole("button", { name: "Accept Plan" }),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
await po.previewPanel.selectTextInPlan("Step two");
const addCommentButton = po.previewPanel.getPlanSelectionCommentButton();
await expect(addCommentButton).toBeVisible({ timeout: Timeout.MEDIUM });
await addCommentButton.click();
await expect(po.page.getByRole("button", { name: "Cancel" })).toBeVisible();
await po.page.getByRole("button", { name: "Cancel" }).click();
await expect(po.page.getByPlaceholder("Add your comment...")).toBeHidden();
await expect(addCommentButton).toBeVisible({ timeout: Timeout.MEDIUM });
await addCommentButton.click();
await po.page
.getByPlaceholder("Add your comment...")
.fill("Add more detail for step two.");
await po.previewPanel.getPlanContent().evaluate((container) => {
let scrollParent: HTMLElement | null = container.parentElement;
while (scrollParent) {
const { overflowY } = window.getComputedStyle(scrollParent);
const isScrollable =
overflowY === "auto" ||
overflowY === "scroll" ||
overflowY === "overlay";
if (isScrollable) {
scrollParent.scrollTop += 48;
scrollParent.dispatchEvent(new Event("scroll"));
return;
}
scrollParent = scrollParent.parentElement;
}
throw new Error("Could not find a scrollable plan container");
});
await expect(po.page.getByPlaceholder("Add your comment...")).toHaveValue(
"Add more detail for step two.",
);
await po.page.getByRole("button", { name: "Add Comment" }).click();
const commentsButton = po.previewPanel.getPlanCommentsButton();
await expect(commentsButton).toBeVisible({ timeout: Timeout.MEDIUM });
await expect(po.previewPanel.getPlanAnnotationMarks()).toHaveCount(1);
await expect(
po.previewPanel.getPlanAnnotationMarks().first(),
).toContainText("Step two");
await expect(
po.previewPanel.getPlanAnnotationMarks().first(),
).toHaveAttribute("role", "button");
await commentsButton.click();
await expect(po.page.getByText("Comments (1)")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await expect(
po.page.getByText("Add more detail for step two."),
).toBeVisible();
await commentsButton.click();
await expect(po.page.getByText("Comments (1)")).toBeHidden();
await po.previewPanel.getPlanAnnotationMarks().first().press("Enter");
const commentDialog = po.page.getByRole("dialog", {
name: "Comment on selected text",
});
await expect(commentDialog).toBeVisible({ timeout: Timeout.MEDIUM });
await expect(
po.page.getByRole("button", { name: "Edit comment" }),
).toBeVisible({ timeout: Timeout.MEDIUM });
await expect(
po.page.getByRole("button", { name: "Edit comment" }),
).toBeFocused();
await expect(
po.page.getByText("Add more detail for step two."),
).toBeVisible();
// Close the comment dialog and send the annotations
await po.page.keyboard.press("Escape");
await expect(commentDialog).toBeHidden();
await commentsButton.click();
await expect(po.page.getByText("Comments (1)")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await po.page.getByRole("button", { name: "Send Comments" }).click();
// Wait for annotations to be cleared (indicates send succeeded)
await expect(po.previewPanel.getPlanAnnotationMarks()).toHaveCount(0, {
timeout: Timeout.MEDIUM,
});
// Verify the request sent to the server contains the correctly formatted comments
await po.snapshotServerDump("last-message");
},
);
===
role: user
message: I have the following comments on the plan:
**Comment 1:**
> Step two
Add more detail for step two.
Please update the plan based on these comments.
\ No newline at end of file
import { beforeEach, describe, expect, it } from "vitest";
import type { PlanAnnotation } from "@/atoms/planAtoms";
import {
applyPlanAnnotationHighlights,
clearPlanAnnotationHighlights,
getPlanSelectionSnapshot,
} from "@/components/preview_panel/plan/planAnnotationDom";
function createAnnotation(overrides: Partial<PlanAnnotation>): PlanAnnotation {
return {
id: "annotation-1",
chatId: 1,
selectedText: "",
comment: "comment",
createdAt: 1,
startOffset: 0,
selectionLength: 0,
...overrides,
};
}
describe("planAnnotationDom", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("computes selection offsets from rendered plan text while ignoring plan UI chrome", () => {
const container = document.createElement("div");
container.innerHTML =
"<p>Intro</p><div data-plan-annotation-ignore>Copy</div><p> Hello world </p>";
document.body.appendChild(container);
const textNode = container.querySelectorAll("p")[1]?.firstChild;
expect(textNode).not.toBeNull();
const range = document.createRange();
range.setStart(textNode!, 0);
range.setEnd(textNode!, textNode!.textContent?.length ?? 0);
expect(getPlanSelectionSnapshot(container, range)).toEqual({
selectedText: "Hello world",
startOffset: 7,
selectionLength: 11,
});
});
it("highlights text that spans multiple inline nodes", () => {
const container = document.createElement("div");
container.innerHTML = `<p>Hello <strong>bold</strong> world</p>`;
document.body.appendChild(container);
applyPlanAnnotationHighlights(container, [
createAnnotation({
id: "annotation-1",
selectedText: "bold world",
startOffset: 6,
selectionLength: 10,
}),
]);
const marks = [
...container.querySelectorAll<HTMLElement>(
'mark[data-annotation-id="annotation-1"]',
),
];
expect(marks).toHaveLength(2);
expect(marks.map((mark) => mark.textContent).join("")).toBe("bold world");
expect(marks[0]?.getAttribute("role")).toBe("button");
expect(marks[0]?.getAttribute("tabindex")).toBe("0");
expect(marks[0]?.getAttribute("aria-haspopup")).toBe("dialog");
expect(marks[0]?.getAttribute("aria-label")).toBe(
"View comment for bold world",
);
clearPlanAnnotationHighlights(container);
expect(container.querySelectorAll("mark[data-annotation-id]")).toHaveLength(
0,
);
expect(container.textContent).toBe("Hello bold world");
});
it("skips stale or overlapping annotations instead of corrupting the DOM", () => {
const container = document.createElement("div");
container.innerHTML = `<p>Hello brave new world</p>`;
document.body.appendChild(container);
applyPlanAnnotationHighlights(container, [
createAnnotation({
id: "valid",
selectedText: "brave",
startOffset: 6,
selectionLength: 5,
}),
createAnnotation({
id: "stale",
selectedText: "planet",
startOffset: 12,
selectionLength: 6,
}),
createAnnotation({
id: "overlap",
selectedText: "ave new",
startOffset: 8,
selectionLength: 7,
}),
]);
const marks = [
...container.querySelectorAll<HTMLElement>("mark[data-annotation-id]"),
];
expect(marks).toHaveLength(1);
expect(marks[0]?.getAttribute("data-annotation-id")).toBe("valid");
expect(marks[0]?.textContent).toBe("brave");
});
});
import { describe, expect, it } from "vitest";
import { getSelectionCommentAnchorRect } from "@/components/preview_panel/plan/selectionCommentButtonPosition";
describe("getSelectionCommentAnchorRect", () => {
it("anchors multi-line selections to the last client rect", () => {
const firstRect = new DOMRect(10, 20, 100, 18);
const lastRect = new DOMRect(12, 44, 60, 18);
const fallbackRect = new DOMRect(10, 20, 140, 42);
const range = {
getClientRects: () =>
[firstRect, lastRect] as unknown as DOMRectList | DOMRect[],
getBoundingClientRect: () => fallbackRect,
};
expect(getSelectionCommentAnchorRect(range)).toBe(lastRect);
});
it("falls back to the bounding rect when client rects are empty", () => {
const fallbackRect = new DOMRect(8, 16, 120, 32);
const range = {
getClientRects: () => [] as unknown as DOMRectList | DOMRect[],
getBoundingClientRect: () => fallbackRect,
};
expect(getSelectionCommentAnchorRect(range)).toBe(fallbackRect);
});
});
......@@ -32,6 +32,72 @@ export const pendingQuestionnaireAtom = atom<
Map<number, PlanQuestionnairePayload>
>(new Map());
export interface PlanAnnotation {
id: string;
chatId: number;
selectedText: string;
comment: string;
createdAt: number;
/** Character offset from the rendered plan text, excluding annotation UI chrome */
startOffset: number;
/** Length of the selected text in characters */
selectionLength: number;
}
export const planAnnotationsAtom = atom<Map<number, PlanAnnotation[]>>(
new Map(),
);
type AnnotationsMap = Map<number, PlanAnnotation[]>;
export function addPlanAnnotation(
prev: AnnotationsMap,
chatId: number,
annotation: PlanAnnotation,
): AnnotationsMap {
const next = new Map(prev);
const list = next.get(chatId) ?? [];
next.set(chatId, [...list, annotation]);
return next;
}
export function updatePlanAnnotation(
prev: AnnotationsMap,
chatId: number,
annotationId: string,
comment: string,
): AnnotationsMap {
const next = new Map(prev);
const list = (next.get(chatId) ?? []).map((a) =>
a.id === annotationId ? { ...a, comment } : a,
);
next.set(chatId, list);
return next;
}
export function removePlanAnnotation(
prev: AnnotationsMap,
chatId: number,
annotationId: string,
): AnnotationsMap {
const next = new Map(prev);
const list = next.get(chatId) ?? [];
next.set(
chatId,
list.filter((a) => a.id !== annotationId),
);
return next;
}
export function clearPlanAnnotations(
prev: AnnotationsMap,
chatId: number,
): AnnotationsMap {
const next = new Map(prev);
next.delete(chatId);
return next;
}
// Transient flag: chatIds that just had a questionnaire submitted (for brief confirmation)
// "visible" = showing, "fading" = fade-out in progress
export const questionnaireSubmittedChatIdsAtom = atom<
......
......@@ -6,6 +6,7 @@ import ShikiHighlighter, {
} from "react-shiki/core";
import type { Element as HastElement } from "hast";
import { useTheme } from "../../contexts/ThemeContext";
import { PLAN_ANNOTATION_IGNORE_ATTRIBUTE } from "../preview_panel/plan/planAnnotationDom";
import { Copy, Check } from "lucide-react";
import github from "@shikijs/themes/github-light-default";
import githubDark from "@shikijs/themes/github-dark-default";
......@@ -102,7 +103,10 @@ export const CodeHighlight = memo(
[&_pre]:rounded-lg [&_pre]:px-6 [&_pre]:py-7"
>
{code && (
<div className="absolute top-2 left-0 right-0 px-6 text-xs z-10 flex items-center justify-between">
<div
{...{ [PLAN_ANNOTATION_IGNORE_ATTRIBUTE]: true }}
className="absolute top-2 left-0 right-0 px-6 text-xs z-10 flex items-center justify-between"
>
{language && (
<span className="tracking-tighter text-muted-foreground/85 truncate min-w-0">
{language}
......
import React, { useEffect, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useAtomValue, useSetAtom } from "jotai";
import { Button } from "@/components/ui/button";
import { Check, FileText } from "lucide-react";
import { VanillaMarkdownParser } from "@/components/chat/DyadMarkdownParser";
import { planStateAtom } from "@/atoms/planAtoms";
import {
clearPlanAnnotations,
planAnnotationsAtom,
planStateAtom,
} from "@/atoms/planAtoms";
import { previewModeAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import { usePlan } from "@/hooks/usePlan";
import { useSettings } from "@/hooks/useSettings";
import { SelectionCommentButton } from "./plan/SelectionCommentButton";
import { CommentsFloatingButton } from "./plan/CommentsFloatingButton";
import { CommentPopover } from "./plan/CommentPopover";
import {
applyPlanAnnotationHighlights,
clearPlanAnnotationHighlights,
} from "./plan/planAnnotationDom";
export const PlanPanel: React.FC = () => {
const chatId = useAtomValue(selectedChatIdAtom);
......@@ -19,6 +36,10 @@ export const PlanPanel: React.FC = () => {
const { savedPlan } = usePlan();
const { settings } = useSettings();
const annotations = useAtomValue(planAnnotationsAtom);
const planContentRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const planData = chatId ? planState.plansByChatId.get(chatId) : null;
const currentPlan = planData?.content ?? null;
const currentTitle = planData?.title ?? null;
......@@ -34,7 +55,102 @@ export const PlanPanel: React.FC = () => {
}
}, [currentPlan, previewMode, setPreviewMode]);
const setAnnotations = useSetAtom(planAnnotationsAtom);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSendingComments, setIsSendingComments] = useState(false);
const chatAnnotations = useMemo(
() => (chatId ? (annotations.get(chatId) ?? []) : []),
[chatId, annotations],
);
// Highlight annotated text in the plan content
useEffect(() => {
const container = planContentRef.current;
if (!container) return;
if (chatAnnotations.length === 0) {
clearPlanAnnotationHighlights(container);
return;
}
let frameId: number | null = null;
let isApplyingHighlights = false;
const observer = new MutationObserver(() => {
if (isApplyingHighlights) {
return;
}
scheduleHighlightRefresh();
});
const refreshHighlights = () => {
observer.disconnect();
isApplyingHighlights = true;
try {
clearPlanAnnotationHighlights(container);
applyPlanAnnotationHighlights(container, chatAnnotations);
} finally {
isApplyingHighlights = false;
observer.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
}
};
const scheduleHighlightRefresh = () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
frameId = requestAnimationFrame(() => {
frameId = null;
refreshHighlights();
});
};
scheduleHighlightRefresh();
observer.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
clearPlanAnnotationHighlights(container);
};
}, [chatAnnotations, currentPlan]);
const handleSendComments = useCallback(() => {
if (!chatId || isSendingComments) return;
const currentAnnotations = annotations.get(chatId) ?? [];
if (currentAnnotations.length === 0) return;
const prompt = currentAnnotations
.map(
(a, i) => `**Comment ${i + 1}:**\n> ${a.selectedText}\n\n${a.comment}`,
)
.join("\n\n---\n\n");
setIsSendingComments(true);
streamMessage({
chatId,
prompt: `I have the following comments on the plan:\n\n${prompt}\n\nPlease update the plan based on these comments.`,
onSettled: ({ success }) => {
if (success) {
setAnnotations((prev) => clearPlanAnnotations(prev, chatId));
}
setIsSendingComments(false);
},
});
}, [chatId, isSendingComments, annotations, streamMessage, setAnnotations]);
const handleAccept = () => {
if (!chatId) return;
......@@ -56,7 +172,19 @@ export const PlanPanel: React.FC = () => {
return (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-hidden">
<div
className="relative h-full overflow-y-auto p-4"
ref={scrollContainerRef}
>
{chatId && (
<CommentsFloatingButton
chatId={chatId}
annotations={chatAnnotations}
onSendComments={handleSendComments}
isSending={isSendingComments}
/>
)}
<div className="border rounded-lg bg-card">
<div className="px-4 py-3 border-b">
<div className="flex items-center gap-2">
......@@ -72,12 +200,34 @@ export const PlanPanel: React.FC = () => {
)}
</div>
<div className="p-4">
<div className="prose dark:prose-invert prose-sm max-w-none">
<div
ref={planContentRef}
data-testid="plan-content"
className="prose dark:prose-invert prose-sm max-w-none"
>
<VanillaMarkdownParser content={currentPlan} />
</div>
</div>
</div>
</div>
</div>
{chatId && (
<>
<SelectionCommentButton
key={chatId}
containerRef={planContentRef}
scrollRef={scrollContainerRef}
chatId={chatId}
chatAnnotations={chatAnnotations}
/>
<CommentPopover
containerRef={planContentRef}
scrollRef={scrollContainerRef}
chatId={chatId}
annotations={chatAnnotations}
/>
</>
)}
<div className="border-t p-4 space-y-4 bg-background">
{isAccepted || isSavedPlan ? (
......
import React, { useEffect, useState } from "react";
import { useSetAtom } from "jotai";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
planAnnotationsAtom,
removePlanAnnotation,
updatePlanAnnotation,
type PlanAnnotation,
} from "@/atoms/planAtoms";
interface CommentCardProps {
annotation: PlanAnnotation;
chatId: number;
}
export const CommentCard: React.FC<CommentCardProps> = ({
annotation,
chatId,
}) => {
const setAnnotations = useSetAtom(planAnnotationsAtom);
const [isEditing, setIsEditing] = useState(false);
const [editedText, setEditedText] = useState(annotation.comment);
useEffect(() => {
if (!isEditing) {
setEditedText(annotation.comment);
}
}, [annotation.comment, isEditing]);
const handleDelete = () => {
setAnnotations((prev) => removePlanAnnotation(prev, chatId, annotation.id));
};
const handleSave = () => {
if (!editedText.trim()) return;
setAnnotations((prev) =>
updatePlanAnnotation(prev, chatId, annotation.id, editedText.trim()),
);
setIsEditing(false);
};
const handleCancel = () => {
setEditedText(annotation.comment);
setIsEditing(false);
};
return (
<div className="rounded-lg border bg-card p-3 space-y-2">
<div className="flex items-start justify-between">
<blockquote className="text-xs text-muted-foreground border-l-2 border-muted-foreground/30 pl-2 italic line-clamp-3 flex-1">
{annotation.selectedText}
</blockquote>
<div className="flex gap-1 ml-2 shrink-0">
<button
type="button"
onClick={() => setIsEditing(true)}
aria-label="Edit comment"
className="p-1 rounded hover:bg-muted"
>
<Pencil size={12} className="text-muted-foreground" />
</button>
<button
type="button"
onClick={handleDelete}
aria-label="Delete comment"
className="p-1 rounded hover:bg-muted"
>
<Trash2 size={12} className="text-muted-foreground" />
</button>
</div>
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
autoFocus
value={editedText}
onChange={(e) => setEditedText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSave();
if (e.key === "Escape") handleCancel();
}}
className="w-full text-sm min-h-[60px] rounded-md border bg-background px-2 py-1.5 resize-none focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!editedText.trim()}
>
Save
</Button>
</div>
</div>
) : (
<p className="text-sm">{annotation.comment}</p>
)}
</div>
);
};
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { CommentCard } from "./CommentCard";
import type { PlanAnnotation } from "@/atoms/planAtoms";
import {
ANNOTATION_ID_ATTRIBUTE,
ANNOTATION_MARK_SELECTOR,
} from "./planAnnotationDom";
interface PopoverState {
annotationId: string;
anchorX: number;
anchorY: number;
}
interface CommentPopoverProps {
containerRef: React.RefObject<HTMLDivElement | null>;
scrollRef?: React.RefObject<HTMLDivElement | null>;
chatId: number;
annotations: PlanAnnotation[];
}
export const CommentPopover: React.FC<CommentPopoverProps> = ({
containerRef,
scrollRef,
chatId,
annotations,
}) => {
const [popover, setPopover] = useState<PopoverState | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const dismiss = useCallback(
({ restoreFocus = false }: { restoreFocus?: boolean } = {}) => {
setPopover(null);
if (restoreFocus) {
const trigger = triggerRef.current;
if (trigger?.isConnected) {
requestAnimationFrame(() => {
trigger.focus();
});
}
}
},
[],
);
const openPopoverForMark = useCallback((mark: HTMLElement) => {
const annotationId = mark.getAttribute(ANNOTATION_ID_ATTRIBUTE);
if (!annotationId) return;
const rect = mark.getBoundingClientRect();
triggerRef.current = mark;
setPopover({
annotationId,
anchorX: rect.right + 8,
anchorY: rect.top,
});
}, []);
// Listen for clicks on highlighted marks
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleClick = (e: MouseEvent) => {
const target = e.target instanceof HTMLElement ? e.target : null;
const mark = target?.closest(ANNOTATION_MARK_SELECTOR) as HTMLElement;
if (!mark) return;
openPopoverForMark(mark);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
const target = e.target instanceof HTMLElement ? e.target : null;
const mark = target?.closest(ANNOTATION_MARK_SELECTOR) as HTMLElement;
if (!mark) return;
e.preventDefault();
openPopoverForMark(mark);
};
container.addEventListener("click", handleClick);
container.addEventListener("keydown", handleKeyDown);
return () => {
container.removeEventListener("click", handleClick);
container.removeEventListener("keydown", handleKeyDown);
};
}, [containerRef, openPopoverForMark]);
// Dismiss on click outside or Escape
useEffect(() => {
if (!popover) return;
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (popoverRef.current?.contains(target)) return;
// Don't dismiss if clicking another mark (the click handler above will update)
const el = e.target as HTMLElement;
if (el.closest?.(ANNOTATION_MARK_SELECTOR)) return;
dismiss();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") dismiss({ restoreFocus: true });
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [popover, dismiss]);
// Reposition on scroll; dismiss if anchor mark leaves viewport
useEffect(() => {
const scrollEl = scrollRef?.current;
if (!scrollEl || !popover) return;
const handleScroll = () => {
const mark = containerRef.current?.querySelector<HTMLElement>(
`mark[${ANNOTATION_ID_ATTRIBUTE}="${popover.annotationId}"]`,
);
if (!mark) {
dismiss();
return;
}
const rect = mark.getBoundingClientRect();
const scrollRect = scrollEl.getBoundingClientRect();
// Dismiss if the mark has scrolled completely out of the visible area
if (rect.bottom < scrollRect.top || rect.top > scrollRect.bottom) {
dismiss();
return;
}
setPopover((current) => {
if (!current || current.annotationId !== popover.annotationId)
return current;
return {
...current,
anchorX: rect.right + 8,
anchorY: rect.top,
};
});
};
scrollEl.addEventListener("scroll", handleScroll);
return () => scrollEl.removeEventListener("scroll", handleScroll);
}, [scrollRef, containerRef, popover?.annotationId, dismiss]);
// Dismiss when annotations change (e.g., deleted)
useEffect(() => {
if (popover && !annotations.find((a) => a.id === popover.annotationId)) {
dismiss();
}
}, [annotations, popover, dismiss]);
useLayoutEffect(() => {
if (!popover || !popoverRef.current) return;
const clampPosition = () => {
const popoverElement = popoverRef.current;
if (!popoverElement) return;
const rect = popoverElement.getBoundingClientRect();
const margin = 8;
const maxX = Math.max(margin, window.innerWidth - rect.width - margin);
const maxY = Math.max(margin, window.innerHeight - rect.height - margin);
const clampedX = Math.max(margin, Math.min(popover.anchorX, maxX));
const clampedY = Math.max(margin, Math.min(popover.anchorY, maxY));
popoverElement.style.left = `${clampedX}px`;
popoverElement.style.top = `${clampedY}px`;
};
clampPosition();
window.addEventListener("resize", clampPosition);
return () => window.removeEventListener("resize", clampPosition);
}, [popover?.annotationId, popover?.anchorX, popover?.anchorY]);
useEffect(() => {
if (!popover || !popoverRef.current) return;
const firstFocusable = popoverRef.current.querySelector<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
(firstFocusable ?? popoverRef.current).focus();
}, [popover?.annotationId]);
if (!popover) return null;
const annotation = annotations.find((a) => a.id === popover.annotationId);
if (!annotation) return null;
return (
<div
ref={popoverRef}
role="dialog"
aria-label="Comment on selected text"
tabIndex={-1}
style={{
position: "fixed",
left: popover.anchorX,
top: popover.anchorY,
zIndex: 50,
}}
className="w-72 rounded-lg border bg-popover shadow-lg"
>
<CommentCard annotation={annotation} chatId={chatId} />
</div>
);
};
import React from "react";
import { MessageSquare, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { CommentCard } from "./CommentCard";
import type { PlanAnnotation } from "@/atoms/planAtoms";
interface CommentsFloatingButtonProps {
chatId: number;
annotations: PlanAnnotation[];
onSendComments: () => void;
isSending: boolean;
}
export const CommentsFloatingButton: React.FC<CommentsFloatingButtonProps> = ({
chatId,
annotations,
onSendComments,
isSending,
}) => {
if (annotations.length === 0) return null;
return (
<div className="sticky top-3 float-right z-10 mr-1">
<Popover>
<PopoverTrigger
aria-label="View comments"
className="relative rounded-full w-9 h-9 flex items-center justify-center bg-muted/80 text-muted-foreground border shadow-sm hover:bg-muted transition-colors cursor-pointer"
>
<MessageSquare size={16} />
<span className="absolute -top-1.5 -right-1.5 bg-primary text-primary-foreground text-[10px] rounded-full min-w-4 h-4 flex items-center justify-center font-medium px-1">
{annotations.length}
</span>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
sideOffset={8}
className="w-80 p-0"
>
<div className="flex flex-col max-h-[400px]">
<div className="flex items-center gap-2 p-3 border-b">
<MessageSquare size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
Comments ({annotations.length})
</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{annotations.map((annotation) => (
<CommentCard
key={annotation.id}
annotation={annotation}
chatId={chatId}
/>
))}
</div>
<div className="border-t p-3">
<Button
onClick={onSendComments}
disabled={isSending}
className="w-full"
size="sm"
>
<Send size={14} className="mr-2" />
{isSending ? "Sending\u2026" : "Send Comments"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>
);
};
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useSetAtom } from "jotai";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
addPlanAnnotation,
planAnnotationsAtom,
type PlanAnnotation,
} from "@/atoms/planAtoms";
import {
ANNOTATION_MARK_SELECTOR,
getPlanSelectionSnapshot,
hasOverlappingPlanAnnotation,
} from "./planAnnotationDom";
import { getSelectionCommentAnchorRect } from "./selectionCommentButtonPosition";
interface FloatingButtonState {
x: number;
y: number;
selectedText: string;
startOffset: number;
selectionLength: number;
}
interface SelectionCommentButtonProps {
containerRef: React.RefObject<HTMLDivElement | null>;
scrollRef: React.RefObject<HTMLDivElement | null>;
chatId: number;
chatAnnotations: PlanAnnotation[];
}
export const SelectionCommentButton: React.FC<SelectionCommentButtonProps> = ({
containerRef,
scrollRef,
chatId,
chatAnnotations,
}) => {
const setAnnotations = useSetAtom(planAnnotationsAtom);
const [floatingButton, setFloatingButton] =
useState<FloatingButtonState | null>(null);
const [showForm, setShowForm] = useState(false);
const [commentText, setCommentText] = useState("");
const buttonRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLDivElement>(null);
const clearState = useCallback(() => {
setFloatingButton(null);
setShowForm(false);
setCommentText("");
}, []);
const handleCancel = useCallback(() => {
setShowForm(false);
setCommentText("");
}, []);
// Listen for text selection via mouseup and selectionchange
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let rafId: number | null = null;
const processSelection = () => {
const selection = window.getSelection();
if (
!selection ||
selection.rangeCount === 0 ||
selection.toString().trim().length === 0
) {
clearState();
return;
}
// Ensure the selection is within the plan container
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
clearState();
return;
}
const snapshot = getPlanSelectionSnapshot(container, range);
if (!snapshot) {
clearState();
return;
}
if (
hasOverlappingPlanAnnotation(
chatAnnotations,
snapshot.startOffset,
snapshot.selectionLength,
)
) {
clearState();
return;
}
const rect = getSelectionCommentAnchorRect(range);
const formWidth = 288; // w-72
const estimatedFormHeight = 150;
const x = Math.max(
8,
Math.min(rect.right + 4, window.innerWidth - formWidth - 8),
);
const y = Math.max(
8,
Math.min(rect.top - 4, window.innerHeight - estimatedFormHeight - 8),
);
setShowForm(false);
setCommentText("");
setFloatingButton({
x,
y,
selectedText: snapshot.selectedText,
startOffset: snapshot.startOffset,
selectionLength: snapshot.selectionLength,
});
};
const scheduleProcessSelection = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
rafId = null;
processSelection();
});
};
const handleMouseUp = (e: MouseEvent) => {
// Ignore clicks on highlighted annotations (handled by CommentPopover)
const target = e.target instanceof HTMLElement ? e.target : null;
if (target?.closest(ANNOTATION_MARK_SELECTOR)) return;
// Small delay to let the selection finalize
scheduleProcessSelection();
};
let selectionChangeTimer: ReturnType<typeof setTimeout> | null = null;
const handleSelectionChange = () => {
if (selectionChangeTimer !== null) {
clearTimeout(selectionChangeTimer);
}
selectionChangeTimer = setTimeout(() => {
selectionChangeTimer = null;
const selection = window.getSelection();
if (
!selection ||
selection.rangeCount === 0 ||
selection.toString().trim().length === 0
) {
return;
}
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
return;
}
scheduleProcessSelection();
}, 200);
};
container.addEventListener("mouseup", handleMouseUp);
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
container.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("selectionchange", handleSelectionChange);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
if (selectionChangeTimer !== null) {
clearTimeout(selectionChangeTimer);
}
};
}, [chatAnnotations, clearState, containerRef]);
// Hide on scroll
useEffect(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || !floatingButton) return;
const handleScroll = () => {
if (!showForm) {
clearState();
}
};
scrollEl.addEventListener("scroll", handleScroll);
return () => scrollEl.removeEventListener("scroll", handleScroll);
}, [scrollRef, floatingButton, showForm, clearState]);
// Dismiss on click outside or Escape
useEffect(() => {
if (!floatingButton) return;
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (buttonRef.current?.contains(target)) return;
if (formRef.current?.contains(target)) return;
clearState();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") clearState();
};
document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [floatingButton, clearState]);
const handleCommentClick = () => {
setShowForm(true);
};
const handleSubmit = () => {
if (!commentText.trim() || !floatingButton) return;
const annotation = {
id: crypto.randomUUID(),
chatId,
selectedText: floatingButton.selectedText,
comment: commentText.trim(),
createdAt: Date.now(),
startOffset: floatingButton.startOffset,
selectionLength: floatingButton.selectionLength,
};
setAnnotations((prev) => addPlanAnnotation(prev, chatId, annotation));
clearState();
window.getSelection()?.removeAllRanges();
};
if (!floatingButton) return null;
return (
<>
{!showForm && (
<div
ref={buttonRef}
style={{
position: "fixed",
left: floatingButton.x,
top: floatingButton.y,
zIndex: 50,
}}
>
<button
onClick={handleCommentClick}
aria-label="Add comment"
className="p-1.5 rounded-md bg-primary text-primary-foreground shadow-md hover:bg-primary/90 transition-colors"
>
<MessageSquare size={14} />
</button>
</div>
)}
{showForm && (
<div
ref={formRef}
role="dialog"
aria-label="Add comment on selected text"
style={{
position: "fixed",
left: floatingButton.x,
top: floatingButton.y,
zIndex: 50,
}}
className="w-72 rounded-lg border bg-popover p-3 shadow-lg space-y-2"
>
<blockquote className="text-xs text-muted-foreground border-l-2 border-muted-foreground/30 pl-2 italic line-clamp-3">
{floatingButton.selectedText}
</blockquote>
<textarea
autoFocus
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Add your comment..."
className="w-full text-sm min-h-[60px] rounded-md border bg-background px-2 py-1.5 resize-none focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="flex items-center justify-end">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button
size="sm"
onClick={handleSubmit}
disabled={!commentText.trim()}
>
Add Comment
</Button>
</div>
</div>
</div>
)}
</>
);
};
import type { PlanAnnotation } from "@/atoms/planAtoms";
export const PLAN_ANNOTATION_IGNORE_ATTRIBUTE = "data-plan-annotation-ignore";
export const ANNOTATION_ID_ATTRIBUTE = "data-annotation-id";
export const ANNOTATION_MARK_SELECTOR = `mark[${ANNOTATION_ID_ATTRIBUTE}]`;
const PLAN_ANNOTATION_IGNORE_SELECTOR = `[${PLAN_ANNOTATION_IGNORE_ATTRIBUTE}]`;
interface PlanTextSegment {
node: Text;
startOffset: number;
endOffset: number;
}
export interface PlanSelectionSnapshot {
selectedText: string;
startOffset: number;
selectionLength: number;
}
function collectPlanTextSegments(container: HTMLElement): PlanTextSegment[] {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const textNode = node as Text;
const text = textNode.textContent ?? "";
const parent = textNode.parentElement;
if (!parent || text.length === 0) {
return NodeFilter.FILTER_REJECT;
}
if (parent.closest(PLAN_ANNOTATION_IGNORE_SELECTOR)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const segments: PlanTextSegment[] = [];
let currentOffset = 0;
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
const textLength = node.textContent?.length ?? 0;
segments.push({
node,
startOffset: currentOffset,
endOffset: currentOffset + textLength,
});
currentOffset += textLength;
}
return segments;
}
/**
* Maps a DOM selection boundary (node + offset) to a flat character offset
* within the plan's concatenated text content.
*
* Creates a temporary Range from the container start to the boundary point,
* then walks the pre-collected text segments to find which segment contains
* the boundary. This Range-based approach correctly handles boundaries that
* land inside element nodes (not just text nodes) and accounts for ignored
* regions (e.g. annotation marks) that are excluded from the segment list.
*/
function getBoundaryTextOffset(
container: HTMLElement,
boundaryNode: Node,
boundaryOffset: number,
segments: PlanTextSegment[],
): number | null {
if (!container.contains(boundaryNode)) {
return null;
}
const boundaryRange = document.createRange();
boundaryRange.selectNodeContents(container);
try {
boundaryRange.setEnd(boundaryNode, boundaryOffset);
} catch {
return null;
}
let offset = 0;
for (const segment of segments) {
if (!boundaryRange.intersectsNode(segment.node)) {
continue;
}
if (boundaryRange.endContainer === segment.node) {
return segment.startOffset + boundaryRange.endOffset;
}
offset = segment.endOffset;
}
return offset;
}
function readPlanTextFromSegments(
segments: PlanTextSegment[],
startOffset: number,
selectionLength: number,
): string | null {
if (selectionLength <= 0 || startOffset < 0) {
return null;
}
const endOffset = startOffset + selectionLength;
let text = "";
for (const segment of segments) {
if (segment.endOffset <= startOffset) {
continue;
}
if (segment.startOffset >= endOffset) {
break;
}
const startInNode = Math.max(0, startOffset - segment.startOffset);
const endInNode = Math.min(
segment.node.textContent?.length ?? 0,
endOffset - segment.startOffset,
);
if (startInNode >= endInNode) {
continue;
}
text += segment.node.data.slice(startInNode, endInNode);
}
return text.length === selectionLength ? text : null;
}
function highlightAtOffset(
segments: PlanTextSegment[],
startOffset: number,
selectionLength: number,
annotationId: string,
selectedText: string,
) {
if (selectionLength <= 0) {
return;
}
const endOffset = startOffset + selectionLength;
const overlappingSegments = segments.filter(
({ startOffset: segmentStart, endOffset: segmentEnd }) =>
segmentStart < endOffset && segmentEnd > startOffset,
);
// Iterate in reverse so that splitText mutations don't shift offsets
// of earlier (not-yet-processed) segments.
for (let index = overlappingSegments.length - 1; index >= 0; index--) {
const segment = overlappingSegments[index];
const { node: textNode } = segment;
const startInNode = Math.max(0, startOffset - segment.startOffset);
const endInNode = Math.min(
textNode.textContent?.length ?? 0,
endOffset - segment.startOffset,
);
const charsToHighlight = endInNode - startInNode;
if (charsToHighlight <= 0 || !textNode.parentNode) {
continue;
}
const highlightNode = textNode.splitText(startInNode);
highlightNode.splitText(charsToHighlight);
const mark = document.createElement("mark");
const isFirstFragment = index === 0;
mark.setAttribute(ANNOTATION_ID_ATTRIBUTE, annotationId);
if (isFirstFragment) {
const normalizedSelectedText = selectedText.replace(/\s+/g, " ").trim();
mark.setAttribute("role", "button");
mark.setAttribute("tabindex", "0");
mark.setAttribute("aria-haspopup", "dialog");
mark.setAttribute(
"aria-label",
normalizedSelectedText.length === 0
? "View comment"
: `View comment for ${normalizedSelectedText}`,
);
} else {
mark.setAttribute("tabindex", "-1");
mark.setAttribute("aria-hidden", "true");
}
mark.className =
"bg-yellow-400/25 text-inherit cursor-pointer rounded-sm px-0.5 border-b border-yellow-400/50";
mark.textContent = highlightNode.textContent;
const parent = highlightNode.parentNode;
if (!parent) {
continue;
}
parent.replaceChild(mark, highlightNode);
}
}
export function getPlanSelectionSnapshot(
container: HTMLElement,
range: Range,
): PlanSelectionSnapshot | null {
if (range.collapsed || !container.contains(range.commonAncestorContainer)) {
return null;
}
const segments = collectPlanTextSegments(container);
if (segments.length === 0) {
return null;
}
const rawStartOffset = getBoundaryTextOffset(
container,
range.startContainer,
range.startOffset,
segments,
);
const rawEndOffset = getBoundaryTextOffset(
container,
range.endContainer,
range.endOffset,
segments,
);
if (rawStartOffset === null || rawEndOffset === null) {
return null;
}
const rawSelectionLength = rawEndOffset - rawStartOffset;
if (rawSelectionLength <= 0) {
return null;
}
const rawSelectedText = readPlanTextFromSegments(
segments,
rawStartOffset,
rawSelectionLength,
);
if (!rawSelectedText) {
return null;
}
const leadingWhitespace =
rawSelectedText.length - rawSelectedText.trimStart().length;
const trailingWhitespace =
rawSelectedText.length - rawSelectedText.trimEnd().length;
const selectedText = rawSelectedText.trim();
if (selectedText.length === 0) {
return null;
}
return {
selectedText,
startOffset: rawStartOffset + leadingWhitespace,
selectionLength:
rawSelectionLength - leadingWhitespace - trailingWhitespace,
};
}
export function hasOverlappingPlanAnnotation(
annotations: PlanAnnotation[],
startOffset: number,
selectionLength: number,
): boolean {
const endOffset = startOffset + selectionLength;
return annotations.some((annotation) => {
const annotationEnd = annotation.startOffset + annotation.selectionLength;
return startOffset < annotationEnd && annotation.startOffset < endOffset;
});
}
export function clearPlanAnnotationHighlights(container: HTMLElement) {
container.querySelectorAll(ANNOTATION_MARK_SELECTOR).forEach((mark) => {
const parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(document.createTextNode(mark.textContent ?? ""), mark);
parent.normalize();
});
}
export function applyPlanAnnotationHighlights(
container: HTMLElement,
annotations: PlanAnnotation[],
) {
const segments = collectPlanTextSegments(container);
if (segments.length === 0) {
return;
}
const totalTextLength = segments[segments.length - 1]?.endOffset ?? 0;
const renderableAnnotations = [...annotations]
.filter((annotation) => {
if (annotation.selectionLength <= 0 || annotation.startOffset < 0) {
return false;
}
if (
annotation.startOffset + annotation.selectionLength >
totalTextLength
) {
return false;
}
const actualText = readPlanTextFromSegments(
segments,
annotation.startOffset,
annotation.selectionLength,
);
return actualText === annotation.selectedText;
})
.sort(
(left, right) =>
left.startOffset - right.startOffset ||
right.selectionLength - left.selectionLength,
);
const nonOverlappingAnnotations: PlanAnnotation[] = [];
let previousEndOffset = -1;
for (const annotation of renderableAnnotations) {
if (annotation.startOffset < previousEndOffset) {
continue;
}
nonOverlappingAnnotations.push(annotation);
previousEndOffset = annotation.startOffset + annotation.selectionLength;
}
// Iterate in reverse so that DOM mutations from highlightAtOffset don't
// invalidate offsets of earlier (not-yet-processed) annotations.
for (let index = nonOverlappingAnnotations.length - 1; index >= 0; index--) {
const annotation = nonOverlappingAnnotations[index];
highlightAtOffset(
segments,
annotation.startOffset,
annotation.selectionLength,
annotation.id,
annotation.selectedText,
);
}
}
interface SelectionPositionRect {
right: number;
top: number;
width: number;
height: number;
}
interface SelectionPositionRange {
getBoundingClientRect(): SelectionPositionRect;
getClientRects(): ArrayLike<SelectionPositionRect>;
}
export function getSelectionCommentAnchorRect(
range: SelectionPositionRange,
): SelectionPositionRect {
const clientRects = Array.from(range.getClientRects()).filter(
(rect) => rect.width > 0 || rect.height > 0,
);
return clientRects[clientRects.length - 1] ?? range.getBoundingClientRect();
}
......@@ -93,7 +93,7 @@ export function useStreamChat({
redo?: boolean;
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
onSettled?: () => void;
onSettled?: (result: { success: boolean }) => void;
}) => {
if (
(!prompt.trim() && (!attachments || attachments.length === 0)) ||
......@@ -108,7 +108,7 @@ export function useStreamChat({
`[CHAT] Ignoring duplicate stream request for chat ${chatId} - stream already in progress`,
);
// Call onSettled to allow callers to clean up their local loading state
onSettled?.();
onSettled?.({ success: false });
return;
}
......@@ -294,7 +294,7 @@ export function useStreamChat({
refreshApp();
refreshVersions();
invalidateTokenCount();
onSettled?.();
onSettled?.({ success: true });
},
onError: ({ error: errorMessage }) => {
// Remove from pending set now that stream ended with error
......@@ -323,7 +323,7 @@ export function useStreamChat({
refreshApp();
refreshVersions();
invalidateTokenCount();
onSettled?.();
onSettled?.({ success: false });
},
},
);
......@@ -346,7 +346,7 @@ export function useStreamChat({
);
return next;
});
onSettled?.();
onSettled?.({ success: false });
}
},
[
......
......@@ -86,6 +86,12 @@ export const createChatCompletionHandler =
let messageContent = CANNED_MESSAGE;
// Route plan comment messages to generate dump for testing
if (userTextContent.includes("I have the following comments on the plan")) {
messageContent =
"I'll update the plan based on your comments.\n\n" + generateDump(req);
}
// Handle compaction summary requests (from generateText() in compaction_handler)
if (
userTextContent.startsWith("Please summarize the following conversation:")
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论