Skip to content
Merged
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
56 changes: 56 additions & 0 deletions src/components/Composer.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,62 @@
max-width: 220px;
}

/* Diff comments batched into the draft (#1493): same pill as attachments,
wider, with the file:line in mono ahead of the comment. */
.reviewCommentRow {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 2px 2px 8px;
}

.reviewCommentChip {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
padding: 3px 6px 3px 8px;
border-radius: var(--radius-pill);
background: var(--card-hover);
border: 1px solid var(--border-soft);
color: var(--text-muted);
font-size: 11.5px;
}

.reviewCommentWhere {
flex-shrink: 0;
font-family: var(--mono);
font-size: 11px;
color: var(--text-dim);
}

.reviewCommentText {
min-width: 0;
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
text-align: left;
}

.reviewCommentText:hover {
text-decoration: underline;
text-decoration-color: var(--border-soft);
}

.reviewCommentInput {
min-width: 200px;
max-width: 360px;
padding: 0 4px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: var(--card);
color: var(--text);
font: inherit;
}

.attachmentIcon {
flex-shrink: 0;
opacity: 0.8;
Expand Down
117 changes: 111 additions & 6 deletions src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useId,
useRef,
useState,
useSyncExternalStore,
type ClipboardEvent,
type CSSProperties,
type KeyboardEvent,
Expand Down Expand Up @@ -80,7 +81,16 @@ import {
import { parseDelegate } from "../delegate";
import { asBtwPrompt } from "../btw";
import { buildBestOfNEntries, providerVendor } from "../bestOfN";
import { keptDrafts } from "../composerSession";
import {
getReviewComments,
keptDrafts,
recordSent,
scheduleDraftSave,
sentHistory,
setReviewComments,
subscribeReviewComments,
} from "../composerSession";
import { formatReviewCommentsPrompt } from "../diffView";
import {
pickerVerb,
type SlashAction,
Expand All @@ -103,13 +113,15 @@ import {
getPasteCardsEnabled,
setComposerBusyAction,
setLastReasoningEffort,
useEnterSendsEnabled,
useTranscriptViewMode,
type ComposerBusyAction,
} from "../uiPrefs";
import { applyComposerVim } from "../composerVim";
import { AttachmentChip } from "./composer/AttachmentChip";
import { CommandList, MentionList } from "./composer/ComposerPopups";
import { ReplyChip } from "./composer/ReplyChip";
import { ReviewCommentChips } from "./composer/ReviewCommentChips";
import { PasteCardList } from "./composer/PasteCardList";
import { SpeechControls } from "./composer/SpeechControls";
import { useComposerAttachments } from "./composer/useComposerAttachments";
Expand Down Expand Up @@ -352,6 +364,7 @@ export const Composer = memo(function Composer({
const currentProviderInfo = providers.find((p) => p.id === provider);
const canAttachImages = supportsImagesForModel(currentProviderInfo, model);
const transcriptView = useTranscriptViewMode();
const enterSends = useEnterSendsEnabled();
const { vimEnabled, vimMode, setVimMode, vimStateRef } =
useComposerVim(threadId);
const [viewOpen, setViewOpen] = useState(false);
Expand Down Expand Up @@ -389,6 +402,7 @@ export const Composer = memo(function Composer({
const rememberDraft = useCallback(
(text: string) => {
draftsRef.current[threadId] = text;
scheduleDraftSave();
syncHasPrompt(text);
syncOverflow(text);
},
Expand All @@ -397,6 +411,7 @@ export const Composer = memo(function Composer({
const writeDraft = useCallback(
(text: string, caret?: number) => {
draftsRef.current[threadId] = text;
scheduleDraftSave();
const el = textareaRef.current;
if (el) {
el.value = text;
Expand Down Expand Up @@ -472,6 +487,17 @@ export const Composer = memo(function Composer({
removePasteCard,
clearPasteCards,
} = usePasteCards({ threadId, pasteCardsRef, syncOverflow, readDraft });
/** Diff comments from the Git pane, sent as one block with the next prompt. */
const readReviewComments = () => getReviewComments(threadId);
const reviewComments = useSyncExternalStore(
subscribeReviewComments,
readReviewComments,
readReviewComments,
);
const clearReviewComments = useCallback(
() => setReviewComments(threadId, () => []),
[threadId],
);
const [stashToast, setStashToast] = useState<"stashed" | "restored" | null>(
null,
);
Expand Down Expand Up @@ -570,10 +596,13 @@ export const Composer = memo(function Composer({
});
/** Last idle Esc; a second press within DOUBLE_ESC_MS rewinds (#478). */
const lastEscAt = useRef(0);
/** Index into sentHistory while ↑/↓ is browsing; null when not. */
const recallIndexRef = useRef<number | null>(null);

useEffect(() => {
commandDismissed.current = false;
lastEscAt.current = 0;
recallIndexRef.current = null;
syncHasPrompt(draftsRef.current[threadId] ?? "");
return () => {
void cancelDictationRef.current();
Expand Down Expand Up @@ -615,7 +644,9 @@ export const Composer = memo(function Composer({
workflows.find((w) => w.id === templateId)?.name ?? null;

const canSend =
!disabled && !sending && (hasPrompt || pasteCards.length > 0);
!disabled &&
!sending &&
(hasPrompt || pasteCards.length > 0 || reviewComments.length > 0);
/**
* Everything that cannot be queued (workflow start, model, permission mode)
* waits for the run to land; only the prompt and Send stay live while busy.
Expand Down Expand Up @@ -865,30 +896,39 @@ export const Composer = memo(function Composer({

const composeOutgoing = useCallback(
(draft: string) => {
let body = composePastePrompt(draft.trim(), pasteCards);
let body = [
formatReviewCommentsPrompt(reviewComments),
composePastePrompt(draft.trim(), pasteCards),
]
.filter(Boolean)
.join("\n\n");
if (replyTo) {
body = wrapReplyContext(replyTo.text, body, replyTo.messageId, {
truncated: replyTo.truncated,
});
}
return body;
},
[pasteCards, replyTo],
[pasteCards, replyTo, reviewComments],
);

const runAction = async (
action: (prompt: string) => void | Promise<void>,
failLabel: string,
) => {
const prompt = composeOutgoing(readDraft());
const typed = readDraft();
const prompt = composeOutgoing(typed);
if (!prompt.trim() || disabled || sending) return;
setSending(true);
setLocalError(null);
try {
await action(prompt);
recordSent(threadId, typed);
recallIndexRef.current = null;
writeDraft("");
clearAttachments();
clearPasteCards();
clearReviewComments();
onClearReply?.();
closeMention();
closeCommand();
Expand Down Expand Up @@ -1039,6 +1079,7 @@ export const Composer = memo(function Composer({
writeDraft("");
clearAttachments();
clearPasteCards();
clearReviewComments();
onClearReply?.();
setStashToast("stashed");
};
Expand All @@ -1057,6 +1098,38 @@ export const Composer = memo(function Composer({
applyStashEntry(entry);
};

/**
* ↑ in an empty composer recalls the last prompt sent on this thread; ↑/↓
* keep walking while the recalled text is untouched. Edits, or a caret not
* on the first (↑) / last (↓) line, leave the arrows to the textarea.
*/
const recallSent = (el: HTMLTextAreaElement, up: boolean): boolean => {
const history = sentHistory[threadId] ?? [];
const at = recallIndexRef.current;
const browsing = at != null && el.value === history[at];
if (!browsing) recallIndexRef.current = null;
let next: number | null;
if (up) {
if (browsing) {
const caret = el.selectionStart;
if (at === 0 || (caret > 0 && el.value.lastIndexOf("\n", caret - 1) !== -1))
return false;
next = at - 1;
} else {
if (el.value !== "" || !history.length) return false;
next = history.length - 1;
}
} else {
if (!browsing || el.value.indexOf("\n", el.selectionEnd) !== -1)
return false;
next = at + 1 < history.length ? at + 1 : null;
}
recallIndexRef.current = next;
const text = next == null ? "" : history[next]!;
writeDraft(text, text.length);
return true;
};

const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === "s" || e.key === "S")) {
e.preventDefault();
Expand Down Expand Up @@ -1116,6 +1189,17 @@ export const Composer = memo(function Composer({
return;
}
}
if (
(e.key === "ArrowUp" || e.key === "ArrowDown") &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
recallSent(e.currentTarget, e.key === "ArrowUp")
) {
e.preventDefault();
return;
}
// Ctrl+C interrupts a live turn when nothing is selected so copy still
// works on a highlighted draft. Cmd+C is left to the platform copy chord.
if (
Expand Down Expand Up @@ -1149,7 +1233,13 @@ export const Composer = memo(function Composer({
else submitSend();
return;
}
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
// Default: ⌘/Ctrl/⇧+Enter send, bare Enter is a newline. With the
// Enter-sends preference, bare Enter sends and ⇧Enter is the newline.
if (
e.key === "Enter" &&
!e.nativeEvent.isComposing &&
(e.metaKey || e.ctrlKey || (enterSends ? !e.shiftKey : e.shiftKey))
) {
e.preventDefault();
submitSend();
return;
Expand Down Expand Up @@ -1558,6 +1648,21 @@ export const Composer = memo(function Composer({
removePasteCard={removePasteCard}
/>
)}
{reviewComments.length > 0 && (
<ReviewCommentChips
comments={reviewComments}
onEdit={(id, text) =>
setReviewComments(threadId, (prev) =>
prev.map((c) => (c.id === id ? { ...c, text } : c)),
)
}
onRemove={(id) =>
setReviewComments(threadId, (prev) =>
prev.filter((c) => c.id !== id),
)
}
/>
)}
{attachments.length > 0 && (
<div className={styles.attachmentRow} aria-label="Attachments">
{attachments.map((a) => (
Expand Down
25 changes: 19 additions & 6 deletions src/components/KeyboardSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useRef } from "react";
import { useComposerVimEnabled } from "../uiPrefs";
import { useComposerVimEnabled, useEnterSendsEnabled } from "../uiPrefs";
import { useEscapeClose } from "../useEscapeClose";
import { useModalFocus } from "../useModalFocus";
import styles from "./KeyboardSheet.module.css";
Expand All @@ -21,6 +21,7 @@ const APP_SHORTCUTS: readonly ShortcutRow[] = [
{ keys: "⌘ + P", action: "Search files in this project" },
{ keys: "⌘ + ⇧ + F", action: "Search file contents" },
{ keys: "⌘ + Enter", action: "Send message" },
{ keys: "↑", action: "Recall the last sent prompt (empty composer)" },
{ keys: "⌥ + Enter", action: "Ask a side question (/btw)" },
{ keys: "⌘ + ⇧ + Enter", action: "Steer the live turn" },
{ keys: "⌘ + S", action: "Stash the draft" },
Expand Down Expand Up @@ -55,10 +56,21 @@ const VIM_SHORTCUTS: readonly ShortcutRow[] = [
{ keys: "o / O", action: "Open line below / above" },
];

function appShortcutRows(composerVim: boolean): readonly ShortcutRow[] {
if (!composerVim) return APP_SHORTCUTS;
return APP_SHORTCUTS.map((row) =>
row.keys === "Escape" ? VIM_ESCAPE : row,
const ENTER_SENDS_ROWS: readonly ShortcutRow[] = [
{ keys: "Enter", action: "Send message" },
{ keys: "⇧ + Enter", action: "New line" },
];

function appShortcutRows(
composerVim: boolean,
enterSends: boolean,
): readonly ShortcutRow[] {
return APP_SHORTCUTS.flatMap((row) =>
composerVim && row.keys === "Escape"
? [VIM_ESCAPE]
: enterSends && row.keys === "⌘ + Enter"
? ENTER_SENDS_ROWS
: [row],
);
}

Expand All @@ -82,6 +94,7 @@ interface KeyboardSheetProps {

export function KeyboardSheet({ open, onClose }: KeyboardSheetProps) {
const composerVim = useComposerVimEnabled();
const enterSends = useEnterSendsEnabled();
const dialogRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => onClose(), [onClose]);
useEscapeClose(open, handleClose);
Expand Down Expand Up @@ -119,7 +132,7 @@ export function KeyboardSheet({ open, onClose }: KeyboardSheetProps) {
×
</button>
</header>
<ShortcutList rows={appShortcutRows(composerVim)} />
<ShortcutList rows={appShortcutRows(composerVim, enterSends)} />
{composerVim && (
<>
<h3 className={styles.section}>Composer vim</h3>
Expand Down
Loading
Loading