feat(web): add agent message comments (#1835)

* feat(web): add agent message comments

* fix(web): harden agent message comments

* fix(web): address message comment review findings

* fix(web): allow comments on legacy agent messages

* fix(web): address latest message comment review

* fix(web): address follow-up message comment review

* fix(web): address remaining message comment review

* fix(web): keep failed mobile comments open

* fix(web): preserve selections over message comments
This commit is contained in:
José Almeida
2026-07-21 13:32:19 +01:00
committed by GitHub
parent 30e6cba810
commit 8f2fe7a8e6
62 changed files with 3309 additions and 188 deletions
+27 -5
View File
@@ -1228,8 +1228,10 @@
}
/* Comment highlights, inline mark spans like Highlight extension */
.tiptap-plan-wrapper .comment-highlight {
.tiptap-plan-wrapper .comment-highlight,
.agent-message-comment-body .comment-highlight {
background-color: color-mix(in oklch, var(--accent) 70%, transparent);
color: inherit;
border-radius: 3px;
border-bottom: 2px solid color-mix(in oklch, var(--accent-foreground) 25%, transparent);
cursor: pointer;
@@ -1239,12 +1241,22 @@
-webkit-box-decoration-break: clone;
}
.tiptap-plan-wrapper .comment-highlight:hover {
.tiptap-plan-wrapper .comment-highlight:hover,
.agent-message-comment-body .comment-highlight:hover {
background-color: color-mix(in oklch, var(--accent) 90%, transparent);
}
.agent-message-comment-fallback {
position: absolute;
pointer-events: none;
background-color: color-mix(in oklch, var(--accent) 70%, transparent);
border-radius: 3px;
border-bottom: 2px solid color-mix(in oklch, var(--accent-foreground) 25%, transparent);
}
/* Comment badge, single widget decoration at end of mark range */
.tiptap-plan-wrapper .comment-badge {
.tiptap-plan-wrapper .comment-badge,
.agent-message-comment-body .comment-badge {
position: relative;
display: inline-block;
width: 0;
@@ -1253,7 +1265,8 @@
vertical-align: top;
}
.tiptap-plan-wrapper .comment-badge svg {
.tiptap-plan-wrapper .comment-badge svg,
.agent-message-comment-body .comment-badge svg {
position: absolute;
top: -6px;
left: 2px;
@@ -1265,10 +1278,18 @@
pointer-events: auto;
}
.tiptap-plan-wrapper .comment-badge:hover svg {
.tiptap-plan-wrapper .comment-badge:hover svg,
.agent-message-comment-body .comment-badge:hover svg,
.agent-message-comment-body .comment-badge:focus-visible svg {
color: var(--primary);
}
.agent-message-comment-body .comment-badge:focus-visible svg {
outline: 2px solid var(--ring);
outline-offset: 2px;
border-radius: 2px;
}
.markdown-preview-source-block {
position: relative;
}
@@ -1736,6 +1757,7 @@
.sidebar-section-content[data-state="closed"] {
animation: sidebar-section-collapse 160ms cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
.sidebar-fade-in,
.sidebar-section-content[data-state="open"],
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { buildContextItems } from "./chat-context-items";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
const comment: AgentMessageComment = {
id: "message-comment-1",
sessionId: "session-1",
source: "agent-message",
messageId: "reply-1",
selectedText: "selected answer",
anchor: {
messageId: "reply-1",
start: 0,
end: 15,
selectedText: "selected answer",
prefix: "",
suffix: " continues",
},
text: "Please clarify this.",
createdAt: "2026-07-20T00:00:00Z",
status: "pending",
};
describe("buildContextItems agent-message comments", () => {
it("creates one removable context item for pending message comments", () => {
const items = buildContextItems({
planContextEnabled: false,
contextFiles: [],
resolvedSessionId: "session-1",
removeContextFile: vi.fn(),
unpinFile: vi.fn(),
addPlan: vi.fn(),
promptsMap: new Map(),
pendingCommentsByFile: {},
handleRemoveCommentFile: vi.fn(),
handleRemoveComment: vi.fn(),
planComments: [],
handleClearPlanComments: vi.fn(),
pendingPRFeedback: [],
handleRemovePRFeedback: vi.fn(),
handleClearPRFeedback: vi.fn(),
walkthroughComments: [],
handleRemoveWalkthroughComment: vi.fn(),
handleClearWalkthroughComments: vi.fn(),
messageComments: [comment],
handleClearMessageComments: vi.fn(),
taskId: "task-1",
} as never);
const item = items.find((candidate) => candidate.kind === "agent-message-comment");
expect(item).toMatchObject({
kind: "agent-message-comment",
label: "1 message comment",
});
});
});
+21 -2
View File
@@ -6,6 +6,7 @@ import type {
PlanComment,
PRFeedbackComment,
WalkthroughComment,
AgentMessageComment,
} from "@/lib/state/slices/comments";
const PLAN_CONTEXT_PATH = "plan:context";
@@ -31,6 +32,8 @@ export type BuildContextItemsParams = {
walkthroughComments: WalkthroughComment[];
handleRemoveWalkthroughComment: (commentId: string) => void;
handleClearWalkthroughComments: () => void;
messageComments: AgentMessageComment[];
handleClearMessageComments: () => void;
taskId: string | null;
};
@@ -192,6 +195,20 @@ function buildWalkthroughCommentItems(params: BuildContextItemsParams): ContextI
];
}
function buildAgentMessageCommentItems(params: BuildContextItemsParams): ContextItem[] {
const { messageComments, handleClearMessageComments } = params;
if (messageComments.length === 0) return [];
return [
{
kind: "agent-message-comment" as const,
id: "agent-message-comments",
label: `${messageComments.length} message comment${messageComments.length === 1 ? "" : "s"}`,
comments: messageComments,
onRemove: handleClearMessageComments,
},
];
}
/** Sort: pinned first, then by kind order, then by label */
const KIND_ORDER: Record<string, number> = {
plan: 0,
@@ -200,8 +217,9 @@ const KIND_ORDER: Record<string, number> = {
comment: 3,
"plan-comment": 4,
"walkthrough-comment": 5,
image: 6,
"pr-feedback": 7,
"agent-message-comment": 6,
image: 7,
"pr-feedback": 8,
};
export function contextItemSortFn(a: ContextItem, b: ContextItem): number {
@@ -221,6 +239,7 @@ export function buildContextItems(params: BuildContextItemsParams): ContextItem[
items.push(...buildFileAndPromptItems(params));
items.push(...buildCommentItems(params));
items.push(...buildWalkthroughCommentItems(params));
items.push(...buildAgentMessageCommentItems(params));
items.push(...buildPRFeedbackItems(params));
if (params.planComments.length > 0) {
@@ -23,6 +23,10 @@ vi.mock("@/components/shared/markdown-components", () => ({
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { __markdownParseCount, __resetMarkdownCounters } from "@/lib/markdown/normalize-cache";
import {
createMessageTextAnchor,
getMessageCommentDecorations,
} from "@/lib/chat/agent-message-comments";
type Row = { id: string; content: string };
@@ -121,4 +125,30 @@ describe("chat render-cost evals", () => {
render(<MessageList rows={rows} panel={false} />);
expect(__markdownParseCount()).toBe(1);
});
it("scenario 5: restoring many inline highlights does not reparse markdown rows", () => {
const content = Array.from({ length: 20 }, (_, index) => `reply-${index}`).join(" ");
const root = document.createElement("div");
root.textContent = content;
const comments = Array.from({ length: 20 }, (_, index) => {
const selectedText = `reply-${index}`;
const start = content.indexOf(selectedText);
return {
id: `comment-${index}`,
sessionId: "session-1",
source: "agent-message" as const,
messageId: "reply-1",
selectedText,
text: "feedback",
createdAt: "2026-07-20T00:00:00Z",
status: "pending" as const,
anchor: createMessageTextAnchor("reply-1", content, start, start + selectedText.length),
};
});
const parsesBefore = __markdownParseCount();
expect(getMessageCommentDecorations(root, comments)).toHaveLength(20);
expect(root.querySelectorAll("mark[data-agent-message-comment-id]")).toHaveLength(0);
expect(__markdownParseCount() - parsesBefore).toBe(0);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildSubmitMessage } from "./chat-input-area";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
const messageComment: AgentMessageComment = {
id: "message-comment-1",
sessionId: "session-1",
source: "agent-message",
messageId: "reply-1",
selectedText: "answer",
anchor: {
messageId: "reply-1",
start: 0,
end: 6,
selectedText: "answer",
prefix: "",
suffix: "",
},
text: "Please make this more precise.",
createdAt: "2026-07-20T00:00:00Z",
status: "pending",
};
describe("buildSubmitMessage agent message comments", () => {
it("includes selected response context while preserving ordinary prose", () => {
const result = buildSubmitMessage({
message: "Continue from here.",
pendingPRFeedback: [],
planComments: [],
messageComments: [messageComment],
});
expect(result).toContain("### Agent Message Comments");
expect(result).toContain("> answer");
expect(result).toContain("Continue from here.");
});
});
@@ -73,6 +73,7 @@ vi.mock("@/lib/ws/connection", () => ({
import { useSubmitHandler } from "./chat-input-area";
beforeEach(() => {
handleSendMessageMock.mockReset();
handleSendMessageMock.mockResolvedValue(undefined);
});
@@ -123,4 +124,71 @@ describe("useSubmitHandler", () => {
variant: "error",
});
});
it("reports deterministic preflight failures as not sent", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const error = Object.assign(new Error("Connection unavailable. Reconnect and try again."), {
name: "MessageSendError",
code: "connection-unavailable",
});
handleSendMessageMock.mockRejectedValueOnce(error);
const { result } = renderHook(() => useSubmitHandler(panelState()));
await act(async () => {
await result.current.handleSubmit("hello");
});
expect(toastMock).toHaveBeenCalledWith({
title: "Message not sent",
description: "Connection unavailable. Reconnect and try again.",
variant: "error",
});
});
it("keeps agent message comments pending when sending fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const markCommentsSent = vi.fn();
handleSendMessageMock.mockRejectedValueOnce(new Error("send failed"));
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
messageComments: [{ id: "comment-1" }],
markCommentsSent,
}),
),
);
await act(async () => {
await result.current.handleSubmit("hello");
});
expect(markCommentsSent).not.toHaveBeenCalled();
});
it("includes each agent message comment once and marks it sent after success", async () => {
const markCommentsSent = vi.fn();
const comment = {
id: "comment-1",
selectedText: "settled answer",
text: "Please expand this.",
};
const { result } = renderHook(() =>
useSubmitHandler(
panelState({
messageComments: [comment],
markCommentsSent,
}),
),
);
await act(async () => {
await result.current.handleSubmit("hello");
});
const [sentMessage] = handleSendMessageMock.mock.calls[0] as [string];
expect(sentMessage.match(/### Agent Message Comments/g)).toHaveLength(1);
expect(sentMessage).toContain("> settled answer");
expect(sentMessage).toContain("> Please expand this.");
expect(markCommentsSent).toHaveBeenCalledWith(["comment-1"]);
});
});
@@ -28,23 +28,34 @@ import {
formatPRFeedbackAsMarkdown,
formatPlanCommentsAsMarkdown,
formatWalkthroughCommentsAsMarkdown,
formatAgentMessageCommentsAsMarkdown,
} from "@/lib/state/slices/comments/format";
import { usePlanActions } from "@/hooks/domains/kanban/use-plan-actions";
import { useExecutorEnvironmentAvailability } from "@/hooks/domains/session/use-executor-environment-availability";
import { useToast } from "@/components/toast-provider";
import { isMessageSendError } from "@/lib/chat/message-send-error";
import type { DiffComment } from "@/lib/diff/types";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
import type { useChatPanelState } from "./use-chat-panel-state";
import { cn } from "@/lib/utils";
const PLAN_CONTEXT_PATH = "plan:context";
export function buildSubmitMessage(
message: string,
reviewComments: DiffComment[] | undefined,
pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[],
planComments: import("@/lib/state/slices/comments").PlanComment[],
walkthroughComments: import("@/lib/state/slices/comments").WalkthroughComment[] = [],
): string {
export function buildSubmitMessage({
message,
reviewComments,
pendingPRFeedback,
planComments,
walkthroughComments = [],
messageComments = [],
}: {
message: string;
reviewComments?: DiffComment[];
pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[];
planComments: import("@/lib/state/slices/comments").PlanComment[];
walkthroughComments?: import("@/lib/state/slices/comments").WalkthroughComment[];
messageComments?: AgentMessageComment[];
}): string {
let finalMessage = message;
if (reviewComments && reviewComments.length > 0) {
finalMessage = formatReviewCommentsAsMarkdown(reviewComments) + (message || "");
@@ -59,6 +70,12 @@ export function buildSubmitMessage(
const planMarkdown = formatPlanCommentsAsMarkdown(planComments);
finalMessage = finalMessage ? `${planMarkdown}${finalMessage}` : planMarkdown;
}
if (messageComments.length > 0) {
const messageCommentsMarkdown = formatAgentMessageCommentsAsMarkdown(messageComments);
finalMessage = finalMessage
? `${messageCommentsMarkdown}${finalMessage}`
: messageCommentsMarkdown;
}
return finalMessage;
}
@@ -101,8 +118,16 @@ function pickInputPlaceholder(a: PlaceholderArgs): string {
);
}
function showUnknownMessageSendToast(error: unknown, toast: ReturnType<typeof useToast>["toast"]) {
function showMessageSendToast(error: unknown, toast: ReturnType<typeof useToast>["toast"]) {
console.error("Failed to send message:", error);
if (isMessageSendError(error)) {
toast({
title: "Message not sent",
description: error.message,
variant: "error",
});
return;
}
toast({
title: "Message send status unknown",
description:
@@ -148,6 +173,7 @@ export function useSubmitHandler(
planComments,
pendingPRFeedback,
walkthroughComments,
messageComments,
markCommentsSent,
clearSessionPlanComments,
handleClearPRFeedback,
@@ -169,13 +195,14 @@ export function useSubmitHandler(
if (isSending) return;
setIsSending(true);
try {
const finalMessage = buildSubmitMessage(
const finalMessage = buildSubmitMessage({
message,
reviewComments,
pendingPRFeedback,
planComments,
walkthroughComments,
);
messageComments,
});
const hasReviewComments = !!(reviewComments && reviewComments.length > 0);
if (onSend) {
// Expand task mentions because onSend bypasses useMessageHandler.buildFinalMessage.
@@ -194,6 +221,7 @@ export function useSubmitHandler(
}
if (reviewComments && reviewComments.length > 0)
markCommentsSent(reviewComments.map((c) => c.id));
if (messageComments.length > 0) markCommentsSent(messageComments.map((c) => c.id));
if (pendingPRFeedback.length > 0) handleClearPRFeedback();
if (walkthroughComments.length > 0) handleClearWalkthroughComments();
if (planComments.length > 0) clearSessionPlanComments();
@@ -205,7 +233,7 @@ export function useSubmitHandler(
}
}
} catch (error) {
showUnknownMessageSendToast(error, toast);
showMessageSendToast(error, toast);
return false;
} finally {
setIsSending(false);
@@ -220,6 +248,7 @@ export function useSubmitHandler(
planComments,
clearSessionPlanComments,
walkthroughComments,
messageComments,
handleClearWalkthroughComments,
pendingPRFeedback,
handleClearPRFeedback,
@@ -482,7 +511,8 @@ export function ChatInputArea({
hasContextComments={
panelState.planComments.length > 0 ||
panelState.pendingPRFeedback.length > 0 ||
panelState.walkthroughComments.length > 0
panelState.walkthroughComments.length > 0 ||
panelState.messageComments.length > 0
}
submitKey={panelState.chatSubmitKey}
hasAgentCommands={!!(panelState.agentCommands && panelState.agentCommands.length > 0)}
@@ -0,0 +1,20 @@
"use client";
import { memo } from "react";
import type { AgentMessageCommentContextItem } from "@/lib/types/context";
import { SelectionCommentItem } from "./selection-comment-item";
export const AgentMessageCommentItem = memo(function AgentMessageCommentItem({
item,
}: {
item: AgentMessageCommentContextItem;
}) {
return (
<SelectionCommentItem
kind="agent-message-comment"
label={item.label}
comments={item.comments}
onRemove={item.onRemove}
/>
);
});
@@ -26,6 +26,7 @@ const ICON_BY_KIND: Record<ContextItemKind, TablerIcon> = {
"file-attachment": IconFile,
prompt: IconAt,
"pr-feedback": IconGitPullRequest,
"agent-message-comment": IconMessageDots,
};
type ContextChipProps = {
@@ -87,7 +88,8 @@ export const ContextChip = memo(function ContextChip({
e.stopPropagation();
onRemove();
}}
className="opacity-0 group-hover:opacity-100 ml-0.5 hover:text-foreground cursor-pointer"
aria-label={`Remove ${label}`}
className="min-h-11 min-w-11 opacity-100 ml-0.5 hover:text-foreground cursor-pointer sm:min-h-0 sm:min-w-0 sm:opacity-0 sm:group-hover:opacity-100"
>
<IconX className="h-2.5 w-2.5" />
</button>
@@ -10,6 +10,7 @@ import { ImageItem } from "./image-item";
import { FileAttachmentItem } from "./file-attachment-item";
import { PRFeedbackItem } from "./pr-feedback-item";
import { WalkthroughCommentItem } from "./walkthrough-comment-item";
import { AgentMessageCommentItem } from "./agent-message-comment-item";
export function ContextItemRenderer({
item,
@@ -37,5 +38,7 @@ export function ContextItemRenderer({
return <PRFeedbackItem item={item} />;
case "walkthrough-comment":
return <WalkthroughCommentItem item={item} />;
case "agent-message-comment":
return <AgentMessageCommentItem item={item} />;
}
}
@@ -2,33 +2,18 @@
import { memo } from "react";
import type { PlanCommentContextItem } from "@/lib/types/context";
import { ContextChip } from "./context-chip";
import { SelectionCommentItem } from "./selection-comment-item";
export const PlanCommentItem = memo(function PlanCommentItem({
item,
}: {
item: PlanCommentContextItem;
}) {
const preview = (
<div className="space-y-1.5">
{item.comments.map((comment) => (
<div key={comment.id} className="text-xs space-y-0.5">
{comment.selectedText && (
<div className="text-muted-foreground italic line-clamp-2">
&ldquo;{comment.selectedText}&rdquo;
</div>
)}
<div className="break-words">{comment.text}</div>
</div>
))}
</div>
);
return (
<ContextChip
<SelectionCommentItem
kind="plan-comment"
label={item.label}
preview={preview}
comments={item.comments}
onClick={item.onOpen}
onRemove={item.onRemove}
/>
@@ -0,0 +1,49 @@
"use client";
import { memo } from "react";
import { ContextChip } from "./context-chip";
type SelectionComment = {
id: string;
selectedText: string;
text: string;
};
export const SelectionCommentItem = memo(function SelectionCommentItem({
kind,
label,
comments,
onClick,
onRemove,
}: {
kind: "plan-comment" | "agent-message-comment";
label: string;
comments: SelectionComment[];
onClick?: () => void;
onRemove?: () => void;
}) {
const preview = (
<div className="space-y-1.5">
{comments.map((comment) => (
<div key={comment.id} className="space-y-0.5 text-xs">
{comment.selectedText ? (
<div className="line-clamp-2 text-muted-foreground italic">
&ldquo;{comment.selectedText}&rdquo;
</div>
) : null}
<div className="break-words">{comment.text}</div>
</div>
))}
</div>
);
return (
<ContextChip
kind={kind}
label={label}
preview={preview}
onClick={onClick}
onRemove={onRemove}
/>
);
});
@@ -15,6 +15,7 @@ import {
getConversationLoadingState,
getSessionRunningState,
getLastTurnGroupId,
getStreamingAgentMessageId,
} from "./message-list-shared";
/**
@@ -195,6 +196,7 @@ export const NativeMessageList = memo(function NativeMessageList({
});
const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId);
const isRunning = getSessionRunningState(sessionState);
const streamingMessageId = getStreamingAgentMessageId(messages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
const handleScrollToMessage = useScrollToMessage();
@@ -247,6 +249,7 @@ export const NativeMessageList = memo(function NativeMessageList({
onOpenFile={onOpenFile}
isLastGroup={item.type === "turn_group" && item.id === lastTurnGroupId}
isTurnActive={isRunning}
streamingMessageId={streamingMessageId}
onScrollToMessage={handleScrollToMessage}
/>
</div>
@@ -61,7 +61,12 @@ vi.mock("@/lib/api/domains/session-api", () => ({
dismissLastAgentError: vi.fn(),
}));
import { MessageItem, MessageListStatus, getConversationLoadingState } from "./message-list-shared";
import {
MessageItem,
MessageListStatus,
getConversationLoadingState,
getStreamingAgentMessageId,
} from "./message-list-shared";
const item: RenderItem = { type: "message", message: { id: "m1" } as Message };
const noop = () => {};
@@ -214,6 +219,25 @@ describe("getConversationLoadingState", () => {
});
});
describe("getStreamingAgentMessageId", () => {
it("only marks an agent reply after the latest user prompt as streaming", () => {
const message = (id: string, author_type: "user" | "agent", type = "message") =>
({ id, author_type, type }) as Message;
expect(
getStreamingAgentMessageId([
message("old-reply", "agent"),
message("prompt", "user"),
message("reply", "agent"),
message("status", "agent", "status"),
]),
).toBe("reply");
expect(getStreamingAgentMessageId([message("auto-started-reply", "agent")])).toBe(
"auto-started-reply",
);
});
});
describe("MessageListStatus", () => {
it("renders a conversation loading indicator while existing content remains visible", () => {
render(
@@ -47,6 +47,27 @@ export function getSessionRunningState(sessionState: string | null | undefined)
return sessionState === "CREATED" || sessionState === "STARTING" || sessionState === "RUNNING";
}
/** Only the latest ordinary agent row in the active turn can be the streaming reply. */
export function getStreamingAgentMessageId(messages: Message[]): string | null {
let latestUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].author_type === "user") {
latestUserIndex = i;
break;
}
}
for (let i = messages.length - 1; i > latestUserIndex; i--) {
const message = messages[i];
if (
message.author_type === "agent" &&
(message.type === "message" || message.type === "content" || !message.type)
) {
return message.id;
}
}
return null;
}
export function getLastTurnGroupId(items: RenderItem[]) {
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
@@ -220,6 +241,7 @@ export const MessageItem = memo(function MessageItem({
onOpenFile,
isLastGroup,
isTurnActive,
streamingMessageId,
onScrollToMessage,
}: {
item: RenderItem;
@@ -231,6 +253,7 @@ export const MessageItem = memo(function MessageItem({
onOpenFile?: (path: string) => void;
isLastGroup: boolean;
isTurnActive: boolean;
streamingMessageId?: string | null;
onScrollToMessage: (id: string) => void;
}) {
if (item.type === "prepare_progress") {
@@ -250,7 +273,13 @@ export const MessageItem = memo(function MessageItem({
worktreePath={worktreePath}
onOpenFile={onOpenFile}
isLastGroup={isLastGroup}
isTurnActive={isTurnActive}
isTurnActive={
isTurnActive &&
(streamingMessageId
? item.messages.some((message) => message.id === streamingMessageId)
: false)
}
streamingMessageId={streamingMessageId}
onScrollToMessage={onScrollToMessage}
/>
);
@@ -264,6 +293,7 @@ export const MessageItem = memo(function MessageItem({
childrenByParentToolCallId={childrenByParentToolCallId}
worktreePath={worktreePath}
sessionId={sessionId ?? undefined}
isTurnActive={isTurnActive && item.message.id === streamingMessageId}
onOpenFile={onOpenFile}
onScrollToMessage={onScrollToMessage}
/>
@@ -17,6 +17,7 @@ import {
getConversationLoadingState,
getSessionRunningState,
getLastTurnGroupId,
getStreamingAgentMessageId,
} from "./message-list-shared";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
@@ -97,11 +98,19 @@ function useStableFirstItemIndex(items: RenderItem[]) {
}
function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
const { items, sessionId, permissionsByToolCallId, childrenByParentToolCallId, taskId } = props;
const {
items,
messages,
sessionId,
permissionsByToolCallId,
childrenByParentToolCallId,
taskId,
} = props;
const { worktreePath, onOpenFile, lastTurnGroupId, isRunning } = props;
const { hasMore, isLoadingMore, loadMore } = props;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const itemCount = items.length;
const streamingMessageId = getStreamingAgentMessageId(messages);
const firstItemIndex = useStableFirstItemIndex(items);
const loadCooldownRef = useRef(false);
@@ -155,6 +164,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
onOpenFile={onOpenFile}
isLastGroup={item.type === "turn_group" && item.id === lastTurnGroupId}
isTurnActive={isRunning}
streamingMessageId={streamingMessageId}
onScrollToMessage={handleScrollToMessage}
/>
</div>
@@ -171,6 +181,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
onOpenFile,
lastTurnGroupId,
isRunning,
streamingMessageId,
handleScrollToMessage,
],
);
@@ -39,6 +39,7 @@ type AdapterContext = {
sessionId?: string;
onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
isTurnActive?: boolean;
};
function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; sessionId: string }) {
@@ -98,6 +99,7 @@ function TaskDescriptionMessage({
worktreePath,
onOpenFile,
onScrollToMessage,
isTurnActive = false,
}: {
comment: Message;
taskId?: string;
@@ -105,6 +107,7 @@ function TaskDescriptionMessage({
worktreePath?: string;
onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
isTurnActive?: boolean;
}) {
const sessionState = useSessionStateValue(sessionId);
const task = useTask(taskId ?? null);
@@ -117,6 +120,7 @@ function TaskDescriptionMessage({
className="bg-muted/40 text-foreground border-border/60"
showRichBlocks={comment.type === "message" || comment.type === "content" || !comment.type}
sessionId={sessionId}
isTurnActive={isTurnActive}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
onScrollToMessage={onScrollToMessage}
@@ -132,6 +136,7 @@ function TaskDescriptionMessage({
label="You"
className="bg-primary/10 text-foreground border-primary/30"
sessionId={sessionId}
isTurnActive={isTurnActive}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
onScrollToMessage={onScrollToMessage}
@@ -322,6 +327,7 @@ const adapters: MessageAdapter[] = [
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
onScrollToMessage={ctx.onScrollToMessage}
isTurnActive={ctx.isTurnActive}
/>
);
}
@@ -335,6 +341,7 @@ const adapters: MessageAdapter[] = [
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
onScrollToMessage={ctx.onScrollToMessage}
isTurnActive={ctx.isTurnActive}
/>
);
}
@@ -348,6 +355,7 @@ const adapters: MessageAdapter[] = [
worktreePath={ctx.worktreePath}
onOpenFile={ctx.onOpenFile}
onScrollToMessage={ctx.onScrollToMessage}
isTurnActive={ctx.isTurnActive}
/>
);
},
@@ -364,6 +372,7 @@ type MessageRendererProps = {
sessionId?: string;
onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
isTurnActive?: boolean;
};
export const MessageRenderer = memo(function MessageRenderer({
@@ -376,6 +385,7 @@ export const MessageRenderer = memo(function MessageRenderer({
sessionId,
onOpenFile,
onScrollToMessage,
isTurnActive = false,
}: MessageRendererProps) {
const ctx = {
isTaskDescription,
@@ -386,6 +396,7 @@ export const MessageRenderer = memo(function MessageRenderer({
sessionId,
onOpenFile,
onScrollToMessage,
isTurnActive,
};
const adapter =
adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1];
@@ -0,0 +1,67 @@
"use client";
import { memo } from "react";
import type { Message } from "@/lib/types/http";
import { RichBlocks } from "@/components/task/chat/messages/rich-blocks";
import { MessageActions } from "@/components/task/chat/messages/message-actions";
import { MemoizedMarkdown } from "@/components/shared/memoized-markdown";
import { MessageCommentSurface } from "./message-comment-surface";
type AgentMessageContentProps = {
comment: Message;
showRaw: boolean;
onToggleRaw: () => void;
showRichBlocks?: boolean;
worktreePath?: string;
onOpenFile?: (path: string) => void;
sessionId?: string | null;
isTurnActive: boolean;
};
export const AgentMessageContent = memo(function AgentMessageContent({
comment,
showRaw,
onToggleRaw,
showRichBlocks,
worktreePath,
onOpenFile,
sessionId,
isTurnActive,
}: AgentMessageContentProps) {
return (
<div className="flex items-start gap-2 sm:gap-3 w-full group">
<div className="flex-1 min-w-0">
{showRaw ? (
<pre className="whitespace-pre-wrap font-mono text-xs bg-muted/20 p-3 rounded-md">
{comment.raw_content || comment.content || "(empty)"}
</pre>
) : (
<MessageCommentSurface
message={comment}
sessionId={sessionId}
isTurnActive={isTurnActive}
>
<div className="markdown-body max-w-none">
<MemoizedMarkdown
content={comment.content || "(empty)"}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
</div>
</MessageCommentSurface>
)}
{!showRaw && showRichBlocks ? <RichBlocks comment={comment} /> : null}
<MessageActions
message={comment}
showCopy={true}
showTimestamp={true}
showRawToggle={true}
showModel={true}
showNavigation={false}
isRawView={showRaw}
onToggleRaw={onToggleRaw}
/>
</div>
</div>
);
});
@@ -13,7 +13,6 @@ import type { Components } from "react-markdown";
import { IconWand, IconMessageDots, IconFile } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/types/http";
import { RichBlocks } from "@/components/task/chat/messages/rich-blocks";
import { MessageActions } from "@/components/task/chat/messages/message-actions";
import { useUserMessageNavigation } from "@/hooks/use-message-navigation";
import { SenderTaskBadge, type SenderTaskInfo } from "./sender-task-badge";
@@ -33,6 +32,7 @@ import {
type WorkflowMessageMetadata,
type WorkflowStepMessageInfo,
} from "./workflow-step-message-badge";
import { AgentMessageContent } from "./agent-message-content";
type ChatMessageProps = {
comment: Message;
@@ -43,6 +43,7 @@ type ChatMessageProps = {
worktreePath?: string;
onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
isTurnActive?: boolean;
};
// Regex to match @file references (file paths after @)
@@ -479,57 +480,6 @@ function UserMessageContent({
);
}
// ── Agent message sub-component ─────────────────────────────────────
type AgentMessageProps = {
comment: Message;
showRaw: boolean;
onToggleRaw: () => void;
showRichBlocks?: boolean;
worktreePath?: string;
onOpenFile?: (path: string) => void;
};
function AgentMessageContent({
comment,
showRaw,
onToggleRaw,
showRichBlocks,
worktreePath,
onOpenFile,
}: AgentMessageProps) {
return (
<div className="flex items-start gap-2 sm:gap-3 w-full group">
<div className="flex-1 min-w-0">
{showRaw ? (
<pre className="whitespace-pre-wrap font-mono text-xs bg-muted/20 p-3 rounded-md">
{comment.raw_content || comment.content || "(empty)"}
</pre>
) : (
<div className="markdown-body max-w-none">
<MemoizedMarkdown
content={comment.content || "(empty)"}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
{showRichBlocks ? <RichBlocks comment={comment} /> : null}
</div>
)}
<MessageActions
message={comment}
showCopy={true}
showTimestamp={true}
showRawToggle={true}
showModel={true}
showNavigation={false}
isRawView={showRaw}
onToggleRaw={onToggleRaw}
/>
</div>
</div>
);
}
// ── Main component ──────────────────────────────────────────────────
export const ChatMessage = memo(function ChatMessage({
@@ -541,6 +491,7 @@ export const ChatMessage = memo(function ChatMessage({
worktreePath,
onOpenFile,
onScrollToMessage,
isTurnActive = false,
}: ChatMessageProps) {
const [showRaw, setShowRaw] = useState(false);
const toggleRaw = useCallback(() => setShowRaw((v) => !v), []);
@@ -585,6 +536,8 @@ export const ChatMessage = memo(function ChatMessage({
showRaw={showRaw}
onToggleRaw={toggleRaw}
showRichBlocks={showRichBlocks}
sessionId={sessionId}
isTurnActive={isTurnActive}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
@@ -0,0 +1,77 @@
import { IconMessage } from "@tabler/icons-react";
import type { MessageCommentDecoration } from "@/lib/chat/agent-message-comments";
function MessageCommentBadges({
decorations,
onOpen,
}: {
decorations: MessageCommentDecoration[];
onOpen: (commentId: string, position: { x: number; y: number }) => void;
}) {
return decorations.map((decoration) => (
<button
key={decoration.comment.id}
type="button"
className="comment-badge agent-message-comment-badge border-0 bg-transparent p-0"
style={{ position: "absolute", left: decoration.left, top: decoration.top }}
data-agent-message-comment-id={decoration.comment.id}
data-comment-id={decoration.comment.id}
aria-label="Edit comment"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
onOpen(decoration.comment.id, { x: rect.right, y: rect.bottom });
}}
>
<IconMessage aria-hidden="true" />
</button>
));
}
function MessageCommentFallbackHighlights({
decorations,
}: {
decorations: MessageCommentDecoration[];
}) {
return decorations.flatMap((decoration) =>
decoration.highlightRects.map((rect, index) => (
<span
key={`${decoration.comment.id}:${index}`}
aria-hidden="true"
className="agent-message-comment-fallback"
data-agent-message-comment-fallback="true"
data-comment-id={decoration.comment.id}
style={rect}
/>
)),
);
}
export function MessageCommentDecorationOverlay({
decorations,
useCustomHighlights,
onOpen,
}: {
decorations: MessageCommentDecoration[];
useCustomHighlights: boolean;
onOpen: (commentId: string, position: { x: number; y: number }) => void;
}) {
return (
<>
{!useCustomHighlights ? <MessageCommentFallbackHighlights decorations={decorations} /> : null}
<MessageCommentBadges decorations={decorations} onOpen={onOpen} />
</>
);
}
export function MessageCustomHighlightStyle({ highlightName }: { highlightName: string }) {
return (
<style>{`::highlight(${highlightName}) {
background-color: color-mix(in oklch, var(--accent) 70%, transparent);
color: inherit;
text-decoration: underline 2px color-mix(in oklch, var(--accent-foreground) 25%, transparent);
text-underline-offset: 2px;
}`}</style>
);
}
@@ -0,0 +1,288 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useCommentsStore } from "@/lib/state/slices/comments";
import { createMessageTextAnchor } from "@/lib/chat/agent-message-comments";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
import { MessageCommentSurface } from "./message-comment-surface";
const COMMENT_TEXT = vi.hoisted(() => "Make this concrete.");
const TOUCH_DRAWER = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => TOUCH_DRAWER.enabled,
}));
vi.mock("@/hooks/domains/comments/use-run-comment", () => ({
useRunComment: () => ({ runComment: vi.fn() }),
}));
vi.mock("@/components/task/plan-selection-popover", () => ({
PlanSelectionPopover: ({
selectedText,
onAdd,
onClose,
errorMessage,
}: {
selectedText: string;
onAdd: (feedback: string) => boolean | void;
onClose: () => void;
errorMessage?: string | null;
}) => (
<div data-testid="comment-popover">
<span>{selectedText}</span>
{errorMessage ? <p role="alert">{errorMessage}</p> : null}
<button
type="button"
onClick={() => {
if (onAdd(COMMENT_TEXT) !== false) onClose();
}}
>
Save comment
</button>
</div>
),
}));
const SESSION_ID = "session-1";
const MESSAGE_TEXT_TEST_ID = "message-text";
const SELECTED_QUOTE = "settled answer";
function message(content: string): Message {
return {
id: "message-1",
session_id: toSessionId(SESSION_ID),
task_id: toTaskId("task-1"),
author_type: "agent",
type: "message",
content,
created_at: "2026-07-21T00:00:00Z",
};
}
function resetComments() {
useCommentsStore.setState({
byId: {},
bySession: {},
pendingForChat: [],
editingCommentId: null,
});
sessionStorage.clear();
}
function addPendingComment(content: string, selectedText: string) {
const start = content.indexOf(selectedText);
useCommentsStore.getState().addComment({
id: "comment-1",
sessionId: SESSION_ID,
source: "agent-message",
messageId: "message-1",
selectedText,
text: COMMENT_TEXT,
createdAt: "2026-07-21T00:00:00Z",
status: "pending",
anchor: createMessageTextAnchor("message-1", content, start, start + selectedText.length),
});
}
afterEach(() => {
cleanup();
resetComments();
TOUCH_DRAWER.enabled = false;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("MessageCommentSurface", () => {
it("anchors a pending selection to the same quote when message text shifts", () => {
const original = "The settled answer contains detail.";
const updated = "Intro. The settled answer contains detail.";
const { rerender } = render(
<MessageCommentSurface
message={message(original)}
sessionId={SESSION_ID}
isTurnActive={false}
>
<span data-testid={MESSAGE_TEXT_TEST_ID}>{original}</span>
</MessageCommentSurface>,
);
const text = screen.getByTestId(MESSAGE_TEXT_TEST_ID).firstChild!;
const range = document.createRange();
const start = original.indexOf(SELECTED_QUOTE);
range.setStart(text, start);
range.setEnd(text, start + SELECTED_QUOTE.length);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
fireEvent.mouseUp(screen.getByTestId(MESSAGE_TEXT_TEST_ID).parentElement!);
fireEvent.click(screen.getByTestId("agent-message-comment-trigger"));
rerender(
<MessageCommentSurface message={message(updated)} sessionId={SESSION_ID} isTurnActive={false}>
<span data-testid={MESSAGE_TEXT_TEST_ID}>{updated}</span>
</MessageCommentSurface>,
);
fireEvent.click(screen.getByRole("button", { name: "Save comment" }));
const saved = Object.values(useCommentsStore.getState().byId)[0];
expect(saved?.source).toBe("agent-message");
if (saved?.source !== "agent-message") throw new Error("Expected an agent message comment");
expect(saved.selectedText).toBe(SELECTED_QUOTE);
expect(saved.anchor.start).toBe(updated.indexOf(SELECTED_QUOTE));
});
it("keeps React-owned text nodes under their rendered parent", () => {
const content = "A settled answer.";
addPendingComment(content, "settled");
render(
<MessageCommentSurface message={message(content)} sessionId={SESSION_ID} isTurnActive={false}>
<span data-testid="react-owned-text">{content}</span>
</MessageCommentSurface>,
);
const reactOwnedText = screen.getByTestId("react-owned-text");
expect(
Array.from(reactOwnedText.childNodes).every((node) => node.nodeType === Node.TEXT_NODE),
).toBe(true);
expect(document.querySelector('.comment-badge[data-comment-id="comment-1"]')).not.toBeNull();
});
it("keeps feedback open and reports when the selected quote was rewritten", () => {
const original = "The settled answer contains detail.";
const updated = "The response was completely rewritten.";
const { rerender } = render(
<MessageCommentSurface
message={message(original)}
sessionId={SESSION_ID}
isTurnActive={false}
>
<span data-testid={MESSAGE_TEXT_TEST_ID}>{original}</span>
</MessageCommentSurface>,
);
const text = screen.getByTestId(MESSAGE_TEXT_TEST_ID).firstChild!;
const range = document.createRange();
const start = original.indexOf(SELECTED_QUOTE);
range.setStart(text, start);
range.setEnd(text, start + SELECTED_QUOTE.length);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
fireEvent.mouseUp(screen.getByTestId(MESSAGE_TEXT_TEST_ID).parentElement!);
fireEvent.click(screen.getByTestId("agent-message-comment-trigger"));
rerender(
<MessageCommentSurface message={message(updated)} sessionId={SESSION_ID} isTurnActive={false}>
<span data-testid={MESSAGE_TEXT_TEST_ID}>{updated}</span>
</MessageCommentSurface>,
);
fireEvent.click(screen.getByRole("button", { name: "Save comment" }));
expect(screen.getByTestId("comment-popover")).not.toBeNull();
expect(Object.values(useCommentsStore.getState().byId)).toHaveLength(0);
expect(screen.getByRole("alert").textContent).toBe(
"The agent response changed. Select the text again.",
);
});
});
describe("MessageCommentSurface mobile drawer", () => {
it("keeps mobile feedback open when the selected quote was rewritten", () => {
TOUCH_DRAWER.enabled = true;
const original = "The settled answer contains detail.";
const updated = "The response was completely rewritten.";
const { rerender } = render(
<MessageCommentSurface
message={message(original)}
sessionId={SESSION_ID}
isTurnActive={false}
>
<span data-testid={MESSAGE_TEXT_TEST_ID}>{original}</span>
</MessageCommentSurface>,
);
const text = screen.getByTestId(MESSAGE_TEXT_TEST_ID).firstChild!;
const range = document.createRange();
const start = original.indexOf(SELECTED_QUOTE);
range.setStart(text, start);
range.setEnd(text, start + SELECTED_QUOTE.length);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
fireEvent.mouseUp(screen.getByTestId(MESSAGE_TEXT_TEST_ID).parentElement!);
fireEvent.click(screen.getByTestId("agent-message-comment-trigger"));
const input = screen.getByTestId("agent-message-comment-input");
fireEvent.change(input, { target: { value: COMMENT_TEXT } });
rerender(
<MessageCommentSurface message={message(updated)} sessionId={SESSION_ID} isTurnActive={false}>
<span data-testid={MESSAGE_TEXT_TEST_ID}>{updated}</span>
</MessageCommentSurface>,
);
fireEvent.click(screen.getByTestId("agent-message-comment-add"));
expect(screen.getByTestId("agent-message-comment-drawer")).not.toBeNull();
expect((input as HTMLTextAreaElement).value).toBe(COMMENT_TEXT);
expect(screen.getByRole("alert").textContent).toBe(
"The agent response changed. Select the text again.",
);
});
});
describe("MessageCommentSurface fallback highlights", () => {
it("renders visible ranges when the CSS Highlight API is unavailable", () => {
vi.stubGlobal("CSS", {});
vi.stubGlobal("Highlight", undefined);
const content = "A settled answer.";
addPendingComment(content, "settled");
render(
<MessageCommentSurface message={message(content)} sessionId={SESSION_ID} isTurnActive={false}>
<span>{content}</span>
</MessageCommentSurface>,
);
expect(
document.querySelector('[data-agent-message-comment-fallback][data-comment-id="comment-1"]'),
).not.toBeNull();
});
});
describe("MessageCommentSurface highlighted interactions", () => {
it("does not open a highlighted comment while text is selected", () => {
vi.spyOn(Range.prototype, "getClientRects").mockReturnValue([
new DOMRect(0, 0, 100, 20),
] as unknown as DOMRectList);
const content = "Read the settled answer.";
addPendingComment(content, "settled answer");
const { container } = render(
<MessageCommentSurface message={message(content)} sessionId={SESSION_ID} isTurnActive={false}>
<span>{content}</span>
</MessageCommentSurface>,
);
vi.spyOn(window, "getSelection").mockReturnValue({ isCollapsed: false } as Selection);
fireEvent.click(container.querySelector("[data-agent-message-body]")!, {
clientX: 10,
clientY: 10,
});
expect(screen.queryByTestId("comment-popover")).toBeNull();
});
it("preserves native link clicks inside a highlighted range", () => {
vi.spyOn(Range.prototype, "getClientRects").mockReturnValue([
new DOMRect(0, 0, 100, 20),
] as unknown as DOMRectList);
const content = "Read the docs.";
addPendingComment(content, "docs");
render(
<MessageCommentSurface message={message(content)} sessionId={SESSION_ID} isTurnActive={false}>
<a href="#docs">{content}</a>
</MessageCommentSurface>,
);
expect(fireEvent.click(screen.getByRole("link"), { clientX: 10, clientY: 10 })).toBe(true);
expect(screen.queryByTestId("comment-popover")).toBeNull();
});
});
@@ -0,0 +1,582 @@
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type Dispatch,
type MouseEvent,
type ReactNode,
type RefObject,
type SetStateAction,
} from "react";
import { createPortal } from "react-dom";
import { useShallow } from "zustand/react/shallow";
import { IconMessage, IconPlus, IconPlayerPlay, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@kandev/ui/drawer";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { PlanSelectionPopover } from "@/components/task/plan-selection-popover";
import { floatingBounds, placeFloatingRect } from "@/components/task/floating-selection-position";
import { useCommentsStore } from "@/lib/state/slices/comments";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import {
agentMessageCommentHighlightName,
createMessageTextAnchor,
getMessageSelection,
isSelectableAgentMessage,
resolveMessageTextAnchor,
type MessageSelection,
} from "@/lib/chat/agent-message-comments";
import type { Message } from "@/lib/types/http";
import { cn, generateUUID } from "@/lib/utils";
import {
messageCommentDecorationAtPoint,
supportsCustomHighlights,
useMessageCommentDecorations,
useMessageCommentShortcut,
} from "./use-message-comment-dom";
import {
MessageCommentDecorationOverlay,
MessageCustomHighlightStyle,
} from "./message-comment-decorations";
type MessageCommentSurfaceProps = {
message: Message;
sessionId?: string | null;
isTurnActive: boolean;
children: ReactNode;
};
type CommentTarget = {
selection: MessageSelection;
anchor: AgentMessageComment["anchor"];
position: { x: number; y: number };
editingCommentId?: string;
editingText?: string;
};
const EMPTY_COMMENT_IDS: string[] = [];
const INTERACTIVE_MESSAGE_TARGETS =
'a, button, input, textarea, select, summary, [role="button"], [role="link"], [contenteditable="true"]';
function isInteractiveMessageTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest(INTERACTIVE_MESSAGE_TARGETS));
}
function useDecorationClickHandler(
decorations: ReturnType<typeof useMessageCommentDecorations>,
openExistingComment: (commentId: string, position: { x: number; y: number }) => void,
) {
return useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (isInteractiveMessageTarget(event.target)) return;
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
const decoration = messageCommentDecorationAtPoint(decorations, event.clientX, event.clientY);
if (!decoration) return;
event.preventDefault();
openExistingComment(decoration.comment.id, { x: event.clientX, y: event.clientY });
},
[decorations, openExistingComment],
);
}
function commentFromTarget(
message: Message,
sessionId: string,
target: CommentTarget,
renderedText: string,
feedback: string,
): AgentMessageComment | null {
const resolved = resolveMessageTextAnchor(target.anchor, renderedText);
if (!resolved) return null;
const anchor = createMessageTextAnchor(message.id, renderedText, resolved.start, resolved.end);
return {
id: generateUUID(),
sessionId,
source: "agent-message",
messageId: message.id,
selectedText: anchor.selectedText,
text: feedback.trim(),
createdAt: new Date().toISOString(),
status: "pending",
anchor,
};
}
function SelectionCommentTrigger({
selection,
isTouch,
onOpen,
portalContainer,
}: {
selection: MessageSelection;
isTouch: boolean;
onOpen: () => void;
portalContainer?: HTMLElement | null;
}) {
const size = isTouch ? 44 : 28;
const portalRect = portalContainer?.getBoundingClientRect();
const outerSize = size + 8;
const position = placeFloatingRect({
left: selection.rect.right - outerSize,
topCandidates: [selection.rect.top - outerSize - 8, selection.rect.bottom + 8],
width: outerSize,
height: outerSize,
bounds: floatingBounds(portalRect),
});
return createPortal(
<div
className={cn(
"pointer-events-auto z-[60] rounded-lg border border-border/50 bg-popover p-1 shadow-lg",
portalContainer ? "absolute" : "fixed",
)}
style={{
left: position.left - (portalRect?.left ?? 0),
top: position.top - (portalRect?.top ?? 0),
}}
>
<button
type="button"
title="Comment (Cmd+Shift+C)"
aria-label="Comment on selection"
data-testid="agent-message-comment-trigger"
className="flex cursor-pointer items-center justify-center rounded bg-accent text-white transition-transform duration-150 ease-out hover:bg-accent/80 active:scale-[0.96]"
style={{ width: size, height: size }}
onMouseDown={(event) => event.preventDefault()}
onClick={onOpen}
>
<IconMessage className="h-3.5 w-3.5" />
</button>
</div>,
portalContainer ?? document.body,
);
}
function DrawerCommentActions({
isEditing,
disabled,
onAdd,
onRun,
onDelete,
}: {
isEditing: boolean;
disabled: boolean;
onAdd: () => void;
onRun?: () => void;
onDelete?: () => void;
}) {
return (
<div className="flex items-center justify-between gap-3">
{isEditing && onDelete ? (
<Button
type="button"
size="sm"
variant="ghost"
aria-label="Delete comment"
onClick={onDelete}
className="min-h-11 cursor-pointer px-3 text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-4 w-4" />
</Button>
) : (
<span />
)}
<div className="inline-flex">
<Button
type="button"
size="sm"
variant={onRun && !isEditing ? "outline" : "default"}
disabled={disabled}
onClick={onAdd}
data-testid="agent-message-comment-add"
className={`min-h-11 cursor-pointer gap-1.5 px-4 transition-transform duration-150 ease-out active:scale-[0.96] ${onRun && !isEditing ? "rounded-r-none border-r-0" : ""}`}
>
<IconPlus className="h-4 w-4" />
{isEditing ? "Update" : "Add"}
</Button>
{onRun && !isEditing ? (
<Button
type="button"
size="sm"
disabled={disabled}
onClick={onRun}
data-testid="agent-message-comment-run"
className="min-h-11 cursor-pointer gap-1.5 rounded-l-none px-4 transition-transform duration-150 ease-out active:scale-[0.96]"
>
<IconPlayerPlay className="h-4 w-4" />
Run
</Button>
) : null}
</div>
</div>
);
}
function MessageCommentDrawer({
target,
open,
onClose,
onAdd,
onRun,
onDelete,
errorMessage,
}: {
target: CommentTarget | null;
open: boolean;
onClose: () => void;
onAdd: (feedback: string) => boolean | void;
onRun: (feedback: string) => boolean | void;
onDelete: (() => void) | undefined;
errorMessage?: string | null;
}) {
const [feedback, setFeedback] = useState(target?.editingText ?? "");
const targetKey = `${target?.editingCommentId ?? "new"}:${target?.selection.start ?? 0}:${target?.selection.end ?? 0}`;
useEffect(() => {
setFeedback(target?.editingText ?? "");
}, [targetKey, target?.editingText]);
if (!target) return null;
const isEditing = target.editingCommentId !== undefined;
const submit = () => {
if (feedback.trim() && onAdd(feedback.trim()) !== false) onClose();
};
const run = () => {
if (feedback.trim() && onRun(feedback.trim()) !== false) onClose();
};
return (
<Drawer open={open} onOpenChange={(next) => !next && onClose()}>
<DrawerContent
className="z-[60] max-h-[82dvh] pb-[calc(1rem+env(safe-area-inset-bottom))]"
data-testid="agent-message-comment-drawer"
>
<DrawerHeader className="shrink-0 px-4 pb-3 text-left">
<DrawerTitle>{isEditing ? "Edit comment" : "Comment"}</DrawerTitle>
<DrawerDescription className="line-clamp-2 text-pretty italic">
&ldquo;{target.selection.selectedText}&rdquo;
</DrawerDescription>
</DrawerHeader>
<div className="min-h-0 overflow-y-auto px-4 pb-2">
<Textarea
value={feedback}
onChange={(event) => setFeedback(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return;
event.preventDefault();
if (event.shiftKey && !isEditing) run();
else submit();
}}
placeholder="Add your comment or instruction..."
aria-label="Comment on agent response"
className="mb-3 min-h-20 resize-none text-sm border-border/50 focus:border-primary/50"
autoFocus
data-testid="agent-message-comment-input"
/>
{errorMessage ? (
<p role="alert" className="mb-3 text-xs text-destructive">
{errorMessage}
</p>
) : null}
<DrawerCommentActions
isEditing={isEditing}
disabled={!feedback.trim()}
onAdd={submit}
onRun={isEditing ? undefined : run}
onDelete={onDelete}
/>
</div>
</DrawerContent>
</Drawer>
);
}
function usePendingCommentsForMessage(sessionId: string | null | undefined, messageId: string) {
return useCommentsStore(
useShallow((state) => {
const commentIds = sessionId
? (state.bySession[sessionId] ?? EMPTY_COMMENT_IDS)
: EMPTY_COMMENT_IDS;
return commentIds.flatMap((id) => {
const comment = state.byId[id];
return comment &&
comment.source === "agent-message" &&
comment.status === "pending" &&
comment.messageId === messageId
? [comment]
: [];
});
}),
);
}
function useCommentTargetState(
rootRef: RefObject<HTMLDivElement | null>,
isSelectable: boolean,
messageId: string,
) {
const [target, setTarget] = useState<CommentTarget | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const close = useCallback(() => {
setComposerOpen(false);
setTarget(null);
window.getSelection()?.removeAllRanges();
}, []);
const setNewSelection = useCallback(
(selection: MessageSelection, openComposer: boolean) => {
const renderedText = rootRef.current?.textContent ?? "";
setTarget({
selection,
anchor: createMessageTextAnchor(messageId, renderedText, selection.start, selection.end),
position: { x: selection.rect.right, y: selection.rect.bottom },
});
setComposerOpen(openComposer);
},
[messageId, rootRef],
);
const captureSelection = useCallback(() => {
if (!rootRef.current || !isSelectable) return;
const selection = getMessageSelection(rootRef.current, window.getSelection());
if (selection) setNewSelection(selection, false);
}, [isSelectable, rootRef, setNewSelection]);
const openFromShortcut = useCallback(
(selection: MessageSelection) => setNewSelection(selection, true),
[setNewSelection],
);
useMessageCommentShortcut(rootRef, isSelectable, openFromShortcut);
useEffect(() => {
if (!target || composerOpen) return;
const dismiss = (event: Event) => {
const element = event.target as HTMLElement | null;
if (element?.closest('[data-testid="agent-message-comment-trigger"]')) return;
setTarget(null);
};
const dismissOnScroll = () => setTarget(null);
document.addEventListener("pointerdown", dismiss, true);
document.addEventListener("scroll", dismissOnScroll, true);
return () => {
document.removeEventListener("pointerdown", dismiss, true);
document.removeEventListener("scroll", dismissOnScroll, true);
};
}, [composerOpen, target]);
return { target, setTarget, composerOpen, setComposerOpen, close, captureSelection };
}
function useExistingCommentHandlers(
rootRef: RefObject<HTMLDivElement | null>,
pendingComments: AgentMessageComment[],
setTarget: Dispatch<SetStateAction<CommentTarget | null>>,
setComposerOpen: Dispatch<SetStateAction<boolean>>,
) {
const openExistingComment = useCallback(
(commentId: string, position: { x: number; y: number }) => {
const comment = pendingComments.find((item) => item.id === commentId);
const root = rootRef.current;
if (!comment || !root) return;
const range = resolveMessageTextAnchor(comment.anchor, root.textContent ?? "");
if (!range) return;
setTarget({
selection: {
...range,
selectedText: comment.selectedText,
rect: new DOMRect(position.x, position.y, 0, 0),
},
anchor: comment.anchor,
position,
editingCommentId: comment.id,
editingText: comment.text,
});
setComposerOpen(true);
window.getSelection()?.removeAllRanges();
},
[pendingComments, rootRef, setComposerOpen, setTarget],
);
return openExistingComment;
}
function useMessageCommentActions({
message,
sessionId,
target,
rootRef,
close,
}: {
message: Message;
sessionId: string | null | undefined;
target: CommentTarget | null;
rootRef: RefObject<HTMLDivElement | null>;
close: () => void;
}) {
const addComment = useCommentsStore((state) => state.addComment);
const updateComment = useCommentsStore((state) => state.updateComment);
const removeComment = useCommentsStore((state) => state.removeComment);
const [saveError, setSaveError] = useState<string | null>(null);
const { runComment } = useRunComment({ sessionId: sessionId ?? null, taskId: message.task_id });
useEffect(() => setSaveError(null), [target]);
const saveComment = useCallback(
(feedback: string): AgentMessageComment | true | null => {
if (!target || !sessionId || !feedback.trim()) return null;
if (target.editingCommentId) {
setSaveError(null);
updateComment(target.editingCommentId, { text: feedback.trim() });
return true;
}
const renderedText = rootRef.current?.textContent ?? "";
const comment = commentFromTarget(message, sessionId, target, renderedText, feedback);
if (!comment) {
setSaveError("The agent response changed. Select the text again.");
return null;
}
setSaveError(null);
addComment(comment);
return comment;
},
[addComment, message, rootRef, sessionId, target, updateComment],
);
const handleAdd = useCallback(
(feedback: string) => {
if (!saveComment(feedback)) return false;
return true;
},
[saveComment],
);
const handleRun = useCallback(
(feedback: string) => {
const comment = saveComment(feedback);
if (!comment) return false;
if (comment !== true) {
void runComment(comment).catch((error) =>
console.error("Failed to run agent message comment:", error),
);
}
return true;
},
[runComment, saveComment],
);
const deleteComment = useCallback(() => {
if (!target?.editingCommentId) return;
removeComment(target.editingCommentId);
close();
}, [close, removeComment, target?.editingCommentId]);
const handleDelete = target?.editingCommentId ? deleteComment : undefined;
return { handleAdd, handleRun, handleDelete, saveError };
}
export function MessageCommentSurface({
message,
sessionId,
isTurnActive,
children,
}: MessageCommentSurfaceProps) {
const rootRef = useRef<HTMLDivElement>(null);
const highlightInstanceId = useId();
const useDrawer = useTouchDrawer();
const pendingComments = usePendingCommentsForMessage(sessionId, message.id);
const isSelectable = Boolean(sessionId) && isSelectableAgentMessage(message, isTurnActive, false);
const targetState = useCommentTargetState(rootRef, isSelectable, message.id);
const { target, composerOpen, setTarget, setComposerOpen, close, captureSelection } = targetState;
const openExistingComment = useExistingCommentHandlers(
rootRef,
pendingComments,
setTarget,
setComposerOpen,
);
const actions = useMessageCommentActions({ message, sessionId, target, rootRef, close });
const portalContainer = rootRef.current?.closest<HTMLElement>('[role="dialog"]');
const highlightName = agentMessageCommentHighlightName(`${message.id}-${highlightInstanceId}`);
const hasCustomHighlightSupport = supportsCustomHighlights();
const decorations = useMessageCommentDecorations(
rootRef,
pendingComments,
message.content,
highlightName,
);
const handleDecorationClick = useDecorationClickHandler(decorations, openExistingComment);
return (
<>
<div
ref={rootRef}
className="agent-message-comment-body relative"
data-agent-message-body="true"
data-message-id={message.id}
data-agent-message-highlight-name={highlightName}
onMouseUp={captureSelection}
onTouchEnd={captureSelection}
onClick={handleDecorationClick}
>
{children}
<MessageCommentDecorationOverlay
decorations={decorations}
useCustomHighlights={hasCustomHighlightSupport}
onOpen={openExistingComment}
/>
</div>
{pendingComments.length > 0 && hasCustomHighlightSupport ? (
<MessageCustomHighlightStyle highlightName={highlightName} />
) : null}
{target && !composerOpen ? (
<SelectionCommentTrigger
selection={target.selection}
isTouch={useDrawer}
onOpen={() => setComposerOpen(true)}
portalContainer={portalContainer}
/>
) : null}
{target && composerOpen && !useDrawer ? (
<PlanSelectionPopover
key={target.editingCommentId ?? `${target.selection.start}:${target.selection.end}`}
selectedText={target.selection.selectedText}
position={target.position}
onAdd={actions.handleAdd}
onAddAndRun={target.editingCommentId ? undefined : actions.handleRun}
onClose={close}
editingComment={target.editingText}
onDelete={actions.handleDelete}
testId="agent-message-comment-popover"
inputTestId="agent-message-comment-input"
addButtonTestId="agent-message-comment-add"
runButtonTestId="agent-message-comment-run"
portalContainer={portalContainer}
errorMessage={actions.saveError}
/>
) : null}
{useDrawer ? (
<MessageCommentDrawer
target={target}
open={Boolean(target && composerOpen)}
onClose={close}
onAdd={actions.handleAdd}
onRun={actions.handleRun}
onDelete={actions.handleDelete}
errorMessage={actions.saveError}
/>
) : null}
</>
);
}
@@ -21,6 +21,7 @@ type TurnGroupMessageProps = {
isLastGroup?: boolean;
/** Whether the turn is still active (agent is running) */
isTurnActive?: boolean;
streamingMessageId?: string | null;
onScrollToMessage?: (messageId: string) => void;
};
@@ -151,6 +152,8 @@ type TurnGroupContentProps = {
taskId?: string;
worktreePath?: string;
onOpenFile?: (path: string) => void;
isTurnActive?: boolean;
streamingMessageId?: string | null;
onScrollToMessage?: (messageId: string) => void;
};
@@ -276,6 +279,7 @@ function renderMessageEntry(message: Message, props: MessageRenderProps) {
childrenByParentToolCallId={props.childrenByParentToolCallId}
worktreePath={props.worktreePath}
sessionId={props.sessionId ?? undefined}
isTurnActive={props.isTurnActive && message.id === props.streamingMessageId}
onOpenFile={props.onOpenFile}
onScrollToMessage={props.onScrollToMessage}
/>
@@ -328,6 +332,8 @@ function TurnGroupContent({
taskId,
worktreePath,
onOpenFile,
isTurnActive,
streamingMessageId,
onScrollToMessage,
}: TurnGroupContentProps) {
const renderProps: MessageRenderProps = {
@@ -337,6 +343,8 @@ function TurnGroupContent({
taskId,
worktreePath,
onOpenFile,
isTurnActive,
streamingMessageId,
onScrollToMessage,
};
const compacted = useMemo(() => compactTurnGroupMessages(group.messages), [group.messages]);
@@ -363,6 +371,7 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({
onOpenFile,
isLastGroup = false,
isTurnActive = false,
streamingMessageId,
onScrollToMessage,
}: TurnGroupMessageProps) {
const isGroupRunning = hasRunningTool(group.messages);
@@ -400,6 +409,8 @@ export const TurnGroupMessage = memo(function TurnGroupMessage({
taskId={taskId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
isTurnActive={isTurnActive}
streamingMessageId={streamingMessageId}
onScrollToMessage={onScrollToMessage}
/>
)}
@@ -0,0 +1,149 @@
"use client";
import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
import {
getMessageCommentDecorations,
getMessageSelection,
type MessageCommentDecoration,
type MessageSelection,
} from "@/lib/chat/agent-message-comments";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
type ShortcutHandler = (selection: MessageSelection) => void;
type ShortcutManager = {
handlers: Map<HTMLElement, ShortcutHandler>;
listener: (event: KeyboardEvent) => void;
};
const shortcutManagers = new WeakMap<Document, ShortcutManager>();
function registerMessageCommentShortcut(root: HTMLElement, handler: ShortcutHandler) {
const ownerDocument = root.ownerDocument;
let manager = shortcutManagers.get(ownerDocument);
if (!manager) {
const handlers = new Map<HTMLElement, ShortcutHandler>();
const listener = (event: KeyboardEvent) => {
if (!((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "c"))
return;
const selection = ownerDocument.getSelection();
const anchorElement =
selection?.anchorNode instanceof Element
? selection.anchorNode
: selection?.anchorNode?.parentElement;
const selectedRoot = anchorElement?.closest<HTMLElement>("[data-agent-message-body]");
if (!selectedRoot) return;
const selectedHandler = handlers.get(selectedRoot);
if (!selectedHandler) return;
const messageSelection = getMessageSelection(selectedRoot, selection);
if (!messageSelection) return;
event.preventDefault();
event.stopImmediatePropagation();
selectedHandler(messageSelection);
};
manager = { handlers, listener };
shortcutManagers.set(ownerDocument, manager);
ownerDocument.addEventListener("keydown", listener, true);
}
manager.handlers.set(root, handler);
return () => {
const current = shortcutManagers.get(ownerDocument);
if (!current) return;
current.handlers.delete(root);
if (current.handlers.size > 0) return;
ownerDocument.removeEventListener("keydown", current.listener, true);
shortcutManagers.delete(ownerDocument);
};
}
export function useMessageCommentShortcut(
rootRef: RefObject<HTMLDivElement | null>,
isSelectable: boolean,
onSelect: ShortcutHandler,
) {
useEffect(() => {
const root = rootRef.current;
if (!isSelectable || !root) return;
return registerMessageCommentShortcut(root, onSelect);
}, [isSelectable, onSelect, rootRef]);
}
type HighlightRegistry = {
set: (name: string, highlight: unknown) => void;
delete: (name: string) => void;
};
type HighlightConstructor = new (...ranges: Range[]) => unknown;
function highlightRegistry() {
return (
globalThis as typeof globalThis & {
CSS?: typeof CSS & { highlights?: HighlightRegistry };
}
).CSS?.highlights;
}
export function supportsCustomHighlights() {
const HighlightClass = (globalThis as typeof globalThis & { Highlight?: HighlightConstructor })
.Highlight;
return Boolean(highlightRegistry() && HighlightClass);
}
function updateCustomHighlight(name: string, ranges: Range[]) {
const registry = highlightRegistry();
const HighlightClass = (globalThis as typeof globalThis & { Highlight?: HighlightConstructor })
.Highlight;
if (!registry || !HighlightClass) return;
if (ranges.length === 0) registry.delete(name);
else registry.set(name, new HighlightClass(...ranges));
}
export function useMessageCommentDecorations(
rootRef: RefObject<HTMLDivElement | null>,
comments: AgentMessageComment[],
messageContent: string,
highlightName: string,
): MessageCommentDecoration[] {
const [decorations, setDecorations] = useState<MessageCommentDecoration[]>([]);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
if (comments.length === 0) {
highlightRegistry()?.delete(highlightName);
setDecorations((current) => (current.length === 0 ? current : []));
return;
}
let disposed = false;
const refresh = () => {
if (disposed) return;
const next = getMessageCommentDecorations(root, comments);
updateCustomHighlight(
highlightName,
next.map((decoration) => decoration.range),
);
setDecorations(next);
};
refresh();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(refresh);
observer?.observe(root);
return () => {
disposed = true;
observer?.disconnect();
highlightRegistry()?.delete(highlightName);
};
}, [comments, highlightName, messageContent, rootRef]);
return decorations;
}
export function messageCommentDecorationAtPoint(
decorations: MessageCommentDecoration[],
x: number,
y: number,
) {
return decorations.find((decoration) =>
Array.from(decoration.range.getClientRects()).some(
(rect) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom,
),
);
}
@@ -13,23 +13,20 @@ import { useProcessedMessages } from "@/hooks/use-processed-messages";
import { useSessionModel } from "@/hooks/domains/session/use-session-model";
import { useQueue } from "@/hooks/domains/session/use-queue";
import { useContextFilesStore, type ContextFile } from "@/lib/state/context-files-store";
import {
useCommentsStore,
isPlanComment,
isPRFeedbackComment,
isWalkthroughComment,
} from "@/lib/state/slices/comments";
import { useCommentsStore, isPlanComment, type Comment } from "@/lib/state/slices/comments";
import { usePendingDiffCommentsByFile } from "@/hooks/domains/comments/use-diff-comments";
import {
usePendingPlanComments,
usePendingPRFeedback,
usePendingWalkthroughComments,
usePendingAgentMessageComments,
} from "@/hooks/domains/comments/use-pending-comments";
import { buildContextItems } from "../chat-context-items";
import { useAutoDisablePlanMode, usePlanLayoutHandlers } from "./use-plan-mode-helpers";
import type { ContextItem } from "@/lib/types/context";
import type { DiffComment } from "@/lib/diff/types";
import type {
AgentMessageComment,
PlanComment,
PRFeedbackComment,
WalkthroughComment,
@@ -53,6 +50,7 @@ export type CommentsState = {
pendingCommentsByFile: Record<string, DiffComment[]>;
pendingPRFeedback: PRFeedbackComment[];
walkthroughComments: WalkthroughComment[];
messageComments: AgentMessageComment[];
markCommentsSent: (ids: string[]) => void;
handleRemoveCommentFile: (filePath: string) => void;
handleRemoveComment: (commentId: string) => void;
@@ -60,9 +58,19 @@ export type CommentsState = {
handleClearPRFeedback: () => void;
handleRemoveWalkthroughComment: (commentId: string) => void;
handleClearWalkthroughComments: () => void;
handleClearMessageComments: () => void;
clearSessionPlanComments: () => void;
};
function removePendingCommentsForSession(sessionId: string | null, source: Comment["source"]) {
if (!sessionId) return;
const state = useCommentsStore.getState();
for (const id of [...state.pendingForChat]) {
const comment = state.byId[id];
if (comment?.sessionId === sessionId && comment.source === source) state.removeComment(id);
}
}
/** Re-focus chat input after dockview layout rebuild. */
function useRefocusChatAfterLayout() {
return useCallback(() => {
@@ -257,11 +265,11 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat
useEffect(() => {
if (resolvedSessionId) hydrateComments(resolvedSessionId);
}, [resolvedSessionId, hydrateComments]);
// Filter pending comments by sessionId to prevent cross-session leakage
const planComments = usePendingPlanComments(resolvedSessionId);
const pendingCommentsByFile = usePendingDiffCommentsByFile(resolvedSessionId);
const pendingPRFeedback = usePendingPRFeedback(resolvedSessionId);
const walkthroughComments = usePendingWalkthroughComments(resolvedSessionId);
const messageComments = usePendingAgentMessageComments(resolvedSessionId);
const markCommentsSent = useCommentsStore((state) => state.markCommentsSent);
const removeComment = useCommentsStore((state) => state.removeComment);
const clearSessionPlanComments = useCallback(() => {
@@ -289,37 +297,24 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat
[removeComment],
);
const handleClearPRFeedback = useCallback(() => {
if (!resolvedSessionId) return;
const state = useCommentsStore.getState();
const allPending = [...state.pendingForChat];
for (const id of allPending) {
const c = state.byId[id];
// Only clear PR feedback for the current session
if (c && isPRFeedbackComment(c) && c.sessionId === resolvedSessionId) {
state.removeComment(id);
}
}
removePendingCommentsForSession(resolvedSessionId, "pr-feedback");
}, [resolvedSessionId]);
const handleRemoveWalkthroughComment = useCallback(
(commentId: string) => removeComment(commentId),
[removeComment],
);
const handleClearWalkthroughComments = useCallback(() => {
if (!resolvedSessionId) return;
const state = useCommentsStore.getState();
const allPending = [...state.pendingForChat];
for (const id of allPending) {
const c = state.byId[id];
if (c && isWalkthroughComment(c) && c.sessionId === resolvedSessionId) {
state.removeComment(id);
}
}
removePendingCommentsForSession(resolvedSessionId, "walkthrough");
}, [resolvedSessionId]);
const handleClearMessageComments = useCallback(() => {
removePendingCommentsForSession(resolvedSessionId, "agent-message");
}, [resolvedSessionId]);
return {
planComments,
pendingCommentsByFile,
pendingPRFeedback,
walkthroughComments,
messageComments,
markCommentsSent,
handleRemoveCommentFile,
handleRemoveComment,
@@ -327,6 +322,7 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat
handleClearPRFeedback,
handleRemoveWalkthroughComment,
handleClearWalkthroughComments,
handleClearMessageComments,
clearSessionPlanComments,
};
}
@@ -387,6 +383,8 @@ function useChatContextItems(opts: ChatContextItemsOptions) {
walkthroughComments: comments.walkthroughComments,
handleRemoveWalkthroughComment: comments.handleRemoveWalkthroughComment,
handleClearWalkthroughComments: comments.handleClearWalkthroughComments,
messageComments: comments.messageComments,
handleClearMessageComments: comments.handleClearMessageComments,
taskId,
}),
[
@@ -410,6 +408,8 @@ function useChatContextItems(opts: ChatContextItemsOptions) {
comments.walkthroughComments,
comments.handleRemoveWalkthroughComment,
comments.handleClearWalkthroughComments,
comments.messageComments,
comments.handleClearMessageComments,
taskId,
],
);
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { placeFloatingRect, type FloatingBounds } from "./floating-selection-position";
describe("placeFloatingRect", () => {
const dialogBounds: FloatingBounds = { left: 100, top: 50, right: 500, bottom: 450 };
it("clamps horizontal placement to the portal container", () => {
expect(
placeFloatingRect({
left: 480,
topCandidates: [100],
width: 340,
height: 180,
bounds: dialogBounds,
}),
).toEqual({ left: 152, top: 100 });
});
it("uses the first fitting vertical candidate within the container", () => {
expect(
placeFloatingRect({
left: 120,
topCandidates: [430, 246],
width: 40,
height: 180,
bounds: dialogBounds,
}),
).toEqual({ left: 120, top: 246 });
});
});
@@ -0,0 +1,45 @@
export type FloatingBounds = {
left: number;
top: number;
right: number;
bottom: number;
};
export function floatingBounds(rect?: Pick<DOMRect, "left" | "top" | "right" | "bottom"> | null) {
return rect
? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
: { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
export function placeFloatingRect({
left,
topCandidates,
width,
height,
bounds,
margin = 8,
}: {
left: number;
topCandidates: number[];
width: number;
height: number;
bounds: FloatingBounds;
margin?: number;
}) {
const minimumLeft = bounds.left + margin;
const maximumLeft = Math.max(minimumLeft, bounds.right - width - margin);
const minimumTop = bounds.top + margin;
const maximumTop = Math.max(minimumTop, bounds.bottom - height - margin);
const fittingTop = topCandidates.find(
(candidate) => candidate >= minimumTop && candidate <= maximumTop,
);
return {
left: clamp(left, minimumLeft, maximumLeft),
top: fittingTop ?? clamp(topCandidates[0] ?? minimumTop, minimumTop, maximumTop),
};
}
@@ -40,6 +40,27 @@ function comment(id = "comment-1"): DiffComment {
};
}
function messageComment() {
return {
id: "message-comment-1",
sessionId: SESSION_ID,
source: "agent-message",
messageId: "reply-1",
selectedText: "settled answer",
anchor: {
messageId: "reply-1",
start: 0,
end: 14,
selectedText: "settled answer",
prefix: "",
suffix: "",
},
text: "Please expand this.",
createdAt: "2026-07-20T00:00:00.000Z",
status: "pending",
} as never;
}
function panelState(overrides: Record<string, unknown> = {}) {
return {
resolvedSessionId: SESSION_ID,
@@ -49,10 +70,12 @@ function panelState(overrides: Record<string, unknown> = {}) {
pendingPRFeedback: [],
planComments: [],
walkthroughComments: [],
messageComments: [],
planModeEnabled: false,
handleClearPRFeedback: vi.fn(),
clearSessionPlanComments: vi.fn(),
handleClearWalkthroughComments: vi.fn(),
handleClearMessageComments: vi.fn(),
clearEphemeral: vi.fn(),
addContextFile: vi.fn(),
...overrides,
@@ -85,6 +108,18 @@ describe("passthrough chat composer metadata helpers", () => {
expect(result.formatted).toContain("Ship it");
});
it("includes pending agent message comments in passthrough context once", () => {
const result = formatPassthroughBaseMessage(
"Ship it",
undefined,
[],
panelState({ messageComments: [messageComment()] }),
);
expect(result.formatted.match(/### Agent Message Comments/g)).toHaveLength(1);
expect(result.commentsToSend.map((item) => item.id)).toEqual(["message-comment-1"]);
});
it("merges selected context files with inline file, prompt, and task mentions", async () => {
const inlineTask: TaskMentionData = {
taskId: "task-2",
@@ -211,10 +246,12 @@ describe("passthrough chat composer cleanup", () => {
pendingPRFeedback: [{ id: "feedback-1" }],
planComments: [{ id: "plan-comment-1" }],
walkthroughComments: [{ id: "walkthrough-comment-1" }],
messageComments: [messageComment()],
}) as unknown as {
handleClearPRFeedback: ReturnType<typeof vi.fn>;
clearSessionPlanComments: ReturnType<typeof vi.fn>;
handleClearWalkthroughComments: ReturnType<typeof vi.fn>;
handleClearMessageComments: ReturnType<typeof vi.fn>;
clearEphemeral: ReturnType<typeof vi.fn>;
addContextFile: ReturnType<typeof vi.fn>;
};
@@ -224,6 +261,7 @@ describe("passthrough chat composer cleanup", () => {
expect(state.handleClearPRFeedback).toHaveBeenCalledTimes(1);
expect(state.clearSessionPlanComments).toHaveBeenCalledTimes(1);
expect(state.handleClearWalkthroughComments).toHaveBeenCalledTimes(1);
expect(state.handleClearMessageComments).toHaveBeenCalledTimes(1);
expect(state.clearEphemeral).toHaveBeenCalledWith(SESSION_ID);
expect(state.addContextFile).toHaveBeenCalledWith(SESSION_ID, {
path: PLAN_CONTEXT_PATH,
@@ -13,6 +13,7 @@ import {
} from "./chat/chat-input-container";
import type { useChatPanelState } from "./chat/use-chat-panel-state";
import type { DiffComment } from "@/lib/diff/types";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
import { buildContextFilesContext, buildTaskMentionsContext } from "@/hooks/use-message-handler";
@@ -52,7 +53,8 @@ export function PassthroughComposerPanel({
const hasContextComments =
panelState.planComments.length > 0 ||
panelState.pendingPRFeedback.length > 0 ||
panelState.walkthroughComments.length > 0;
panelState.walkthroughComments.length > 0 ||
panelState.messageComments.length > 0;
return (
<div
data-testid="passthrough-composer"
@@ -97,7 +99,7 @@ export function PassthroughComposerPanel({
type PassthroughFinalMessage = {
content: string;
commentsToSend: DiffComment[];
commentsToSend: Array<DiffComment | AgentMessageComment>;
contextFilesMeta?: Array<{ path: string; name: string }>;
};
@@ -108,21 +110,24 @@ export function formatPassthroughBaseMessage(
panelState: ReturnType<typeof useChatPanelState>,
) {
const commentsToSend = reviewComments ?? pendingComments;
const messageComments = panelState.messageComments;
const hasStructuredComments =
!!reviewComments ||
panelState.pendingPRFeedback.length > 0 ||
panelState.planComments.length > 0 ||
panelState.walkthroughComments.length > 0;
panelState.walkthroughComments.length > 0 ||
messageComments.length > 0;
if (hasStructuredComments) {
return {
formatted: buildSubmitMessage(
content,
commentsToSend.length > 0 ? commentsToSend : undefined,
panelState.pendingPRFeedback,
panelState.planComments,
panelState.walkthroughComments,
),
commentsToSend,
formatted: buildSubmitMessage({
message: content,
reviewComments: commentsToSend.length > 0 ? commentsToSend : undefined,
pendingPRFeedback: panelState.pendingPRFeedback,
planComments: panelState.planComments,
walkthroughComments: panelState.walkthroughComments,
messageComments,
}),
commentsToSend: [...commentsToSend, ...messageComments],
};
}
if (pendingComments.length > 0) {
@@ -258,6 +263,9 @@ export function clearPassthroughComposerContext(panelState: ReturnType<typeof us
if (panelState.walkthroughComments.length > 0) {
panelState.handleClearWalkthroughComments();
}
if (panelState.messageComments.length > 0) {
panelState.handleClearMessageComments();
}
if (!panelState.resolvedSessionId) return;
panelState.clearEphemeral(panelState.resolvedSessionId);
if (panelState.planModeEnabled) {
@@ -160,6 +160,8 @@ vi.mock("./chat/use-chat-panel-state", () => ({
planComments: [],
pendingPRFeedback: [],
walkthroughComments: [],
messageComments: [],
handleClearMessageComments: vi.fn(),
pendingCommentsByFile: mockPendingByFile,
}),
}));
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { PlanSelectionPopover } from "./plan-selection-popover";
afterEach(cleanup);
describe("PlanSelectionPopover", () => {
it("keeps entered feedback open when the submitter rejects the save", () => {
const onClose = vi.fn();
render(
<PlanSelectionPopover
selectedText="settled answer"
position={{ x: 100, y: 100 }}
onAdd={() => false}
onClose={onClose}
/>,
);
const input = screen.getByPlaceholderText("Add your comment or instruction...");
fireEvent.change(input, { target: { value: "Keep this feedback" } });
fireEvent.click(screen.getByRole("button", { name: "Add" }));
expect(onClose).not.toHaveBeenCalled();
expect((input as HTMLTextAreaElement).value).toBe("Keep this feedback");
});
});
@@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { floatingBounds, placeFloatingRect } from "@/components/task/floating-selection-position";
type SelectionPosition = {
x: number;
@@ -16,37 +17,23 @@ type SelectionPosition = {
type PlanSelectionPopoverProps = {
selectedText: string;
position: SelectionPosition;
onAdd: (comment: string, selectedText: string) => void;
onAddAndRun?: (comment: string, selectedText: string) => void;
onAdd: (comment: string, selectedText: string) => boolean | void;
onAddAndRun?: (comment: string, selectedText: string) => boolean | void;
onClose: () => void;
editingComment?: string;
onDelete?: () => void;
testId?: string;
inputTestId?: string;
addButtonTestId?: string;
runButtonTestId?: string;
portalContainer?: HTMLElement | null;
errorMessage?: string | null;
};
const POPOVER_WIDTH = 340;
const POPOVER_HEIGHT = 180;
const MARGIN = 8;
function computePopoverPosition(position: SelectionPosition): { left: number; top: number } {
// Place directly below cursor, aligned to left of click
let left = position.x;
let top = position.y + 4;
// Clamp horizontal — keep popover on screen
if (left + POPOVER_WIDTH > window.innerWidth - MARGIN) {
left = window.innerWidth - POPOVER_WIDTH - MARGIN;
}
if (left < MARGIN) left = MARGIN;
// If overflows bottom, flip above cursor
if (top + POPOVER_HEIGHT > window.innerHeight - MARGIN) {
top = position.y - POPOVER_HEIGHT - 4;
}
if (top < MARGIN) top = MARGIN;
return { left, top };
}
/** Drag support for the popover. */
function useDrag() {
const [offset, setOffset] = useState({ dx: 0, dy: 0 });
@@ -111,20 +98,18 @@ function usePopoverDismiss(
function usePopoverComposer(
comment: string,
selectedText: string,
onAdd: (comment: string, selectedText: string) => void,
onAdd: (comment: string, selectedText: string) => boolean | void,
onClose: () => void,
onAddAndRun?: (comment: string, selectedText: string) => void,
onAddAndRun?: (comment: string, selectedText: string) => boolean | void,
) {
const handleSubmit = useCallback(() => {
if (!comment.trim()) return;
onAdd(comment.trim(), selectedText);
onClose();
if (onAdd(comment.trim(), selectedText) !== false) onClose();
}, [comment, onAdd, selectedText, onClose]);
const handleSubmitAndRun = useCallback(() => {
if (!comment.trim() || !onAddAndRun) return;
onAddAndRun(comment.trim(), selectedText);
onClose();
if (onAddAndRun(comment.trim(), selectedText) !== false) onClose();
}, [comment, onAddAndRun, selectedText, onClose]);
const handleKeyDown = useCallback(
@@ -157,12 +142,16 @@ function PopoverActions({
onSubmit,
onSubmitAndRun,
onDelete,
addButtonTestId,
runButtonTestId,
}: {
isEditing: boolean;
isDisabled: boolean;
onSubmit: () => void;
onSubmitAndRun?: () => void;
onDelete?: () => void;
addButtonTestId?: string;
runButtonTestId?: string;
}) {
return (
<div className="mt-2 flex items-center justify-between">
@@ -176,6 +165,7 @@ function PopoverActions({
size="sm"
variant="ghost"
onClick={onDelete}
aria-label="Delete comment"
className="h-6 px-1.5 text-muted-foreground hover:text-destructive cursor-pointer"
>
<IconTrash className="h-3 w-3" />
@@ -191,6 +181,7 @@ function PopoverActions({
variant={onSubmitAndRun && !isEditing ? "outline" : "default"}
onClick={onSubmit}
disabled={isDisabled}
data-testid={addButtonTestId}
className={`h-7 gap-1 text-xs cursor-pointer ${onSubmitAndRun && !isEditing ? "rounded-r-none border-r-0" : ""}`}
>
<IconPlus className="h-3 w-3" />
@@ -208,6 +199,7 @@ function PopoverActions({
size="sm"
onClick={onSubmitAndRun}
disabled={isDisabled}
data-testid={runButtonTestId}
className="h-7 gap-1 rounded-l-none text-xs cursor-pointer"
>
<IconPlayerPlay className="h-3 w-3" />
@@ -233,6 +225,12 @@ export function PlanSelectionPopover({
onClose,
editingComment,
onDelete,
testId,
inputTestId,
addButtonTestId,
runButtonTestId,
portalContainer,
errorMessage,
}: PlanSelectionPopoverProps) {
const [comment, setComment] = useState(editingComment || "");
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -246,7 +244,15 @@ export function PlanSelectionPopover({
const effectiveOnAddAndRun = editingComment ? undefined : onAddAndRun;
const { handleSubmit, handleSubmitAndRun, handleKeyDown, isDisabled, previewText } =
usePopoverComposer(comment, selectedText, onAdd, onClose, effectiveOnAddAndRun);
const { left, top } = computePopoverPosition(position);
const portalRect = portalContainer?.getBoundingClientRect();
const { left, top } = placeFloatingRect({
left: position.x,
topCandidates: [position.y + 4, position.y - POPOVER_HEIGHT - 4],
width: POPOVER_WIDTH,
height: POPOVER_HEIGHT,
bounds: floatingBounds(portalRect),
margin: MARGIN,
});
const handleDelete = onDelete
? () => {
@@ -259,10 +265,16 @@ export function PlanSelectionPopover({
<div
ref={popoverRef}
className={cn(
"fixed z-50 rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl",
"pointer-events-auto z-[60] rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl",
"animate-in fade-in-0 zoom-in-95 duration-150",
portalContainer ? "absolute" : "fixed",
)}
style={{ width: POPOVER_WIDTH, left: left + offset.dx, top: top + offset.dy }}
data-testid={testId}
style={{
width: POPOVER_WIDTH,
left: left + offset.dx - (portalRect?.left ?? 0),
top: top + offset.dy - (portalRect?.top ?? 0),
}}
>
<div
onMouseDown={onMouseDown}
@@ -281,16 +293,24 @@ export function PlanSelectionPopover({
onKeyDown={handleKeyDown}
placeholder="Add your comment or instruction..."
className="min-h-[60px] resize-none text-sm border-border/50 focus:border-primary/50"
data-testid={inputTestId}
/>
{errorMessage ? (
<p role="alert" className="mt-2 text-xs text-destructive">
{errorMessage}
</p>
) : null}
<PopoverActions
isEditing={!!editingComment}
isDisabled={isDisabled}
onSubmit={handleSubmit}
onSubmitAndRun={effectiveOnAddAndRun ? handleSubmitAndRun : undefined}
onDelete={handleDelete}
addButtonTestId={addButtonTestId}
runButtonTestId={runButtonTestId}
/>
</div>
</div>,
document.body,
portalContainer ?? document.body,
);
}
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http";
const mocks = vi.hoisted(() => ({
getWebSocketClient: vi.fn(),
onSend: null as null | ((content: string) => Promise<void>),
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: mocks.getWebSocketClient,
}));
vi.mock("./task-chat-panel", () => ({
TaskChatPanel: ({ onSend }: { onSend: (content: string) => Promise<void> }) => {
mocks.onSend = onSend;
return <div data-testid="preview-chat" />;
},
}));
import { PreviewSessionBody } from "./preview-session-tabs";
const session: TaskSession = {
id: toSessionId("session-1"),
task_id: toTaskId("task-1"),
state: "COMPLETED",
started_at: "2026-07-21T00:00:00Z",
updated_at: "2026-07-21T00:00:00Z",
};
afterEach(() => {
cleanup();
mocks.getWebSocketClient.mockReset();
mocks.onSend = null;
});
describe("PreviewSessionBody send failures", () => {
it("rejects when the WebSocket client is unavailable", async () => {
mocks.getWebSocketClient.mockReturnValue(null);
render(<PreviewSessionBody session={session} taskId="task-1" />);
await expect(mocks.onSend?.("hello")).rejects.toMatchObject({
name: "MessageSendError",
code: "connection-unavailable",
message: "Connection unavailable. Reconnect and try again.",
});
});
it("rethrows message.add failures to the chat input", async () => {
const error = new Error("message.add failed");
mocks.getWebSocketClient.mockReturnValue({ request: vi.fn().mockRejectedValue(error) });
render(<PreviewSessionBody session={session} taskId="task-1" />);
await expect(mocks.onSend?.("hello")).rejects.toBe(error);
});
});
@@ -6,11 +6,11 @@ import { GridSpinner } from "@/components/grid-spinner";
import { PanelLoadingState } from "@/components/panel-loading-state";
import { SessionTabs, type SessionTab } from "@/components/session-tabs";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session";
import type { AgentProfileOption } from "@/lib/state/slices";
import { MessageSendError } from "@/lib/chat/message-send-error";
import type { TaskSession } from "@/lib/types/http";
import { getWebSocketClient } from "@/lib/ws/connection";
import { EnsureSessionErrorEmptyState } from "./ensure-session-error";
@@ -149,24 +149,23 @@ function SessionAgentLogo({ profile }: { profile: AgentProfileOption | null | un
return <AgentLogo agentName={profile.agent_name} size={12} className="shrink-0" />;
}
function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) {
const { toast } = useToast();
export function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) {
const handleSendMessage = useCallback(
async (content: string) => {
const client = getWebSocketClient();
if (!client) return;
try {
await client.request(
"message.add",
{ task_id: taskId, session_id: session.id, content },
10000,
if (!client) {
throw new MessageSendError(
"connection-unavailable",
"Connection unavailable. Reconnect and try again.",
);
} catch (error) {
console.error("Failed to send message:", error);
toast({ title: "Failed to send message", variant: "error" });
}
await client.request(
"message.add",
{ task_id: taskId, session_id: session.id, content },
10000,
);
},
[taskId, session.id, toast],
[taskId, session.id],
);
if (session.is_passthrough) {
+11
View File
@@ -493,6 +493,17 @@ export class ApiClient {
});
}
async startQuickChat(
workspaceId: string,
agentProfileId: string,
title: string,
): Promise<{ task_id: string; session_id: string }> {
return this.request("POST", `/api/v1/workspaces/${workspaceId}/quick-chat`, {
title,
agent_profile_id: agentProfileId,
});
}
/** Start a config chat session via the dedicated config-chat endpoint. */
async startConfigChat(
workspaceId: string,
@@ -0,0 +1,145 @@
import type { Locator, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { SessionPage } from "../../pages/session-page";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
export const AGENT_REPLY = "The settled answer contains a useful detail.";
export const SELECTED_REPLY_TEXT = "settled answer";
export async function expectAgentMessageHighlight(body: Locator, expectedCount: number) {
const highlightName = await body.getAttribute("data-agent-message-highlight-name");
if (!highlightName) throw new Error("Expected an agent message highlight name");
await expect
.poll(() =>
body.evaluate((_, name) => {
const registry = (
CSS as typeof CSS & {
highlights?: { get: (key: string) => { size: number } | undefined };
}
).highlights;
return registry?.get(name)?.size ?? 0;
}, highlightName),
)
.toBe(expectedCount);
}
export async function clickAgentMessageHighlight(body: Locator, selectedText: string) {
const position = await body.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
const value = node.nodeValue ?? "";
const start = value.indexOf(text);
if (start >= 0) {
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, start + text.length);
const rangeRect = range.getBoundingClientRect();
const bodyRect = element.getBoundingClientRect();
return {
x: rangeRect.left - bodyRect.left + rangeRect.width / 2,
y: rangeRect.top - bodyRect.top + rangeRect.height / 2,
};
}
node = walker.nextNode();
}
throw new Error(`Could not locate highlighted text: ${text}`);
}, selectedText);
await body.click({ position });
}
export async function openSeededAgentReply(
page: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: 'e2e:message("ready")',
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await page.goto(`/t/${task.id}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await apiClient.seedSessionMessage(task.session_id, {
type: "message",
content: AGENT_REPLY,
});
const body = session.activeChat().locator(`[data-agent-message-body][data-message-id]`).filter({
hasText: AGENT_REPLY,
});
await expect(body).toBeVisible({ timeout: 15_000 });
return { task, session, body };
}
export async function selectAgentReplyText(
body: ReturnType<SessionPage["activeChat"]>,
selectedText: string,
) {
await body.evaluate((element, selectedText) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
const value = node.nodeValue ?? "";
const start = value.indexOf(selectedText);
if (start >= 0) {
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, start + selectedText.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
return;
}
node = walker.nextNode();
}
throw new Error(`Could not find selected text: ${selectedText}`);
}, selectedText);
}
export async function openSeededQuickChatReply(
page: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
) {
const quickChat = await apiClient.startQuickChat(
seedData.workspaceId,
seedData.agentProfileId,
title,
);
await page.goto("/");
await page.waitForLoadState("networkidle");
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Shift+q`);
const dialog = page.getByRole("dialog", { name: "Quick Chat" });
await expect(dialog).toBeVisible({ timeout: 15_000 });
await expect(dialog.getByTestId("quick-chat-messages")).toBeVisible();
await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 30_000 });
await apiClient.seedSessionMessage(quickChat.session_id, {
type: "message",
content: AGENT_REPLY,
});
const body = dialog
.getByTestId("quick-chat-messages")
.locator("[data-agent-message-body]")
.filter({
hasText: AGENT_REPLY,
});
await expect(body).toBeVisible({ timeout: 15_000 });
return { dialog, body };
}
@@ -0,0 +1,98 @@
import { test, expect } from "../../fixtures/test-base";
import {
AGENT_REPLY,
SELECTED_REPLY_TEXT,
clickAgentMessageHighlight,
expectAgentMessageHighlight,
openSeededAgentReply,
openSeededQuickChatReply,
selectAgentReplyText,
} from "./agent-message-comments-helpers";
test.describe("Agent message comments", () => {
test("adds pending context and restores the highlight after reload", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { session, body } = await openSeededAgentReply(
testPage,
apiClient,
seedData,
"Agent Message Comments",
);
await selectAgentReplyText(body, SELECTED_REPLY_TEXT);
const commentTrigger = testPage.getByTestId("agent-message-comment-trigger");
await expect(commentTrigger).toBeVisible();
const popover = testPage.getByTestId("agent-message-comment-popover");
await expect(popover).not.toBeVisible();
await commentTrigger.click();
await expect(popover).toBeVisible();
await popover.getByTestId("agent-message-comment-input").fill("Please expand this detail.");
await popover.getByTestId("agent-message-comment-add").click();
await expect(popover).not.toBeVisible();
await expect(
session.activeChat().getByText("1 message comment", { exact: true }),
).toBeVisible();
await expectAgentMessageHighlight(body, 1);
await expect(body.locator("mark[data-agent-message-comment-id]")).toHaveCount(0);
await expect(body.locator(".comment-badge[data-comment-id]")).toHaveCount(1);
await testPage.reload();
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
const restoredBody = session
.activeChat()
.locator(`[data-agent-message-body][data-message-id]`)
.filter({
hasText: AGENT_REPLY,
});
await expectAgentMessageHighlight(restoredBody, 1);
await expect(restoredBody.locator(".comment-badge[data-comment-id]")).toHaveCount(1);
await expect(
session.activeChat().getByText("1 message comment", { exact: true }),
).toBeVisible();
// Saved highlights and badges both use the same edit/delete loop as plan comments.
await clickAgentMessageHighlight(restoredBody, SELECTED_REPLY_TEXT);
await expect(popover).toBeVisible();
const input = popover.getByTestId("agent-message-comment-input");
await expect(input).toHaveValue("Please expand this detail.");
await input.fill("Please make this detail concrete.");
await popover.getByRole("button", { name: "Update", exact: true }).click();
await restoredBody.locator(".comment-badge[data-comment-id] svg").click();
await expect(input).toHaveValue("Please make this detail concrete.");
await popover.getByRole("button", { name: "Delete comment" }).click();
await expectAgentMessageHighlight(restoredBody, 0);
await expect(restoredBody.locator(".comment-badge[data-comment-id]")).toHaveCount(0);
await expect(
session.activeChat().getByText("1 message comment", { exact: true }),
).not.toBeVisible();
});
test("wires pending context into Quick Chat", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(90_000);
const { dialog, body } = await openSeededQuickChatReply(
testPage,
apiClient,
seedData,
"Quick Chat Message Comments",
);
await selectAgentReplyText(body, SELECTED_REPLY_TEXT);
await testPage.getByTestId("agent-message-comment-trigger").click();
const popover = testPage.getByTestId("agent-message-comment-popover");
await expect(popover).toBeVisible();
await popover
.getByTestId("agent-message-comment-input")
.fill("Keep this context in Quick Chat.");
await popover.getByTestId("agent-message-comment-add").click();
await expect(dialog.getByText("1 message comment", { exact: true })).toBeVisible();
await expectAgentMessageHighlight(body, 1);
await expect(body.locator(".comment-badge[data-comment-id]")).toHaveCount(1);
});
});
@@ -0,0 +1,110 @@
import { test, expect } from "../../fixtures/test-base";
import type { Locator, Page } from "@playwright/test";
import {
openSeededAgentReply,
openSeededQuickChatReply,
SELECTED_REPLY_TEXT,
selectAgentReplyText,
} from "./agent-message-comments-helpers";
async function expectTouchTarget(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
if (!box) throw new Error("Expected a visible touch target");
expect(box.height).toBeGreaterThanOrEqual(44);
}
async function expectDrawerContained(page: Page, drawer: Locator) {
const viewport = page.viewportSize();
if (!viewport) throw new Error("Expected a mobile viewport");
await expect
.poll(async () => {
const box = await drawer.boundingBox();
return box ? box.y + box.height : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(viewport.height + 1);
const box = await drawer.boundingBox();
if (!box) throw new Error("Expected a visible comment drawer");
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1);
}
test.describe("Agent message comments on mobile", () => {
test("opens the native drawer and Run sends the comment immediately", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { task, body } = await openSeededAgentReply(
testPage,
apiClient,
seedData,
"Mobile Agent Message Comments",
);
await selectAgentReplyText(body, SELECTED_REPLY_TEXT);
const commentTrigger = testPage.getByTestId("agent-message-comment-trigger");
await expect(commentTrigger).toBeVisible();
await expectTouchTarget(commentTrigger);
await commentTrigger.click();
const drawer = testPage.getByTestId("agent-message-comment-drawer");
await expect(drawer).toBeVisible();
await expectDrawerContained(testPage, drawer);
const runButton = drawer.getByTestId("agent-message-comment-run");
const addButton = drawer.getByTestId("agent-message-comment-add");
await expectTouchTarget(runButton);
await expectTouchTarget(addButton);
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
await drawer.getByTestId("agent-message-comment-input").fill("Run with this correction.");
await runButton.click();
await expect(drawer).not.toBeVisible({ timeout: 15_000 });
await expect
.poll(
async () => {
const { messages } = await apiClient.listSessionMessages(task.session_id!);
return messages.some(
(message) =>
message.author_type === "user" &&
message.content.includes("### Agent Message Comments") &&
message.content.includes(SELECTED_REPLY_TEXT) &&
message.content.includes("Run with this correction."),
);
},
{ timeout: 20_000 },
)
.toBe(true);
});
test("keeps the comment drawer interactive inside Quick Chat", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const { dialog, body } = await openSeededQuickChatReply(
testPage,
apiClient,
seedData,
"Mobile Quick Chat Message Comments",
);
await selectAgentReplyText(body, SELECTED_REPLY_TEXT);
await testPage.getByTestId("agent-message-comment-trigger").click();
const drawer = testPage.getByTestId("agent-message-comment-drawer");
await expect(drawer).toBeVisible();
const input = drawer.getByTestId("agent-message-comment-input");
await input.fill("Keep this mobile Quick Chat context.");
await expect(input).toHaveValue("Keep this mobile Quick Chat context.");
await drawer.getByTestId("agent-message-comment-add").click();
await expect(drawer).not.toBeVisible();
await expect(dialog.getByText("1 message comment", { exact: true })).toBeVisible();
});
});
@@ -30,7 +30,7 @@ async function seedMobileTaskWithPlan(testPage: Page, apiClient: ApiClient, seed
async function openMobilePlanPanel(testPage: Page, session: SessionPage) {
await session.togglePlanMode();
await testPage.getByRole("button", { name: "Plan" }).tap();
await testPage.getByRole("button", { name: "Plan", exact: true }).tap();
await expect(session.planPanel).toBeVisible({ timeout: 10_000 });
}
@@ -0,0 +1,57 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { useCommentsStore, type AgentMessageComment } from "@/lib/state/slices/comments";
import { usePendingAgentMessageComments } from "./use-pending-comments";
function messageComment(id: string, sessionId: string): AgentMessageComment {
return {
id,
sessionId,
source: "agent-message",
messageId: `reply-${id}`,
selectedText: "selected reply",
anchor: {
messageId: `reply-${id}`,
start: 0,
end: 14,
selectedText: "selected reply",
prefix: "",
suffix: "",
},
text: "Please clarify this.",
createdAt: "2026-07-20T00:00:00Z",
status: "pending",
};
}
describe("usePendingAgentMessageComments", () => {
beforeEach(() => {
useCommentsStore.setState({
byId: {},
bySession: {},
pendingForChat: [],
editingCommentId: null,
});
});
it("returns only comments for resolved session", () => {
act(() => {
useCommentsStore.getState().addComment(messageComment("one", "session-1"));
useCommentsStore.getState().addComment(messageComment("two", "session-2"));
});
const { result } = renderHook(() => usePendingAgentMessageComments("session-1"));
expect(result.current.map((comment) => comment.id)).toEqual(["one"]);
});
it("returns no comments when there is no resolved session", () => {
act(() => {
useCommentsStore.getState().addComment(messageComment("one", "session-1"));
});
const { result } = renderHook(() => usePendingAgentMessageComments(null));
expect(result.current).toEqual([]);
});
});
@@ -6,12 +6,14 @@ import type {
PlanComment,
PRFeedbackComment,
WalkthroughComment,
AgentMessageComment,
} from "@/lib/state/slices/comments";
import {
isDiffComment,
isPlanComment,
isPRFeedbackComment,
isWalkthroughComment,
isAgentMessageComment,
} from "@/lib/state/slices/comments";
const EMPTY_COMMENTS: Comment[] = [];
@@ -19,6 +21,7 @@ const EMPTY_DIFF_COMMENTS: DiffComment[] = [];
const EMPTY_PLAN_COMMENTS: PlanComment[] = [];
const EMPTY_PR_FEEDBACK_COMMENTS: PRFeedbackComment[] = [];
const EMPTY_WALKTHROUGH_COMMENTS: WalkthroughComment[] = [];
const EMPTY_AGENT_MESSAGE_COMMENTS: AgentMessageComment[] = [];
/**
* Get all pending comments (any source).
@@ -123,3 +126,22 @@ export function usePendingWalkthroughComments(sessionId?: string | null): Walkth
return pending.length === 0 ? EMPTY_WALKTHROUGH_COMMENTS : pending;
}, [byId, pendingForChat, sessionId]);
}
/** Get pending inline comments attached to settled agent replies. */
export function usePendingAgentMessageComments(sessionId?: string | null): AgentMessageComment[] {
const byId = useCommentsStore((state) => state.byId);
const pendingForChat = useCommentsStore((state) => state.pendingForChat);
return useMemo(() => {
if (!sessionId || pendingForChat.length === 0) return EMPTY_AGENT_MESSAGE_COMMENTS;
const pending: AgentMessageComment[] = [];
for (const id of pendingForChat) {
const comment = byId[id];
if (comment && isAgentMessageComment(comment)) {
if (comment.sessionId !== sessionId) continue;
pending.push(comment);
}
}
return pending.length === 0 ? EMPTY_AGENT_MESSAGE_COMMENTS : pending;
}, [byId, pendingForChat, sessionId]);
}
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { DiffComment, PlanComment, WalkthroughComment } from "@/lib/state/slices/comments";
import type {
AgentMessageComment,
DiffComment,
PlanComment,
WalkthroughComment,
} from "@/lib/state/slices/comments";
// ---------------------------------------------------------------------------
// Mocks — declared before imports that use them
@@ -36,6 +41,8 @@ vi.mock("@/lib/state/slices/comments/format", () => ({
formatPRFeedbackAsMarkdown: () => "[pr-feedback]",
formatWalkthroughCommentsAsMarkdown: (comments: WalkthroughComment[]) =>
`[walkthrough] ${comments[0]?.text ?? ""}`,
formatAgentMessageCommentsAsMarkdown: (comments: AgentMessageComment[]) =>
`[agent-message] ${comments[0]?.text ?? ""}`,
}));
import { useRunComment } from "./use-run-comment";
@@ -104,6 +111,27 @@ function makeWalkthroughComment(text = "explain this step"): WalkthroughComment
};
}
function makeAgentMessageComment(text = "expand this answer"): AgentMessageComment {
return {
id: "c-4",
source: "agent-message",
sessionId: "sess-1",
messageId: "reply-1",
selectedText: "settled answer",
anchor: {
messageId: "reply-1",
start: 0,
end: 14,
selectedText: "settled answer",
prefix: "",
suffix: "",
},
text,
createdAt: new Date().toISOString(),
status: "pending",
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -259,6 +287,20 @@ describe("useRunComment — busy agent queues", () => {
);
expect(mockMarkCommentsSent).toHaveBeenCalledWith(["c-3"]);
});
it("formats agent message comments when queuing", async () => {
mockStoreState = makeStoreState("RUNNING");
const { result } = renderCommentHook();
await act(async () => {
await result.current.runComment(makeAgentMessageComment("please expand"));
});
expect(mockAppendToQueue).toHaveBeenCalledWith(
expect.objectContaining({ content: "[agent-message] please expand" }),
);
expect(mockMarkCommentsSent).toHaveBeenCalledWith(["c-4"]);
});
});
describe("useRunComment — edge cases", () => {
@@ -8,6 +8,7 @@ import {
formatPlanCommentsAsMarkdown,
formatPRFeedbackAsMarkdown,
formatWalkthroughCommentsAsMarkdown,
formatAgentMessageCommentsAsMarkdown,
} from "@/lib/state/slices/comments/format";
import type {
Comment,
@@ -16,6 +17,7 @@ import type {
FileEditorComment,
PRFeedbackComment,
WalkthroughComment,
AgentMessageComment,
} from "@/lib/state/slices/comments";
import type { Message } from "@/lib/types/http";
@@ -32,6 +34,8 @@ function formatSingleComment(comment: Comment): string {
return formatPRFeedbackAsMarkdown([comment as PRFeedbackComment]);
case "walkthrough":
return formatWalkthroughCommentsAsMarkdown([comment as WalkthroughComment]);
case "agent-message":
return formatAgentMessageCommentsAsMarkdown([comment as AgentMessageComment]);
case "file-editor": {
const fc = comment as FileEditorComment;
const lines: string[] = ["### File Comment", ""];
+32 -2
View File
@@ -1,8 +1,18 @@
import { describe, it, expect } from "vitest";
import { buildContextFilesContext, buildTaskMentionsContext } from "./use-message-handler";
import { describe, it, expect, vi } from "vitest";
import {
buildContextFilesContext,
buildTaskMentionsContext,
sendMessageRequest,
} from "./use-message-handler";
import type { AppState } from "@/lib/state/store";
import type { TaskMentionData } from "./use-inline-mention";
const getWebSocketClientMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: getWebSocketClientMock,
}));
const IMPROVE_HARNESS_PROMPT = "improve-harness";
const IMPROVE_HARNESS_CONTENT = "Review this session for durable harness improvements.";
@@ -184,3 +194,23 @@ describe("buildContextFilesContext", () => {
expect(out).not.toContain("### @improve-harness");
});
});
describe("sendMessageRequest", () => {
it("fails when the WebSocket client is unavailable", async () => {
getWebSocketClientMock.mockReturnValue(null);
await expect(
sendMessageRequest({
taskId: "task-1",
resolvedSessionId: "session-1",
finalMessage: "hello",
modelToSend: undefined,
planMode: false,
}),
).rejects.toMatchObject({
name: "MessageSendError",
code: "connection-unavailable",
message: "Connection unavailable. Reconnect and try again.",
});
});
});
+16 -4
View File
@@ -1,5 +1,6 @@
import { useCallback } from "react";
import { getWebSocketClient } from "@/lib/ws/connection";
import { MessageSendError } from "@/lib/chat/message-send-error";
import { useAppStoreApi } from "@/components/state-provider";
import { useQueue } from "./domains/session/use-queue";
import type { MessageAttachment } from "@/components/task/chat/chat-input-container";
@@ -155,9 +156,16 @@ type SendMessagePayload = {
contextFilesMeta?: Array<{ path: string; name: string }>;
};
async function sendMessageRequest(payload: SendMessagePayload): Promise<Message | undefined> {
export async function sendMessageRequest(
payload: SendMessagePayload,
): Promise<Message | undefined> {
const client = getWebSocketClient();
if (!client) return undefined;
if (!client) {
throw new MessageSendError(
"connection-unavailable",
"Connection unavailable. Reconnect and try again.",
);
}
const {
taskId,
@@ -227,8 +235,12 @@ export function useMessageHandler({
inlineTaskMentions?: TaskMentionData[],
) => {
if (!taskId || !resolvedSessionId) {
console.error("No active task session. Start an agent before sending a message.");
return;
const error = new MessageSendError(
"no-active-session",
"No active task session. Start an agent before sending a message.",
);
console.error(error.message);
throw error;
}
const { finalMessage, allContextFiles } = buildFinalMessage(
@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from "vitest";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
import {
createMessageTextAnchor,
getMessageCommentDecorations,
getMessageSelection,
isSelectableAgentMessage,
resolveMessageTextAnchor,
} from "./agent-message-comments";
function message(overrides: Partial<Message> = {}): Message {
return {
id: "reply-1",
session_id: toSessionId("session-1"),
task_id: toTaskId("task-1"),
author_type: "agent",
type: "message",
content: "A settled answer with useful detail.",
created_at: "2026-07-20T00:00:00Z",
...overrides,
};
}
describe("agent message comment anchors", () => {
it("captures nearby text context and resolves unchanged content", () => {
const content = "A settled answer with useful detail.";
const start = content.indexOf("answer");
const end = start + "answer".length;
const anchor = createMessageTextAnchor("reply-1", content, start, end);
expect(anchor).toEqual({
messageId: "reply-1",
start,
end,
selectedText: "answer",
prefix: "A settled ",
suffix: " with useful detail.",
});
expect(resolveMessageTextAnchor(anchor, content)).toEqual({ start, end });
});
it("falls back to nearby quote text when content shifted before selection", () => {
const anchor = createMessageTextAnchor("reply-1", "before select after", 7, 13);
expect(resolveMessageTextAnchor(anchor, "new before select after")).toEqual({
start: 11,
end: 17,
});
});
it("uses prefix and suffix context when selected text repeats", () => {
const original = "first answer; second answer; trailing";
const start = original.lastIndexOf("answer");
const anchor = createMessageTextAnchor("reply-1", original, start, start + 6);
expect(resolveMessageTextAnchor(anchor, "first answer; revised answer; trailing")).toEqual({
start: 22,
end: 28,
});
});
it("only permits settled ordinary agent prose", () => {
expect(isSelectableAgentMessage(message(), false, false)).toBe(true);
expect(isSelectableAgentMessage(message({ type: undefined }), false, false)).toBe(true);
expect(isSelectableAgentMessage(message({ author_type: "user" }), false, false)).toBe(false);
expect(isSelectableAgentMessage(message({ type: "thinking" }), false, false)).toBe(false);
for (const type of ["tool_call", "status", "agent_plan"] as const) {
expect(isSelectableAgentMessage(message({ type }), false, false)).toBe(false);
}
expect(isSelectableAgentMessage(message(), true, false)).toBe(false);
expect(isSelectableAgentMessage(message(), false, true)).toBe(false);
// Rich blocks render outside the prose annotation surface. Their presence
// must not disable selection of an otherwise ordinary reply.
expect(
isSelectableAgentMessage(message({ metadata: { diff: "--- a/file" } }), false, false),
).toBe(true);
expect(
isSelectableAgentMessage(
message({ metadata: { content_blocks: [{ type: "text" }] } }),
false,
false,
),
).toBe(true);
});
});
describe("message-local DOM anchors", () => {
it("rejects a cross-message selection and captures a local selection", () => {
const root = document.createElement("div");
root.innerHTML = "<p>First settled reply.</p><p>Second reply.</p>";
document.body.append(root);
const firstText = root.querySelector("p")?.firstChild;
expect(firstText).toBeTruthy();
const selection = window.getSelection();
const range = document.createRange();
range.setStart(firstText!, 6);
range.setEnd(firstText!, 13);
selection?.removeAllRanges();
selection?.addRange(range);
const captured = getMessageSelection(root, selection);
expect(captured?.selectedText).toBe("settled");
const secondText = root.querySelectorAll("p")[1].firstChild;
const crossRange = document.createRange();
crossRange.setStart(firstText!, 6);
crossRange.setEnd(secondText!, 3);
selection?.removeAllRanges();
selection?.addRange(crossRange);
expect(getMessageSelection(root.querySelector("p")!, selection)).toBeNull();
selection?.removeAllRanges();
root.remove();
});
it("resolves decoration ranges without rewriting React-owned nodes", () => {
const root = document.createElement("div");
root.innerHTML = "<p>A settled </p><p>answer with detail.</p>";
const renderedHtml = root.innerHTML;
const comment = {
id: "comment-1",
sessionId: "session-1",
source: "agent-message" as const,
messageId: "reply-1",
selectedText: "answer",
text: "Please expand this.",
createdAt: "2026-07-20T00:00:00Z",
status: "pending" as const,
anchor: createMessageTextAnchor("reply-1", "A settled answer with detail.", 10, 16),
};
const decorations = getMessageCommentDecorations(root, [comment]);
expect(decorations).toHaveLength(1);
expect(decorations[0].range.toString()).toBe("answer");
expect(root.innerHTML).toBe(renderedHtml);
expect(root.textContent).toBe("A settled answer with detail.");
// Recomputing for a virtualized row remount remains side-effect free.
expect(getMessageCommentDecorations(root, [comment])).toHaveLength(1);
expect(root.innerHTML).toBe(renderedHtml);
});
it("keeps highlight restoration bounded to the message body", () => {
const words = Array.from({ length: 40 }, (_, index) => `reply-${index}`).join(" ");
const root = document.createElement("div");
root.textContent = words;
const comments = Array.from({ length: 40 }, (_, index) => {
const selectedText = `reply-${index}`;
const start = words.indexOf(selectedText);
return {
id: `comment-${index}`,
sessionId: "session-1",
source: "agent-message" as const,
messageId: "reply-1",
selectedText,
text: "feedback",
createdAt: "2026-07-20T00:00:00Z",
status: "pending" as const,
anchor: createMessageTextAnchor("reply-1", words, start, start + selectedText.length),
};
});
const createTreeWalker = vi.spyOn(document, "createTreeWalker");
expect(getMessageCommentDecorations(root, comments)).toHaveLength(40);
expect(createTreeWalker).toHaveBeenCalledTimes(1);
expect(root.querySelectorAll("mark[data-agent-message-comment-id]")).toHaveLength(0);
expect(root.textContent).toBe(words);
});
});
+199
View File
@@ -0,0 +1,199 @@
import type { Message } from "@/lib/types/http";
import type { MessageTextAnchor } from "@/lib/state/slices/comments";
import type { AgentMessageComment } from "@/lib/state/slices/comments";
const ANCHOR_CONTEXT_LENGTH = 80;
export function agentMessageCommentHighlightName(messageId: string) {
return `agent-message-comment-${messageId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
export type ResolvedMessageTextRange = { start: number; end: number };
export type MessageSelection = ResolvedMessageTextRange & {
selectedText: string;
rect: DOMRect;
};
export function createMessageTextAnchor(
messageId: string,
content: string,
start: number,
end: number,
): MessageTextAnchor {
const safeStart = Math.max(0, Math.min(start, content.length));
const safeEnd = Math.max(safeStart, Math.min(end, content.length));
return {
messageId,
start: safeStart,
end: safeEnd,
selectedText: content.slice(safeStart, safeEnd),
prefix: content.slice(Math.max(0, safeStart - ANCHOR_CONTEXT_LENGTH), safeStart),
suffix: content.slice(safeEnd, safeEnd + ANCHOR_CONTEXT_LENGTH),
};
}
function isValidRange(content: string, start: number, end: number, selectedText: string) {
return start >= 0 && end > start && content.slice(start, end) === selectedText;
}
function findQuotedRange(
anchor: MessageTextAnchor,
content: string,
): ResolvedMessageTextRange | null {
const quote = `${anchor.prefix}${anchor.selectedText}${anchor.suffix}`;
const quoteIndex = content.indexOf(quote);
if (quoteIndex !== -1) {
const start = quoteIndex + anchor.prefix.length;
return { start, end: start + anchor.selectedText.length };
}
const candidates: number[] = [];
let candidate = content.indexOf(anchor.selectedText);
while (candidate !== -1) {
candidates.push(candidate);
candidate = content.indexOf(anchor.selectedText, candidate + 1);
}
if (candidates.length === 0) return null;
const prefix = anchor.prefix.slice(-32);
const suffix = anchor.suffix.slice(0, 32);
const best = candidates.reduce((current, next) => {
const score = (index: number) => {
const before = content.slice(Math.max(0, index - prefix.length), index);
const after = content.slice(index + anchor.selectedText.length);
return (
(prefix && before.endsWith(prefix) ? 2 : 0) +
(suffix && after.startsWith(suffix) ? 2 : 0) -
Math.min(Math.abs(index - anchor.start), 1000) / 10000
);
};
return score(next) > score(current) ? next : current;
});
return { start: best, end: best + anchor.selectedText.length };
}
export function resolveMessageTextAnchor(
anchor: MessageTextAnchor,
content: string,
): ResolvedMessageTextRange | null {
if (!anchor.selectedText) return null;
if (isValidRange(content, anchor.start, anchor.end, anchor.selectedText)) {
return { start: anchor.start, end: anchor.end };
}
return findQuotedRange(anchor, content);
}
function boundaryOffset(root: HTMLElement, container: Node, offset: number): number | null {
if (!root.contains(container)) return null;
try {
const range = document.createRange();
range.selectNodeContents(root);
range.setEnd(container, offset);
return range.toString().length;
} catch {
return null;
}
}
/** Convert a browser selection into offsets relative to one message body. */
export function getMessageSelection(
root: HTMLElement,
selection: Selection | null,
): MessageSelection | null {
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
const start = boundaryOffset(root, range.startContainer, range.startOffset);
const end = boundaryOffset(root, range.endContainer, range.endOffset);
if (start === null || end === null || end <= start) return null;
const selectedText = range.toString();
if (!selectedText.trim()) return null;
return { start, end, selectedText, rect: range.getBoundingClientRect() };
}
function messageTextNodes(root: HTMLElement): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let node = walker.nextNode();
while (node) {
if (node.nodeValue) nodes.push(node as Text);
node = walker.nextNode();
}
return nodes;
}
function domRangeForOffsets(textNodes: Text[], start: number, end: number): Range | null {
let cursor = 0;
let startBoundary: { node: Text; offset: number } | null = null;
let endBoundary: { node: Text; offset: number } | null = null;
for (const textNode of textNodes) {
const textLength = textNode.data.length;
const nodeStart = cursor;
const nodeEnd = cursor + textLength;
cursor = nodeEnd;
if (!startBoundary && start >= nodeStart && start <= nodeEnd) {
startBoundary = { node: textNode, offset: start - nodeStart };
}
if (end >= nodeStart && end <= nodeEnd) {
endBoundary = { node: textNode, offset: end - nodeStart };
break;
}
}
if (!startBoundary || !endBoundary) return null;
const range = document.createRange();
range.setStart(startBoundary.node, startBoundary.offset);
range.setEnd(endBoundary.node, endBoundary.offset);
return range;
}
export type MessageCommentDecoration = {
comment: AgentMessageComment;
range: Range;
highlightRects: Array<{ left: number; top: number; width: number; height: number }>;
left: number;
top: number;
};
/** Resolve highlights without changing DOM nodes owned by React. */
export function getMessageCommentDecorations(
root: HTMLElement,
comments: AgentMessageComment[],
): MessageCommentDecoration[] {
const content = root.textContent ?? "";
const rootRect = root.getBoundingClientRect();
const textNodes = messageTextNodes(root);
const decorations: MessageCommentDecoration[] = [];
for (const comment of comments) {
const resolved = resolveMessageTextAnchor(comment.anchor, content);
if (!resolved) continue;
const range = domRangeForOffsets(textNodes, resolved.start, resolved.end);
if (!range) continue;
const rects = Array.from(range.getClientRects());
const rect = rects.at(-1) ?? range.getBoundingClientRect();
const highlightRects = (rects.length > 0 ? rects : [rect]).map((rangeRect) => ({
left: rangeRect.left - rootRect.left + root.scrollLeft,
top: rangeRect.top - rootRect.top + root.scrollTop,
width: rangeRect.width,
height: rangeRect.height,
}));
decorations.push({
comment,
range,
highlightRects,
left: rect.right - rootRect.left + root.scrollLeft,
top: rect.top - rootRect.top + root.scrollTop,
});
}
return decorations;
}
export function isSelectableAgentMessage(
message: Message,
isTurnActive: boolean,
isRawView: boolean,
): boolean {
if (isTurnActive || isRawView) return false;
if (message.author_type !== "agent") return false;
if (message.type && message.type !== "message" && message.type !== "content") return false;
if (!message.content.trim()) return false;
return true;
}
+17
View File
@@ -0,0 +1,17 @@
export type MessageSendErrorCode = "connection-unavailable" | "no-active-session";
export class MessageSendError extends Error {
readonly code: MessageSendErrorCode;
constructor(code: MessageSendErrorCode, message: string) {
super(message);
this.name = "MessageSendError";
this.code = code;
}
}
export function isMessageSendError(error: unknown): error is MessageSendError {
if (!(error instanceof Error) || error.name !== "MessageSendError") return false;
const code = (error as Error & { code?: unknown }).code;
return code === "connection-unavailable" || code === "no-active-session";
}
@@ -4,9 +4,16 @@ import {
formatReviewCommentsAsMarkdown,
formatPRFeedbackAsMarkdown,
formatWalkthroughCommentsAsMarkdown,
formatAgentMessageCommentsAsMarkdown,
formatCommentsForMessage,
} from "./format";
import type { PlanComment, DiffComment, PRFeedbackComment, WalkthroughComment } from "./types";
import type {
AgentMessageComment,
PlanComment,
DiffComment,
PRFeedbackComment,
WalkthroughComment,
} from "./types";
function makePlanComment(overrides: Partial<PlanComment> = {}): PlanComment {
return {
@@ -210,4 +217,63 @@ describe("formatCommentsForMessage", () => {
expect(result.prFeedbackComments).toHaveLength(0);
expect(result.walkthroughComments).toHaveLength(0);
});
it("separates agent-message comments for prompt formatting", () => {
const messageId = "agent-reply-1";
const selectedText = "the exact answer";
const messageComment: AgentMessageComment = {
id: "m1",
sessionId: "s",
source: "agent-message",
messageId,
selectedText,
anchor: {
messageId,
start: 0,
end: 15,
selectedText,
prefix: "",
suffix: " continues",
},
text: "Please expand this.",
createdAt: "",
status: "pending",
};
const result = formatCommentsForMessage([messageComment]);
expect((result as { agentMessageComments: unknown[] }).agentMessageComments).toEqual([
messageComment,
]);
});
});
describe("formatAgentMessageCommentsAsMarkdown", () => {
it("formats selected reply text and multiline feedback as markdown", () => {
const comment = {
id: "m1",
sessionId: "s",
source: "agent-message",
messageId: "agent-reply-1",
selectedText: "the exact answer",
anchor: {
messageId: "agent-reply-1",
start: 0,
end: 15,
selectedText: "the exact answer",
prefix: "",
suffix: " continues",
},
text: "Please expand this.\nInclude one example.",
createdAt: "",
status: "pending",
} as never;
const result = formatAgentMessageCommentsAsMarkdown([comment]);
expect(result).toContain("### Agent Message Comments");
expect(result).toContain("> the exact answer");
expect(result).toContain("> Please expand this.\n> Include one example.");
expect(result).toContain("---");
});
});
+35 -2
View File
@@ -4,8 +4,15 @@ import type {
PlanComment,
PRFeedbackComment,
WalkthroughComment,
AgentMessageComment,
} from "./types";
import {
isAgentMessageComment,
isDiffComment,
isPlanComment,
isPRFeedbackComment,
isWalkthroughComment,
} from "./types";
import { isDiffComment, isPlanComment, isPRFeedbackComment, isWalkthroughComment } from "./types";
/**
* Format diff review comments as human-readable markdown for sending to agent.
@@ -119,6 +126,23 @@ export function formatWalkthroughCommentsAsMarkdown(comments: WalkthroughComment
return lines.join("\n");
}
/** Format feedback anchored to ordinary agent replies. */
export function formatAgentMessageCommentsAsMarkdown(comments: AgentMessageComment[]): string {
if (!comments || comments.length === 0) return "";
const lines: string[] = ["### Agent Message Comments", ""];
for (const comment of comments) {
lines.push("Selected agent response:");
lines.push(toBlockquote(comment.selectedText));
lines.push("");
lines.push("User feedback:");
lines.push(toBlockquote(comment.text));
lines.push("");
}
lines.push("---", "");
return lines.join("\n");
}
/**
* Format all pending comments for inclusion in a chat message.
*/
@@ -127,18 +151,27 @@ export function formatCommentsForMessage(comments: Comment[]): {
planComments: PlanComment[];
prFeedbackComments: PRFeedbackComment[];
walkthroughComments: WalkthroughComment[];
agentMessageComments: AgentMessageComment[];
} {
const diffComments: DiffComment[] = [];
const planComments: PlanComment[] = [];
const prFeedbackComments: PRFeedbackComment[] = [];
const walkthroughComments: WalkthroughComment[] = [];
const agentMessageComments: AgentMessageComment[] = [];
for (const c of comments) {
if (isDiffComment(c)) diffComments.push(c);
else if (isPlanComment(c)) planComments.push(c);
else if (isPRFeedbackComment(c)) prFeedbackComments.push(c);
else if (isWalkthroughComment(c)) walkthroughComments.push(c);
else if (isAgentMessageComment(c)) agentMessageComments.push(c);
}
return { diffComments, planComments, prFeedbackComments, walkthroughComments };
return {
diffComments,
planComments,
prFeedbackComments,
walkthroughComments,
agentMessageComments,
};
}
@@ -6,6 +6,8 @@ export {
type FileEditorComment,
type PRFeedbackComment,
type WalkthroughComment,
type AgentMessageComment,
type MessageTextAnchor,
type AnnotationSide,
type CommentsState,
type CommentsActions,
@@ -15,12 +17,14 @@ export {
isFileEditorComment,
isPRFeedbackComment,
isWalkthroughComment,
isAgentMessageComment,
} from "./types";
export {
formatReviewCommentsAsMarkdown,
formatPlanCommentsAsMarkdown,
formatPRFeedbackAsMarkdown,
formatWalkthroughCommentsAsMarkdown,
formatAgentMessageCommentsAsMarkdown,
formatCommentsForMessage,
} from "./format";
export {
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCommentsStore } from "./comments-store";
import { COMMENTS_STORAGE_PREFIX, loadSessionComments } from "./persistence";
import type { AgentMessageComment } from "./types";
const SESSION_ID = "session-message-comments";
function messageComment(overrides: Partial<AgentMessageComment> = {}): AgentMessageComment {
return {
id: "message-comment-1",
sessionId: SESSION_ID,
source: "agent-message",
messageId: "reply-1",
selectedText: "selected answer",
anchor: {
messageId: "reply-1",
start: 0,
end: 15,
selectedText: "selected answer",
prefix: "",
suffix: " continues",
},
text: "Please clarify this.",
createdAt: "2026-07-20T00:00:00Z",
status: "pending",
...overrides,
};
}
describe("agent-message comment persistence", () => {
beforeEach(() => {
window.sessionStorage.clear();
useCommentsStore.setState({
byId: {},
bySession: {},
pendingForChat: [],
editingCommentId: null,
});
});
it("persists pending message comments in sessionStorage", () => {
const comment = messageComment();
useCommentsStore.getState().addComment(comment);
expect(loadSessionComments(SESSION_ID)).toEqual([comment]);
expect(window.sessionStorage.getItem(`${COMMENTS_STORAGE_PREFIX}${SESSION_ID}`)).not.toBeNull();
});
it("hydrates message comments and pending context after a remount", () => {
const comment = messageComment();
window.sessionStorage.setItem(
`${COMMENTS_STORAGE_PREFIX}${SESSION_ID}`,
JSON.stringify([comment]),
);
useCommentsStore.getState().hydrateSession(SESSION_ID);
const state = useCommentsStore.getState();
expect(state.byId[comment.id]).toEqual(comment);
expect(state.bySession[SESSION_ID]).toEqual([comment.id]);
expect(state.pendingForChat).toEqual([comment.id]);
expect(state.getPendingComments()).toEqual([comment]);
});
});
+24 -1
View File
@@ -72,12 +72,31 @@ export type WalkthroughComment = CommentBase & {
stepText: string;
};
export type MessageTextAnchor = {
messageId: string;
start: number;
end: number;
selectedText: string;
/** Text immediately before the selection, used when message content shifts. */
prefix: string;
/** Text immediately after the selection, used when message content shifts. */
suffix: string;
};
export type AgentMessageComment = CommentBase & {
source: "agent-message";
messageId: string;
selectedText: string;
anchor: MessageTextAnchor;
};
export type Comment =
| DiffComment
| PlanComment
| FileEditorComment
| PRFeedbackComment
| WalkthroughComment;
| WalkthroughComment
| AgentMessageComment;
// ---------------------------------------------------------------------------
// Type guards
@@ -103,6 +122,10 @@ export function isWalkthroughComment(c: Comment): c is WalkthroughComment {
return c.source === "walkthrough";
}
export function isAgentMessageComment(c: Comment): c is AgentMessageComment {
return c.source === "agent-message";
}
// ---------------------------------------------------------------------------
// Store state & actions
// ---------------------------------------------------------------------------
+8 -1
View File
@@ -3,6 +3,7 @@ import type {
PlanComment,
PRFeedbackComment,
WalkthroughComment,
AgentMessageComment,
} from "@/lib/state/slices/comments";
import type { FileAttachment } from "@/components/task/chat/file-attachment";
@@ -69,6 +70,11 @@ export type WalkthroughCommentContextItem = ContextItemBase & {
onRemoveComment: (id: string) => void;
};
export type AgentMessageCommentContextItem = ContextItemBase & {
kind: "agent-message-comment";
comments: AgentMessageComment[];
};
export type ContextItem =
| PlanContextItem
| FileContextItem
@@ -78,6 +84,7 @@ export type ContextItem =
| ImageContextItem
| FileAttachmentContextItem
| PRFeedbackContextItem
| WalkthroughCommentContextItem;
| WalkthroughCommentContextItem
| AgentMessageCommentContextItem;
export type ContextItemKind = ContextItem["kind"];
+49
View File
@@ -0,0 +1,49 @@
# Agent-message inline comments — implementation plan
Status: complete
Spec: [`docs/specs/ui/agent-message-comments.md`](../../specs/ui/agent-message-comments.md)
## Constraints
- Frontend-only. Do not change backend/API/schema/task_comments unless implementation evidence disproves the approved boundary.
- Use existing frontend comment store and browser sessionStorage persistence.
- Ordinary settled agent prose only: `message`/`content`, inactive turn, no raw view. Sibling rich blocks remain outside the selectable prose surface.
- Preserve normal Task Chat, Quick Chat, passthrough, queue, attachments, and existing comment types.
## Waves and tasks
### Wave 1 — comment foundation
1. [`task-01-comment-foundation.md`](task-01-comment-foundation.md) — Extend the comment union, message-local anchor helpers, sessionStorage hydration, Markdown formatting, and focused tests.
### Wave 2 — selection and overlays
2. [`task-02-selection-and-overlay.md`](task-02-selection-and-overlay.md) — Capture local text selections, restore plan-style highlights and badges after virtualization, and provide the plan comment affordance plus shared desktop Popover/mobile Drawer actions.
### Wave 3 — composer and delivery wiring
3. [`task-03-chat-wiring.md`](task-03-chat-wiring.md) — Add message-comment context chips, composer inclusion/cleanup, immediate/queued Run, Task Chat/Quick Chat integration, and passthrough preservation.
### Wave 4 — browser verification
4. [`task-04-e2e-and-verification.md`](task-04-e2e-and-verification.md) — Add render-cost/component coverage plus Chromium and mobile-chrome E2E for Add, restore, Drawer, and Run paths.
## Mobile design contract
- Entry point: select settled agent prose in Task Chat or Quick Chat, then activate the plan-style comment affordance.
- Exemplar: existing inset `Drawer` context surfaces (`mobile-menu-sheet.tsx`) and `useTouchDrawer` coarse-pointer branching.
- Hierarchy: selected excerpt → feedback input → attached Add/Run actions, with update/delete for pending comments.
- Surface: the shared plan-comment Popover on desktop; an equivalent phone inset bottom Drawer. Drawer owns its content scroll and clears safe area.
- Shared: comment model, anchor resolver, store, formatter, context chips, and delivery callbacks shared across compositions.
- Verification: desktop Chromium and `mobile-chrome` selection/Add/Run tests, containment, and no horizontal document overflow.
## Verification commands
- `cd apps && pnpm --filter @kandev/web test -- --run <focused tests>`
- `cd apps/web && pnpm e2e:run --host tests/chat/agent-message-comments.spec.ts --project=chromium`
- `cd apps/web && pnpm e2e:run --host --no-build tests/chat/mobile-agent-message-comments.spec.ts --project=mobile-chrome`
- `make fmt`
- `make typecheck test lint`
- `node --test scripts/validate-public-docs.test.mjs`
- `node scripts/validate-public-docs.mjs`
@@ -0,0 +1,35 @@
---
id: agent-message-comments-01
title: Comment foundation
status: complete
wave: 1
depends_on: None
plan: plan.md
---
## Acceptance
- `AgentMessageComment` and `MessageTextAnchor` are part of the frontend union with type guards, sessionStorage persistence/hydration, and Markdown formatting.
- Unit tests cover round-trip persistence/hydration, anchor normalization, eligible-message filtering, and multiline Markdown.
- Existing comment types and formatting remain compatible.
## Files
- `apps/web/lib/state/slices/comments/types.ts`
- `apps/web/lib/state/slices/comments/comments-store.ts`
- `apps/web/lib/state/slices/comments/persistence.ts`
- `apps/web/lib/state/slices/comments/format.ts`
- `apps/web/lib/state/slices/comments/*.test.ts`
- `apps/web/lib/chat/agent-message-comments.ts`
## Verification
- `cd apps && pnpm --filter @kandev/web test -- --run lib/state/slices/comments lib/chat/agent-message-comments.test.ts`
## Output
Implemented the comment union, sessionStorage-backed hydration, message-local anchors, Markdown formatting, and focused persistence/format/anchor tests.
## Output contract
Summarize model, helpers, tests, touched files, blockers, and follow-up risks.
@@ -0,0 +1,35 @@
---
id: agent-message-comments-02
title: Selection and overlay
status: complete
wave: 2
depends_on: agent-message-comments-01
plan: plan.md
---
## Acceptance
- Only eligible settled agent prose exposes selection actions; selection is message-local and cannot cross rows.
- Plan-style accent highlights and comment badges restore from stored offsets when a virtualized message remounts, without global DOM anchors.
- Selection first exposes the plan-style comment affordance. Desktop reuses the plan comment Popover; coarse-pointer/mobile uses an equivalent native Drawer with accessible Add/Run/edit/delete actions and safe-area/touch geometry.
- Activating a pending highlight or badge reopens it for update or deletion.
## Files
- `apps/web/components/task/chat/messages/chat-message.tsx`
- `apps/web/components/task/chat/messages/message-comment-surface.tsx`
- `apps/web/lib/chat/agent-message-comments.ts`
- focused component/unit tests
## Verification
- focused Vitest selection/highlight tests
- desktop and mobile rendered checks
## Output
Implemented message-local selection restoration, plan-style affordance/highlight/badge behavior, shared desktop plan Popover, and coarse-pointer/mobile Drawer interactions with Add/Run/update/delete behavior.
## Output contract
Summarize selection eligibility, anchor/highlight behavior, overlay composition, tests, and risks.
@@ -0,0 +1,39 @@
---
id: agent-message-comments-03
title: Chat wiring and delivery
status: complete
wave: 3
depends_on: agent-message-comments-01, agent-message-comments-02
plan: plan.md
---
## Acceptance
- Add persists pending context and exposes removable message-comment chips in Task Chat and Quick Chat composers.
- Composer send includes and clears message comments only on success; failed sends preserve context. Existing passthrough review-comment behavior remains intact.
- Run uses existing direct message and busy queue paths, removing the comment only after successful delivery.
## Files
- `apps/web/lib/types/context.ts`
- `apps/web/components/task/chat-context-items.ts`
- `apps/web/components/task/chat/context-items/*`
- `apps/web/components/task/chat/use-chat-panel-state.ts`
- `apps/web/components/task/chat/chat-input-area.tsx`
- `apps/web/hooks/use-message-handler.ts`
- `apps/web/hooks/domains/comments/use-run-comment.ts`
- `apps/web/components/task/passthrough-chat-composer.tsx`
- focused tests
## Verification
- focused composer/store/run tests
- passthrough regression tests
## Output
Wired Task Chat and Quick Chat context chips, composer inclusion/cleanup, direct and queued Run delivery, and passthrough preservation.
## Output contract
Summarize Task Chat/Quick Chat/passthrough wiring, direct/queue behavior, tests, and risks.
@@ -0,0 +1,36 @@
---
id: agent-message-comments-04
title: E2E and verification
status: complete
wave: 4
depends_on: agent-message-comments-03
plan: plan.md
---
## Acceptance
- Render-cost/unit coverage proves message-comment state/highlight updates do not reparse unrelated Markdown rows.
- Chromium E2E covers selecting settled prose, Add, composer inclusion, persistence/remount, and Run delivery.
- `mobile-chrome` E2E covers Drawer actions, touch-sized controls, containment, and no document horizontal overflow.
## Files
- `apps/web/components/task/chat/__evals__/render-cost.test.tsx`
- `apps/web/e2e/tests/chat/agent-message-comments.spec.ts`
- `apps/web/e2e/tests/chat/mobile-agent-message-comments.spec.ts`
- `docs/public/developer-tools.md`
## Verification
- `make fmt`
- `make typecheck test lint`
- targeted Chromium and mobile-chrome managed E2E
- public-doc validators
## Output
Added render-cost coverage, focused component/unit tests, desktop Chromium E2E, mobile-chrome E2E, and public documentation.
## Output contract
Summarize tests/results, docs, review findings, unresolved risks, and any unrelated pre-existing failures.
+6
View File
@@ -11,6 +11,12 @@ Kandev includes short-lived chat, reusable AI helpers, dictation, file and edito
Quick Chat is an agent conversation outside the board. Use it for repository orientation, experiments, and disposable questions that do not need workflow state, review gates, dependencies, or a delivery record.
### Comment on an agent reply
In Task Chat or Quick Chat, select text from a settled agent prose reply, then select the comment button that appears beside the selection. The editor works like plan comments: enter feedback and choose **Add** to keep it as pending context, or **Run** to send it immediately. Pending selections use the same inline highlight and comment badge as plans; select either one to update or delete the feedback. Pending message comments are kept for the current browser tab, appear as composer context chips, and are included in the next prompt as Markdown. If the agent is busy, **Run** queues the feedback for its next turn.
Inline comments are available only on ordinary settled prose. Streaming replies, tool/thinking/status output, plans, rich-block content, raw views, and user messages do not accept inline comments.
Select **Quick Chat** beside **New Task** in the expanded sidebar, or select its standalone row in the collapsed sidebar.
### Start a chat
+1
View File
@@ -120,6 +120,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [settings-manual-save](ui/settings-manual-save.md) | shipped |
| [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped |
| [task-layout-profiles](ui/task-layout-profiles.md) | draft |
| [agent-message-comments](ui/agent-message-comments.md) | shipped |
## system-page/ — operational diagnostics & maintenance UI
+59
View File
@@ -0,0 +1,59 @@
---
status: shipped
created: 2026-07-20
owner: cfl
---
# Agent-message inline comments
## Why
Users need to point an agent at one exact part of an ordinary settled reply without copying prose into a new prompt. Inline comments turn a message selection into durable, visible prompt context while keeping the existing chat and queue delivery paths intact.
## What
- In Task Chat and Quick Chat, users can select text from the ordinary prose portion of a settled agent reply (`message` or `content`). Rich blocks rendered beside that prose remain outside the selectable surface.
- Current streaming replies, user messages, raw views, thinking, tools, status/progress, `agent_plan`, and rich-block content do not expose inline-comment controls.
- Selection first reveals the same accent comment affordance used by plan selection. Activating it opens the shared plan-comment popover on desktop or the equivalent coarse-pointer/mobile bottom Drawer. The keyboard shortcut and Add/Run action hierarchy match plan comments.
- **Add** stores a pending agent-message comment in browser `sessionStorage`, renders the same accent highlight and comment badge used in plans when its message is mounted, and adds a removable context chip to the composer.
- Activating a pending comment's highlight or badge reopens the editor so the feedback can be updated or deleted.
- **Run** sends the single comment immediately when the session is idle or appends it to the existing queue when the agent is busy. Successful delivery removes the pending comment.
- Sending normal composer content includes all pending agent-message comments in Markdown and removes them only after successful direct or passthrough delivery. Failed sends preserve draft and context.
- Highlights identify selections using message-local text anchors, not global DOM positions. Virtualized/unmounted chat rows restore highlights when remounted.
- Existing diff, plan, PR-feedback, walkthrough, attachment, file, prompt, and passthrough context behavior remains unchanged.
## Data model and persistence
`AgentMessageComment` extends the frontend comment union with `source: "agent-message"`, session id, message id, selected text, user feedback, pending/sent status, and a message-local anchor containing start/end text offsets plus nearby prefix/suffix context. Comments are persisted per session using the existing browser `sessionStorage` comment store. No backend table, API, WebSocket schema, or `task_comments` change is in scope.
## Markdown and delivery
Agent-message comments format as a Markdown section containing the selected response text and user feedback as blockquotes. All existing comment types retain their current format. Normal, queued, immediate, and passthrough sends use the existing message/queue APIs; only the final client-side message composition changes.
## Mobile contract
- Desktop entry point: select settled agent prose, activate the plan-style comment affordance, then act from the shared anchored plan popover.
- Mobile entry point: select settled agent prose, activate a touch-sized version of the same affordance, then act from an inset bottom Drawer following existing phone context surfaces.
- Drawer hierarchy and language mirror the desktop popover: selected text preview, feedback input, attached Add/Run actions, and edit/delete controls. One internal scroll owner; controls have touch-sized hitboxes and safe-area bottom padding.
- Shared state and delivery handlers are reused. Only overlay presentation differs by pointer/viewport.
- Mobile E2E proves selection, Drawer presentation, Add, composer context, and Run delivery outcome without document horizontal overflow.
## Scenarios
- **GIVEN** a settled agent prose reply, **WHEN** the user selects non-empty text, **THEN** a plan-style comment affordance appears for that selection.
- **GIVEN** a visible selection affordance, **WHEN** the user activates it, **THEN** the plan-style editor opens with the exact selected text.
- **GIVEN** a current streaming reply or non-prose/rich message, **WHEN** the user selects text, **THEN** no inline-comment action surface opens.
- **GIVEN** feedback and a selection, **WHEN** the user chooses **Add**, **THEN** the comment is persisted in sessionStorage, highlighted in the message, and shown as a composer chip.
- **GIVEN** a pending comment, **WHEN** the user reloads the tab or the virtualized row remounts, **THEN** state and highlight restore for the same message-local anchor.
- **GIVEN** a pending comment highlight or badge, **WHEN** the user activates it, **THEN** they can update or delete the pending feedback.
- **GIVEN** a pending comment, **WHEN** the user chooses **Run** while idle, **THEN** one Markdown message is sent immediately and the comment is removed after success.
- **GIVEN** a pending comment, **WHEN** the user chooses **Run** while busy, **THEN** one Markdown queue entry is appended and the comment is removed after success.
- **GIVEN** pending comment context and composer text, **WHEN** the user submits successfully, **THEN** the Markdown context is included and pending comments are cleared.
- **GIVEN** a failed direct, queued, or passthrough send, **THEN** pending context and draft remain available.
## Out of scope
- Backend/API/schema/task-comment persistence.
- Comments on user messages, streaming replies, tools, status, thinking, plans, rich-block content, or raw views.
- Cross-message selections or server-side highlight rendering.
- Editing an already-sent inline comment.