Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/web/src/assistantResponseQuote.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 21 additions & 0 deletions apps/web/src/assistantResponseQuote.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
17 changes: 17 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -6236,6 +6252,7 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadEnvironmentId={activeThread.environmentId}
routeThreadKey={routeThreadKey}
onOpenTurnDiff={onOpenTurnDiff}
onReplyToAssistantSelection={onReplyToAssistantSelection}
revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
onRevertUserMessage={onRevertUserMessage}
isRevertingCheckpoint={isRevertingCheckpoint}
Expand Down
113 changes: 105 additions & 8 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -114,6 +115,7 @@ import {
} from "./userMessageTerminalContexts";
import { SkillInlineText } from "./SkillInlineText";
import { formatWorkspaceRelativePath } from "../../filePathDisplay";
import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
import {
buildReviewCommentRenderablePatch,
formatReviewCommentFence,
Expand All @@ -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;
Expand Down Expand Up @@ -196,6 +199,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = {
layout: true,
},
} as const;
const NOOP_REPLY_TO_ASSISTANT_SELECTION = () => {};

// ---------------------------------------------------------------------------
// Props (public API)
Expand All @@ -215,6 +219,7 @@ interface MessagesTimelineProps {
turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>;
routeThreadKey: string;
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
onReplyToAssistantSelection?: (text: string) => void;
revertTurnCountByUserMessageId: Map<MessageId, number>;
onRevertUserMessage: (messageId: MessageId) => void;
isRevertingCheckpoint: boolean;
Expand Down Expand Up @@ -261,6 +266,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
turnDiffSummaryByAssistantMessageId,
routeThreadKey,
onOpenTurnDiff,
onReplyToAssistantSelection = NOOP_REPLY_TO_ASSISTANT_SELECTION,
revertTurnCountByUserMessageId,
onRevertUserMessage,
isRevertingCheckpoint,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -513,6 +520,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onRevertUserMessage,
onImageExpand,
onOpenTurnDiff,
onReplyToAssistantSelection,
onToggleTurnFold,
onToggleWorkGroup,
agentPanelModel,
Expand All @@ -529,6 +537,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onRevertUserMessage,
onImageExpand,
onOpenTurnDiff,
onReplyToAssistantSelection,
onToggleTurnFold,
onToggleWorkGroup,
agentPanelModel,
Expand Down Expand Up @@ -1104,17 +1113,76 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
const ctx = use(TimelineRowCtx);
const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
const markdownRootRef = useRef<HTMLDivElement>(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 (
<>
<div className="relative min-w-0 px-1 py-0.5">
<ChatMarkdown
text={messageText}
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
isStreaming={Boolean(row.message.streaming)}
skills={ctx.skills}
/>
<div
className="relative min-w-0 px-1 py-0.5"
onKeyUp={scheduleSelectionCapture}
onPointerUp={scheduleSelectionCapture}
>
<div ref={markdownRootRef}>
<ChatMarkdown
text={messageText}
cwd={ctx.markdownCwd}
threadRef={ctx.threadRef ?? undefined}
isStreaming={Boolean(row.message.streaming)}
skills={ctx.skills}
/>
</div>
<AssistantChangedFilesSection
turnSummary={row.assistantTurnDiffSummary}
routeThreadKey={ctx.routeThreadKey}
Expand All @@ -1139,6 +1207,35 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
</div>
) : null}
</div>
{selectionAction
? createPortal(
<div
className="fixed z-[70]"
style={{
top: selectionAction.top,
left: selectionAction.left,
transform: "translateX(-50%)",
}}
>
<Button
size="sm"
variant="outline"
className="assistant-selection-action dropdown-glass h-8 gap-1.5 rounded-full px-3 shadow-lg shadow-black/15"
aria-label="Reply to selected assistant text"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
ctx.onReplyToAssistantSelection(selectionAction.text);
window.getSelection()?.removeAllRanges();
setSelectionAction(null);
}}
>
<MessageCircleIcon className="size-3.5" />
Reply
</Button>
</div>,
document.body,
)
: null}
</>
);
}
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions docs/user/chat.md
Original file line number Diff line number Diff line change
@@ -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.
Loading