Skip to content

Commit 5e656d5

Browse files
vforshVladislav Forsh
andauthored
feat(messages): add quote action for composer (#504)
Co-authored-by: Vladislav Forsh <forsh@eot.games>
1 parent da5624b commit 5e656d5

5 files changed

Lines changed: 89 additions & 1 deletion

File tree

src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
109109
onPlanAccept={options.onPlanAccept}
110110
onPlanSubmitChanges={options.onPlanSubmitChanges}
111111
onOpenThreadLink={options.onOpenThreadLink}
112+
onQuoteMessage={options.canInsertComposerText ? options.onInsertComposerText : undefined}
112113
isThinking={options.isProcessing}
113114
isLoadingMessages={
114115
options.activeThreadId

src/features/messages/components/MessageRows.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Diff from "lucide-react/dist/esm/icons/diff";
88
import FileDiff from "lucide-react/dist/esm/icons/file-diff";
99
import FileText from "lucide-react/dist/esm/icons/file-text";
1010
import Image from "lucide-react/dist/esm/icons/image";
11+
import Quote from "lucide-react/dist/esm/icons/quote";
1112
import Search from "lucide-react/dist/esm/icons/search";
1213
import Terminal from "lucide-react/dist/esm/icons/terminal";
1314
import Users from "lucide-react/dist/esm/icons/users";
@@ -56,6 +57,7 @@ type MessageRowProps = MarkdownFileLinkProps & {
5657
item: Extract<ConversationItem, { kind: "message" }>;
5758
isCopied: boolean;
5859
onCopy: (item: Extract<ConversationItem, { kind: "message" }>) => void;
60+
onQuote?: (item: Extract<ConversationItem, { kind: "message" }>) => void;
5961
codeBlockCopyUseModifier?: boolean;
6062
};
6163

@@ -360,6 +362,7 @@ export const MessageRow = memo(function MessageRow({
360362
item,
361363
isCopied,
362364
onCopy,
365+
onQuote,
363366
codeBlockCopyUseModifier,
364367
showMessageFilePath,
365368
workspacePath,
@@ -414,6 +417,17 @@ export const MessageRow = memo(function MessageRow({
414417
onClose={() => setLightboxIndex(null)}
415418
/>
416419
)}
420+
{onQuote && hasText && (
421+
<button
422+
type="button"
423+
className="ghost message-quote-button"
424+
onClick={() => onQuote(item)}
425+
aria-label="Quote message"
426+
title="Quote message"
427+
>
428+
<Quote size={14} aria-hidden />
429+
</button>
430+
)}
417431
<button
418432
type="button"
419433
className={`ghost message-copy-button${isCopied ? " is-copied" : ""}`}

src/features/messages/components/Messages.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,33 @@ describe("Messages", () => {
144144
expect(markdown?.textContent ?? "").toContain("Literal [image] token");
145145
});
146146

147+
it("quotes a message into composer using markdown blockquote format", () => {
148+
const onQuoteMessage = vi.fn();
149+
const items: ConversationItem[] = [
150+
{
151+
id: "msg-quote-1",
152+
kind: "message",
153+
role: "assistant",
154+
text: "First line\nSecond line",
155+
},
156+
];
157+
158+
render(
159+
<Messages
160+
items={items}
161+
threadId="thread-1"
162+
workspaceId="ws-1"
163+
isThinking={false}
164+
openTargets={[]}
165+
selectedOpenAppId=""
166+
onQuoteMessage={onQuoteMessage}
167+
/>,
168+
);
169+
170+
fireEvent.click(screen.getByRole("button", { name: "Quote message" }));
171+
expect(onQuoteMessage).toHaveBeenCalledWith("> First line\n> Second line\n\n");
172+
});
173+
147174
it("opens linked review thread when clicking thread link", () => {
148175
const onOpenThreadLink = vi.fn();
149176
const items: ConversationItem[] = [

src/features/messages/components/Messages.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,21 @@ type MessagesProps = {
6060
onPlanAccept?: () => void;
6161
onPlanSubmitChanges?: (changes: string) => void;
6262
onOpenThreadLink?: (threadId: string) => void;
63+
onQuoteMessage?: (text: string) => void;
6364
};
6465

66+
function toMarkdownQuote(text: string): string {
67+
const trimmed = text.trim();
68+
if (!trimmed) {
69+
return "";
70+
}
71+
return trimmed
72+
.split(/\r?\n/)
73+
.map((line) => `> ${line}`)
74+
.join("\n")
75+
.concat("\n\n");
76+
}
77+
6578
export const Messages = memo(function Messages({
6679
items,
6780
threadId,
@@ -82,6 +95,7 @@ export const Messages = memo(function Messages({
8295
onPlanAccept,
8396
onPlanSubmitChanges,
8497
onOpenThreadLink,
98+
onQuoteMessage,
8599
}: MessagesProps) {
86100
const bottomRef = useRef<HTMLDivElement | null>(null);
87101
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -259,6 +273,20 @@ export const Messages = memo(function Messages({
259273
[],
260274
);
261275

276+
const handleQuoteMessage = useCallback(
277+
(item: Extract<ConversationItem, { kind: "message" }>) => {
278+
if (!onQuoteMessage) {
279+
return;
280+
}
281+
const quoteText = toMarkdownQuote(item.text);
282+
if (!quoteText) {
283+
return;
284+
}
285+
onQuoteMessage(quoteText);
286+
},
287+
[onQuoteMessage],
288+
);
289+
262290
useLayoutEffect(() => {
263291
const container = containerRef.current;
264292
const shouldScroll =
@@ -353,6 +381,7 @@ export const Messages = memo(function Messages({
353381
item={item}
354382
isCopied={isCopied}
355383
onCopy={handleCopyMessage}
384+
onQuote={onQuoteMessage ? handleQuoteMessage : undefined}
356385
codeBlockCopyUseModifier={codeBlockCopyUseModifier}
357386
showMessageFilePath={showMessageFilePath}
358387
workspacePath={workspacePath}

src/styles/messages.css

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,24 @@
176176
transition: opacity 160ms ease, transform 160ms ease;
177177
}
178178

179-
.message:hover .message-copy-button {
179+
.message-quote-button {
180+
display: inline-flex;
181+
align-items: center;
182+
justify-content: center;
183+
position: absolute;
184+
right: 34px;
185+
bottom: -12px;
186+
padding: 4px;
187+
border-radius: 999px;
188+
background: var(--surface-card-strong);
189+
border: 1px solid var(--border-strong);
190+
opacity: 0;
191+
transform: translateY(4px);
192+
transition: opacity 160ms ease, transform 160ms ease;
193+
}
194+
195+
.message:hover .message-copy-button,
196+
.message:hover .message-quote-button {
180197
opacity: 1;
181198
transform: translateY(0);
182199
}

0 commit comments

Comments
 (0)