diff --git a/apps/web/src/assistantResponseQuote.test.ts b/apps/web/src/assistantResponseQuote.test.ts new file mode 100644 index 000000000000..ecb89af18d64 --- /dev/null +++ b/apps/web/src/assistantResponseQuote.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildAssistantResponseQuoteInsertion, + formatAssistantResponseQuote, +} from "./assistantResponseQuote"; + +describe("assistant response quotes", () => { + it("formats multiline markdown as a reply quote", () => { + expect(formatAssistantResponseQuote(" First line\n\n- second line ")).toBe( + [ + "> **Replying to an assistant response:**", + ">", + "> First line", + "> ", + "> - second line", + ].join("\n"), + ); + }); + + it("inserts after existing composer text without adding excess blank lines", () => { + expect(buildAssistantResponseQuoteInsertion("My note\n", "quoted text")).toBe( + ["", "> **Replying to an assistant response:**", ">", "> quoted text", "", ""].join("\n"), + ); + expect(buildAssistantResponseQuoteInsertion("", " ")).toBeNull(); + }); +}); diff --git a/apps/web/src/assistantResponseQuote.ts b/apps/web/src/assistantResponseQuote.ts new file mode 100644 index 000000000000..3676a2fa199a --- /dev/null +++ b/apps/web/src/assistantResponseQuote.ts @@ -0,0 +1,21 @@ +const ASSISTANT_RESPONSE_QUOTE_LABEL = "Replying to an assistant response:"; + +export function formatAssistantResponseQuote(text: string): string | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + return [ + `> **${ASSISTANT_RESPONSE_QUOTE_LABEL}**`, + ">", + ...trimmed.split("\n").map((line) => `> ${line}`), + ].join("\n"); +} + +export function buildAssistantResponseQuoteInsertion(prompt: string, text: string): string | null { + const quote = formatAssistantResponseQuote(text); + if (!quote) return null; + + const separator = + prompt.length === 0 ? "" : prompt.endsWith("\n\n") ? "" : prompt.endsWith("\n") ? "\n" : "\n\n"; + return `${separator}${quote}\n\n`; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c307..e1b1ec6f2565 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -217,6 +217,7 @@ import { } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; +import { buildAssistantResponseQuoteInsertion } from "../assistantResponseQuote"; import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -2787,6 +2788,21 @@ function ChatViewContent(props: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const onReplyToAssistantSelection = useCallback( + (text: string) => { + const composer = composerRef.current; + const snapshot = composer?.readSnapshot(); + const insertion = buildAssistantResponseQuoteInsertion(snapshot?.value ?? "", text); + if (!composer || !insertion || !composer.insertTextAtEnd(insertion)) { + toastManager.add({ + type: "info", + title: "Reply unavailable", + description: "Finish the active composer request before attaching this quote.", + }); + } + }, + [composerRef], + ); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); @@ -6236,6 +6252,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} + onReplyToAssistantSelection={onReplyToAssistantSelection} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9cd1c78aa4d5..a244d61b5c95 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,6 +29,7 @@ import { type MouseEvent, type ReactNode, } from "react"; +import { createPortal } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { @@ -114,6 +115,7 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -140,6 +142,7 @@ interface TimelineRowSharedState { onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onReplyToAssistantSelection: (text: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; @@ -196,6 +199,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const; +const NOOP_REPLY_TO_ASSISTANT_SELECTION = () => {}; // --------------------------------------------------------------------------- // Props (public API) @@ -215,6 +219,7 @@ interface MessagesTimelineProps { turnDiffSummaryByAssistantMessageId: Map; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onReplyToAssistantSelection?: (text: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; @@ -261,6 +266,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaryByAssistantMessageId, routeThreadKey, onOpenTurnDiff, + onReplyToAssistantSelection = NOOP_REPLY_TO_ASSISTANT_SELECTION, revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, @@ -425,6 +431,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -513,6 +520,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onReplyToAssistantSelection, onToggleTurnFold, onToggleWorkGroup, agentPanelModel, @@ -529,6 +537,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onImageExpand, onOpenTurnDiff, + onReplyToAssistantSelection, onToggleTurnFold, onToggleWorkGroup, agentPanelModel, @@ -1104,17 +1113,76 @@ function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const markdownRootRef = useRef(null); + const [selectionAction, setSelectionAction] = useState<{ + text: string; + top: number; + left: number; + } | null>(null); + + const captureSelection = useCallback(() => { + const root = markdownRootRef.current; + const selection = window.getSelection(); + if (!root || !selection || selection.isCollapsed || selection.rangeCount !== 1) { + setSelectionAction(null); + return; + } + + const range = selection.getRangeAt(0); + if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) { + setSelectionAction(null); + return; + } + + const text = chatMarkdownClipboardPayload(selection)?.text.trim(); + const rect = Array.from(range.getClientRects()).at(-1) ?? range.getBoundingClientRect(); + if (!text || (rect.width === 0 && rect.height === 0)) { + setSelectionAction(null); + return; + } + + setSelectionAction({ + text, + top: Math.min(window.innerHeight - 44, rect.bottom + 8), + left: Math.max(48, Math.min(window.innerWidth - 48, rect.left + rect.width / 2)), + }); + }, []); + + const scheduleSelectionCapture = useCallback(() => { + window.requestAnimationFrame(captureSelection); + }, [captureSelection]); + + useEffect(() => { + if (!selectionAction) return; + + const dismiss = () => setSelectionAction(null); + + document.addEventListener("selectionchange", captureSelection); + document.addEventListener("scroll", dismiss, true); + window.addEventListener("resize", dismiss); + return () => { + document.removeEventListener("selectionchange", captureSelection); + document.removeEventListener("scroll", dismiss, true); + window.removeEventListener("resize", dismiss); + }; + }, [captureSelection, selectionAction]); return ( <> -
- +
+
+ +
) : null}
+ {selectionAction + ? createPortal( +
+ +
, + document.body, + ) + : null} ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 340a94d00c4c..f907996a3902 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1800,6 +1800,35 @@ label:has(> select#reasoning-effort) select { height: 100%; } +.assistant-selection-action { + transition-duration: 160ms; + transition-property: opacity, scale, translate, box-shadow; + transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1); +} + +@starting-style { + .assistant-selection-action { + opacity: 0; + scale: 0.96; + translate: 0 4px; + } +} + +@media (prefers-reduced-motion: reduce) { + .assistant-selection-action { + transition-duration: 120ms; + transition-property: opacity; + } + + @starting-style { + .assistant-selection-action { + opacity: 0; + scale: 1; + translate: 0; + } + } +} + /* Chat markdown rendering */ .chat-markdown { min-width: 0; diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..cafcdfdc96b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) +- [Chat and response replies](./user/chat.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) diff --git a/docs/user/chat.md b/docs/user/chat.md new file mode 100644 index 000000000000..eb388ad16247 --- /dev/null +++ b/docs/user/chat.md @@ -0,0 +1,11 @@ +# Chat + +## Reply to part of a response + +In the web or desktop app, select text in an assistant response and choose **Reply**. T3 Code adds +the selected passage to the message composer as a quote. Write your comment below it and send the +message normally. You can attach more than one passage before sending. + +The quote remains visible in chat history and is included in the next provider prompt. Mobile can +display these quoted replies, but selecting a response passage to create one currently requires the +web or desktop app.