Skip to content
Open
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
81 changes: 77 additions & 4 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import {
parseStandaloneComposerSlashCommand,
replaceTextRange,
} from "../composer-logic";
import {
navigateComposerPromptHistory,
resolveComposerPromptHistoryEntries,
} from "../composerPromptHistory";
import {
derivePendingApprovals,
derivePendingUserInputs,
Expand Down Expand Up @@ -264,6 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
>({});
const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
useState<Record<string, number>>({});
const composerPromptHistoryNavigationRef = useRef<{
draftPrompt: string;
historyIndex: number;
} | null>(null);
const isApplyingComposerPromptHistoryRef = useRef(false);
const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
const [planSidebarOpen, setPlanSidebarOpen] = useState(false);
const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false);
Expand Down Expand Up @@ -321,6 +330,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
messagesScrollRef.current = element;
setMessagesScrollElement(element);
}, []);
const resetComposerPromptHistoryNavigation = useCallback(() => {
composerPromptHistoryNavigationRef.current = null;
isApplyingComposerPromptHistoryRef.current = false;
}, []);

const terminalState = useTerminalStateStore((state) =>
selectThreadTerminalState(state.terminalStateByThreadId, threadId),
Expand Down Expand Up @@ -814,6 +827,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
[activeThread?.proposedPlans, timelineMessages, workLogEntries],
);
const composerPromptHistoryEntries = useMemo(
() =>
activeThread
? resolveComposerPromptHistoryEntries({
currentProjectId: activeThread.projectId,
currentThreadMessages: timelineMessages,
projects,
threads,
ignoredMessageTexts: [IMAGE_ONLY_BOOTSTRAP_PROMPT],
})
: [],
[activeThread, projects, threads, timelineMessages],
);
const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
useTurnDiffSummaries(activeThread);
const turnDiffSummaryByAssistantMessageId = useMemo(() => {
Expand Down Expand Up @@ -1761,7 +1787,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
useEffect(() => {
promptRef.current = prompt;
setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
}, [prompt]);
if (isApplyingComposerPromptHistoryRef.current) {
isApplyingComposerPromptHistoryRef.current = false;
return;
}
resetComposerPromptHistoryNavigation();
}, [prompt, resetComposerPromptHistoryNavigation]);

useEffect(() => {
setOptimisticUserMessages((existing) => {
Expand All @@ -1775,10 +1806,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
setComposerHighlightedItemId(null);
setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
resetComposerPromptHistoryNavigation();
dragDepthRef.current = 0;
setIsDragOverComposer(false);
setExpandedImage(null);
}, [threadId]);
}, [resetComposerPromptHistoryNavigation, threadId]);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -2211,6 +2243,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
planMarkdown: activeProposedPlan.planMarkdown,
});
promptRef.current = "";
resetComposerPromptHistoryNavigation();
clearComposerDraftContent(activeThread.id);
setComposerHighlightedItemId(null);
setComposerCursor(0);
Expand All @@ -2226,6 +2259,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
if (standaloneSlashCommand) {
await handleInteractionModeChange(standaloneSlashCommand);
promptRef.current = "";
resetComposerPromptHistoryNavigation();
clearComposerDraftContent(activeThread.id);
setComposerHighlightedItemId(null);
setComposerCursor(0);
Expand Down Expand Up @@ -2293,6 +2327,7 @@ export default function ChatView({ threadId }: ChatViewProps) {

setThreadError(threadIdForSend, null);
promptRef.current = "";
resetComposerPromptHistoryNavigation();
clearComposerDraftContent(threadIdForSend);
setComposerHighlightedItemId(null);
setComposerCursor(0);
Expand Down Expand Up @@ -2969,6 +3004,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
value: string;
cursor: number;
expandedCursor: number;
isOnFirstVisualLine: boolean;
isOnLastVisualLine: boolean;
} => {
const editorSnapshot = composerEditorRef.current?.readSnapshot();
if (editorSnapshot) {
Expand All @@ -2978,11 +3015,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
value: promptRef.current,
cursor: composerCursor,
expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
isOnFirstVisualLine: true,
isOnLastVisualLine: true,
};
}, [composerCursor]);

const resolveActiveComposerTrigger = useCallback((): {
snapshot: { value: string; cursor: number; expandedCursor: number };
snapshot: {
value: string;
cursor: number;
expandedCursor: number;
isOnFirstVisualLine: boolean;
isOnLastVisualLine: boolean;
};
trigger: ComposerTrigger | null;
} => {
const snapshot = readComposerSnapshot();
Expand Down Expand Up @@ -3130,7 +3175,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
return true;
}

const { trigger } = resolveActiveComposerTrigger();
const { snapshot, trigger } = resolveActiveComposerTrigger();
const menuIsActive = composerMenuOpenRef.current || trigger !== null;

if (menuIsActive) {
Expand All @@ -3152,6 +3197,34 @@ export default function ChatView({ threadId }: ChatViewProps) {
}
}

if (
(key === "ArrowUp" || key === "ArrowDown") &&
!isComposerApprovalState &&
!activePendingProgress &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
(key === "ArrowUp" ? snapshot.isOnFirstVisualLine : snapshot.isOnLastVisualLine)
) {
const navigation = navigateComposerPromptHistory({
currentPrompt: snapshot.value,
direction: key === "ArrowUp" ? "up" : "down",
entries: composerPromptHistoryEntries,
navigationState: composerPromptHistoryNavigationRef.current,
});
if (navigation.handled) {
composerPromptHistoryNavigationRef.current = navigation.nextNavigationState;
isApplyingComposerPromptHistoryRef.current = true;
promptRef.current = navigation.nextPrompt;
setPrompt(navigation.nextPrompt);
setComposerCursor(
collapseExpandedComposerCursor(navigation.nextPrompt, navigation.nextPrompt.length),
);
setComposerTrigger(detectComposerTrigger(navigation.nextPrompt, navigation.nextPrompt.length));
return true;
}
}

if (key === "Enter" && !event.shiftKey) {
void onSend();
return true;
Expand Down
90 changes: 88 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,13 @@ export interface ComposerPromptEditorHandle {
focus: () => void;
focusAt: (cursor: number) => void;
focusAtEnd: () => void;
readSnapshot: () => { value: string; cursor: number; expandedCursor: number };
readSnapshot: () => {
value: string;
cursor: number;
expandedCursor: number;
isOnFirstVisualLine: boolean;
isOnLastVisualLine: boolean;
};
}

interface ComposerPromptEditorProps {
Expand All @@ -484,6 +490,74 @@ interface ComposerPromptEditorInnerProps extends ComposerPromptEditorProps {
editorRef: Ref<ComposerPromptEditorHandle>;
}

function readComposerVisualLineState(
rootElement: HTMLElement | null,
fallback: {
isOnFirstVisualLine: boolean;
isOnLastVisualLine: boolean;
},
value: string,
): {
isOnFirstVisualLine: boolean;
isOnLastVisualLine: boolean;
} {
if (!rootElement || value.length === 0 || typeof window === "undefined") {
return {
isOnFirstVisualLine: true,
isOnLastVisualLine: true,
};
}

const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
return fallback;
}

const selectionRange = selection.getRangeAt(0);
if (!rootElement.contains(selectionRange.startContainer)) {
return fallback;
}

const caretRange = selectionRange.cloneRange();
caretRange.collapse(true);
const caretRect = caretRange.getClientRects()[0] ?? caretRange.getBoundingClientRect();
if (!caretRect || (!caretRect.width && !caretRect.height && !caretRect.top && !caretRect.bottom)) {
return fallback;
}

const contentRange = document.createRange();
contentRange.selectNodeContents(rootElement);
const contentRect = contentRange.getBoundingClientRect();
if (
!contentRect ||
(!contentRect.width &&
!contentRect.height &&
!contentRect.top &&
!contentRect.bottom)
) {
return {
isOnFirstVisualLine: true,
isOnLastVisualLine: true,
};
}

const rootRect = rootElement.getBoundingClientRect();
const computedLineHeight = Number.parseFloat(window.getComputedStyle(rootElement).lineHeight);
const tolerance = Number.isFinite(computedLineHeight)
? Math.max(2, computedLineHeight / 2)
: 4;
const scrollTop = rootElement.scrollTop;
const maxScrollTop = Math.max(0, rootElement.scrollHeight - rootElement.clientHeight);
const visibleTop = Math.max(contentRect.top, rootRect.top);
const visibleBottom = Math.min(contentRect.bottom, rootRect.bottom);

return {
isOnFirstVisualLine: scrollTop <= tolerance && caretRect.top <= visibleTop + tolerance,
isOnLastVisualLine:
scrollTop >= maxScrollTop - tolerance && caretRect.bottom >= visibleBottom - tolerance,
};
}

function ComposerCommandKeyPlugin(props: {
onCommandKeyDown?: (
key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
Expand Down Expand Up @@ -713,6 +787,8 @@ function ComposerPromptEditorInner({
value,
cursor: initialCursor,
expandedCursor: expandCollapsedComposerCursor(value, initialCursor),
isOnFirstVisualLine: true,
isOnLastVisualLine: true,
});
const isApplyingControlledUpdateRef = useRef(false);

Expand All @@ -735,6 +811,7 @@ function ComposerPromptEditorInner({
value,
cursor: normalizedCursor,
expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),
...readComposerVisualLineState(editor.getRootElement(), snapshotRef.current, value),
};

const rootElement = editor.getRootElement();
Expand Down Expand Up @@ -771,6 +848,11 @@ function ComposerPromptEditorInner({
value: snapshotRef.current.value,
cursor: boundedCursor,
expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
...readComposerVisualLineState(
editor.getRootElement(),
snapshotRef.current,
snapshotRef.current.value,
),
};
onChangeRef.current(
snapshotRef.current.value,
Expand All @@ -786,6 +868,8 @@ function ComposerPromptEditorInner({
value: string;
cursor: number;
expandedCursor: number;
isOnFirstVisualLine: boolean;
isOnLastVisualLine: boolean;
} => {
let snapshot = snapshotRef.current;
editor.getEditorState().read(() => {
Expand All @@ -807,6 +891,7 @@ function ComposerPromptEditorInner({
value: nextValue,
cursor: nextCursor,
expandedCursor: nextExpandedCursor,
...readComposerVisualLineState(editor.getRootElement(), snapshotRef.current, nextValue),
};
});
snapshotRef.current = snapshot;
Expand Down Expand Up @@ -864,13 +949,14 @@ function ComposerPromptEditorInner({
value: nextValue,
cursor: nextCursor,
expandedCursor: nextExpandedCursor,
...readComposerVisualLineState(editor.getRootElement(), snapshotRef.current, nextValue),
};
const cursorAdjacentToMention =
isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") ||
isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right");
onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention);
});
}, []);
}, [editor]);

return (
<div className="relative">
Expand Down
Loading
Loading