From 8f2fe7a8e6156d41f763c00a41b815a5f3c7cbc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:32:19 +0100 Subject: [PATCH] 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 --- apps/web/app/globals.css | 32 +- .../task/chat-context-items.test.ts | 56 ++ .../web/components/task/chat-context-items.ts | 23 +- .../task/chat/__evals__/render-cost.test.tsx | 30 + .../task/chat/chat-input-area.test.ts | 37 ++ .../task/chat/chat-input-area.test.tsx | 68 ++ .../components/task/chat/chat-input-area.tsx | 54 +- .../agent-message-comment-item.tsx | 20 + .../task/chat/context-items/context-chip.tsx | 4 +- .../context-items/context-item-renderer.tsx | 3 + .../chat/context-items/plan-comment-item.tsx | 21 +- .../context-items/selection-comment-item.tsx | 49 ++ .../task/chat/message-list-native.tsx | 3 + .../task/chat/message-list-shared.test.tsx | 26 +- .../task/chat/message-list-shared.tsx | 32 +- .../task/chat/message-list-virtuoso.tsx | 13 +- .../components/task/chat/message-renderer.tsx | 11 + .../chat/messages/agent-message-content.tsx | 67 ++ .../task/chat/messages/chat-message.tsx | 57 +- .../messages/message-comment-decorations.tsx | 77 +++ .../messages/message-comment-surface.test.tsx | 288 +++++++++ .../chat/messages/message-comment-surface.tsx | 582 ++++++++++++++++++ .../task/chat/messages/turn-group-message.tsx | 11 + .../chat/messages/use-message-comment-dom.ts | 149 +++++ .../task/chat/use-chat-panel-state.ts | 52 +- .../task/floating-selection-position.test.ts | 30 + .../task/floating-selection-position.ts | 45 ++ .../task/passthrough-chat-composer.test.ts | 38 ++ .../task/passthrough-chat-composer.tsx | 30 +- .../task/passthrough-toolbar.test.tsx | 2 + .../task/plan-selection-popover.test.tsx | 26 + .../task/plan-selection-popover.tsx | 84 ++- .../task/preview-session-tabs.test.tsx | 55 ++ .../components/task/preview-session-tabs.tsx | 25 +- apps/web/e2e/helpers/api-client.ts | 11 + .../chat/agent-message-comments-helpers.ts | 145 +++++ .../tests/chat/agent-message-comments.spec.ts | 98 +++ .../mobile-agent-message-comments.spec.ts | 110 ++++ .../mobile-plan-toolbar-implement.spec.ts | 2 +- .../comments/use-pending-comments.test.ts | 57 ++ .../domains/comments/use-pending-comments.ts | 22 + .../domains/comments/use-run-comment.test.ts | 44 +- .../hooks/domains/comments/use-run-comment.ts | 4 + apps/web/hooks/use-message-handler.test.ts | 34 +- apps/web/hooks/use-message-handler.ts | 20 +- .../lib/chat/agent-message-comments.test.ts | 166 +++++ apps/web/lib/chat/agent-message-comments.ts | 199 ++++++ apps/web/lib/chat/message-send-error.ts | 17 + .../lib/state/slices/comments/format.test.ts | 68 +- apps/web/lib/state/slices/comments/format.ts | 37 +- apps/web/lib/state/slices/comments/index.ts | 4 + .../slices/comments/message-comments.test.ts | 65 ++ apps/web/lib/state/slices/comments/types.ts | 25 +- apps/web/lib/types/context.ts | 9 +- docs/plans/agent-message-comments/plan.md | 49 ++ .../task-01-comment-foundation.md | 35 ++ .../task-02-selection-and-overlay.md | 35 ++ .../task-03-chat-wiring.md | 39 ++ .../task-04-e2e-and-verification.md | 36 ++ docs/public/developer-tools.md | 6 + docs/specs/INDEX.md | 1 + docs/specs/ui/agent-message-comments.md | 59 ++ 62 files changed, 3309 insertions(+), 188 deletions(-) create mode 100644 apps/web/components/task/chat-context-items.test.ts create mode 100644 apps/web/components/task/chat/chat-input-area.test.ts create mode 100644 apps/web/components/task/chat/context-items/agent-message-comment-item.tsx create mode 100644 apps/web/components/task/chat/context-items/selection-comment-item.tsx create mode 100644 apps/web/components/task/chat/messages/agent-message-content.tsx create mode 100644 apps/web/components/task/chat/messages/message-comment-decorations.tsx create mode 100644 apps/web/components/task/chat/messages/message-comment-surface.test.tsx create mode 100644 apps/web/components/task/chat/messages/message-comment-surface.tsx create mode 100644 apps/web/components/task/chat/messages/use-message-comment-dom.ts create mode 100644 apps/web/components/task/floating-selection-position.test.ts create mode 100644 apps/web/components/task/floating-selection-position.ts create mode 100644 apps/web/components/task/plan-selection-popover.test.tsx create mode 100644 apps/web/components/task/preview-session-tabs.test.tsx create mode 100644 apps/web/e2e/tests/chat/agent-message-comments-helpers.ts create mode 100644 apps/web/e2e/tests/chat/agent-message-comments.spec.ts create mode 100644 apps/web/e2e/tests/chat/mobile-agent-message-comments.spec.ts create mode 100644 apps/web/hooks/domains/comments/use-pending-comments.test.ts create mode 100644 apps/web/lib/chat/agent-message-comments.test.ts create mode 100644 apps/web/lib/chat/agent-message-comments.ts create mode 100644 apps/web/lib/chat/message-send-error.ts create mode 100644 apps/web/lib/state/slices/comments/message-comments.test.ts create mode 100644 docs/plans/agent-message-comments/plan.md create mode 100644 docs/plans/agent-message-comments/task-01-comment-foundation.md create mode 100644 docs/plans/agent-message-comments/task-02-selection-and-overlay.md create mode 100644 docs/plans/agent-message-comments/task-03-chat-wiring.md create mode 100644 docs/plans/agent-message-comments/task-04-e2e-and-verification.md create mode 100644 docs/specs/ui/agent-message-comments.md diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 46a7bb23f7..abe0bb8af8 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -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"], diff --git a/apps/web/components/task/chat-context-items.test.ts b/apps/web/components/task/chat-context-items.test.ts new file mode 100644 index 0000000000..265111db62 --- /dev/null +++ b/apps/web/components/task/chat-context-items.test.ts @@ -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", + }); + }); +}); diff --git a/apps/web/components/task/chat-context-items.ts b/apps/web/components/task/chat-context-items.ts index efe36026b2..8f3f20e7bf 100644 --- a/apps/web/components/task/chat-context-items.ts +++ b/apps/web/components/task/chat-context-items.ts @@ -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 = { plan: 0, @@ -200,8 +217,9 @@ const KIND_ORDER: Record = { 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) { diff --git a/apps/web/components/task/chat/__evals__/render-cost.test.tsx b/apps/web/components/task/chat/__evals__/render-cost.test.tsx index a37b852a20..daf218c815 100644 --- a/apps/web/components/task/chat/__evals__/render-cost.test.tsx +++ b/apps/web/components/task/chat/__evals__/render-cost.test.tsx @@ -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(); 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); + }); }); diff --git a/apps/web/components/task/chat/chat-input-area.test.ts b/apps/web/components/task/chat/chat-input-area.test.ts new file mode 100644 index 0000000000..dda08403cb --- /dev/null +++ b/apps/web/components/task/chat/chat-input-area.test.ts @@ -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."); + }); +}); diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index 1c70ce9003..fc89090bab 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -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"]); + }); }); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 88f00563a6..500c1365d1 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -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["toast"]) { +function showMessageSendToast(error: unknown, toast: ReturnType["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)} diff --git a/apps/web/components/task/chat/context-items/agent-message-comment-item.tsx b/apps/web/components/task/chat/context-items/agent-message-comment-item.tsx new file mode 100644 index 0000000000..440f71ea01 --- /dev/null +++ b/apps/web/components/task/chat/context-items/agent-message-comment-item.tsx @@ -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 ( + + ); +}); diff --git a/apps/web/components/task/chat/context-items/context-chip.tsx b/apps/web/components/task/chat/context-items/context-chip.tsx index 94eebbf0c0..3e19192d74 100644 --- a/apps/web/components/task/chat/context-items/context-chip.tsx +++ b/apps/web/components/task/chat/context-items/context-chip.tsx @@ -26,6 +26,7 @@ const ICON_BY_KIND: Record = { "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" > diff --git a/apps/web/components/task/chat/context-items/context-item-renderer.tsx b/apps/web/components/task/chat/context-items/context-item-renderer.tsx index c4423af0be..c78a884782 100644 --- a/apps/web/components/task/chat/context-items/context-item-renderer.tsx +++ b/apps/web/components/task/chat/context-items/context-item-renderer.tsx @@ -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 ; case "walkthrough-comment": return ; + case "agent-message-comment": + return ; } } diff --git a/apps/web/components/task/chat/context-items/plan-comment-item.tsx b/apps/web/components/task/chat/context-items/plan-comment-item.tsx index f7741b7ad6..d603389b68 100644 --- a/apps/web/components/task/chat/context-items/plan-comment-item.tsx +++ b/apps/web/components/task/chat/context-items/plan-comment-item.tsx @@ -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 = ( -
- {item.comments.map((comment) => ( -
- {comment.selectedText && ( -
- “{comment.selectedText}” -
- )} -
{comment.text}
-
- ))} -
- ); - return ( - diff --git a/apps/web/components/task/chat/context-items/selection-comment-item.tsx b/apps/web/components/task/chat/context-items/selection-comment-item.tsx new file mode 100644 index 0000000000..8b92ad3c5a --- /dev/null +++ b/apps/web/components/task/chat/context-items/selection-comment-item.tsx @@ -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 = ( +
+ {comments.map((comment) => ( +
+ {comment.selectedText ? ( +
+ “{comment.selectedText}” +
+ ) : null} +
{comment.text}
+
+ ))} +
+ ); + + return ( + + ); +}); diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index e609837335..d328c33c28 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -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} /> diff --git a/apps/web/components/task/chat/message-list-shared.test.tsx b/apps/web/components/task/chat/message-list-shared.test.tsx index ef9ba671b8..64a8f36ce0 100644 --- a/apps/web/components/task/chat/message-list-shared.test.tsx +++ b/apps/web/components/task/chat/message-list-shared.test.tsx @@ -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( diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index e9628ad516..4a4362ce06 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -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} /> diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx index 26c560eb40..5e4fcecfc1 100644 --- a/apps/web/components/task/chat/message-list-virtuoso.tsx +++ b/apps/web/components/task/chat/message-list-virtuoso.tsx @@ -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(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} /> @@ -171,6 +181,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) { onOpenFile, lastTurnGroupId, isRunning, + streamingMessageId, handleScrollToMessage, ], ); diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 2fcef0e5fd..366e889fd0 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -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]; diff --git a/apps/web/components/task/chat/messages/agent-message-content.tsx b/apps/web/components/task/chat/messages/agent-message-content.tsx new file mode 100644 index 0000000000..b8eb866406 --- /dev/null +++ b/apps/web/components/task/chat/messages/agent-message-content.tsx @@ -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 ( +
+
+ {showRaw ? ( +
+            {comment.raw_content || comment.content || "(empty)"}
+          
+ ) : ( + +
+ +
+
+ )} + {!showRaw && showRichBlocks ? : null} + +
+
+ ); +}); diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index f5c049e5da..f5b0cd384a 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -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 ( -
-
- {showRaw ? ( -
-            {comment.raw_content || comment.content || "(empty)"}
-          
- ) : ( -
- - {showRichBlocks ? : null} -
- )} - -
-
- ); -} - // ── 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} /> diff --git a/apps/web/components/task/chat/messages/message-comment-decorations.tsx b/apps/web/components/task/chat/messages/message-comment-decorations.tsx new file mode 100644 index 0000000000..75291f73ea --- /dev/null +++ b/apps/web/components/task/chat/messages/message-comment-decorations.tsx @@ -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) => ( + + )); +} + +function MessageCommentFallbackHighlights({ + decorations, +}: { + decorations: MessageCommentDecoration[]; +}) { + return decorations.flatMap((decoration) => + decoration.highlightRects.map((rect, index) => ( +