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
90 changes: 90 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ import {
formatElementContextLabel,
} from "../lib/elementContext";
import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation";
import { isPreviewFocused } from "../lib/previewFocus";
import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext";
import { environmentCatalog } from "../connection/catalog";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
Expand Down Expand Up @@ -225,6 +226,12 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { FindInThreadBar } from "./chat/FindInThreadBar";
import {
clampThreadFindIndex,
isThreadFindActiveForThread,
stepThreadFindIndex,
} from "./chat/threadFind";
import { ChatHeader } from "./chat/ChatHeader";
import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls";
import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
Expand Down Expand Up @@ -309,6 +316,28 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
const EMPTY_PROVIDERS: ServerProvider[] = [];
const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};

interface ChatFindState {
open: boolean;
threadKey: string | null;
query: string;
activeIndex: number;
matchCount: number;
/** Bumped on every open request so a repeat Cmd+F re-selects the field. */
focusRequestId: number;
/** Bumped on every next/previous step so Enter on a lone match still re-reveals it. */
navigationId: number;
}
const CLOSED_CHAT_FIND_STATE: ChatFindState = {
open: false,
threadKey: null,
query: "",
activeIndex: 0,
matchCount: 0,
focusRequestId: 0,
navigationId: 0,
};

function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
const composerAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -1248,6 +1277,7 @@ function ChatViewContent(props: ChatViewProps) {
const localComposerRef = useRef<ChatComposerHandle | null>(null);
const composerRef = useComposerHandleContext() ?? localComposerRef;
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [findState, setFindState] = useState<ChatFindState>(CLOSED_CHAT_FIND_STATE);
const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
const optimisticUserMessagesRef = useRef(optimisticUserMessages);
Expand Down Expand Up @@ -4261,6 +4291,38 @@ function ChatViewContent(props: ChatViewProps) {
terminalUiOpenByThreadRef.current[activeThreadKey] = current;
}, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);

const openThreadFind = useCallback(() => {
setFindState((state) => ({
...state,
open: true,
threadKey: activeThreadKey,
focusRequestId: state.focusRequestId + 1,
}));
}, [activeThreadKey]);
const closeThreadFind = useCallback(() => {
setFindState(CLOSED_CHAT_FIND_STATE);
}, []);
const changeThreadFindQuery = useCallback((query: string) => {
setFindState((state) => ({ ...state, query, activeIndex: 0 }));
}, []);
const stepThreadFind = useCallback((delta: number) => {
setFindState((state) => ({
...state,
activeIndex: stepThreadFindIndex(state.activeIndex, state.matchCount, delta),
navigationId: state.navigationId + 1,
}));
}, []);
const handleThreadFindMatchCountChange = useCallback((matchCount: number) => {
setFindState((state) => (state.matchCount === matchCount ? state : { ...state, matchCount }));
}, []);
// Find is per-thread; switching threads drops it rather than carrying a query
// that matched somewhere else.
useEffect(() => {
setFindState(CLOSED_CHAT_FIND_STATE);
}, [activeThreadKey]);
Comment thread
cursor[bot] marked this conversation as resolved.
const isThreadFindActive = isThreadFindActiveForThread(findState, activeThreadKey);
const threadFindActiveIndex = clampThreadFindIndex(findState.activeIndex, findState.matchCount);
Comment thread
cursor[bot] marked this conversation as resolved.

useEffect(() => {
const handler = (event: globalThis.KeyboardEvent) => {
if (!activeThreadId || isCommandPaletteOpen()) {
Expand All @@ -4273,6 +4335,7 @@ function ChatViewContent(props: ChatViewProps) {
const shortcutContext = {
terminalFocus: terminalFocusOwner !== null,
terminalOpen: Boolean(terminalUiState.terminalOpen),
previewFocus: isPreviewFocused(),
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
};

Expand Down Expand Up @@ -4300,6 +4363,13 @@ function ChatViewContent(props: ChatViewProps) {
return;
}

if (command === "chat.find") {
event.preventDefault();
event.stopPropagation();
openThreadFind();
return;
}

if (command === "rightPanel.toggle") {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -4403,6 +4473,7 @@ function ChatViewContent(props: ChatViewProps) {
onToggleDiff,
toggleRightPanel,
toggleTerminalVisibility,
openThreadFind,
composerRef,
]);

Expand Down Expand Up @@ -5739,8 +5810,27 @@ function ChatViewContent(props: ChatViewProps) {
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
hideEmptyPlaceholder={isDraftHeroState}
topFadeEnabled={!hasTimelineTopBanner}
findQuery={isThreadFindActive ? findState.query : ""}
findActiveIndex={threadFindActiveIndex}
findNavigationId={findState.navigationId}
onFindMatchCountChange={handleThreadFindMatchCountChange}
/>

{isThreadFindActive && (
<div className="pointer-events-none absolute inset-x-0 top-2 z-30 flex justify-end px-3 sm:px-5">
<FindInThreadBar
query={findState.query}
matchCount={findState.matchCount}
activeIndex={threadFindActiveIndex}
focusRequestId={findState.focusRequestId}
onQueryChange={changeThreadFindQuery}
onNext={() => stepThreadFind(1)}
onPrevious={() => stepThreadFind(-1)}
onClose={closeThreadFind}
/>
</div>
)}

{/* scroll to end pill — shown when user has scrolled away from the live edge */}
{showScrollToBottom && (
<div
Expand Down
125 changes: 125 additions & 0 deletions apps/web/src/components/chat/FindInThreadBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
import { Button } from "../ui/button";
import { formatThreadFindCount } from "./threadFind";

interface FindInThreadBarProps {
query: string;
matchCount: number;
activeIndex: number;
/** Bumped by the caller to pull focus back into the field on a repeat Cmd+F. */
focusRequestId: number;
onQueryChange: (query: string) => void;
onNext: () => void;
onPrevious: () => void;
onClose: () => void;
}

export function FindInThreadBar({
query,
matchCount,
activeIndex,
focusRequestId,
onQueryChange,
onNext,
onPrevious,
onClose,
}: FindInThreadBarProps) {
const inputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
const input = inputRef.current;
if (!input) {
return;
}
input.focus();
input.select();
}, [focusRequestId]);

const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) {
return;
}
if (event.key === "Escape") {
event.preventDefault();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
event.stopPropagation();
onClose();
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (event.shiftKey) {
onPrevious();
} else {
onNext();
}
}
};

const hasQuery = query.trim().length > 0;
const noResults = hasQuery && matchCount === 0;

return (
<div
role="search"
aria-label="Find in thread"
className="pointer-events-auto flex items-center gap-1 rounded-full border border-border/60 bg-card px-2 py-1 shadow-md"
>
<input
ref={inputRef}
type="text"
value={query}
aria-label="Find in thread"
placeholder="Find in thread"
spellCheck={false}
autoComplete="off"
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={handleKeyDown}
className="h-6 w-44 bg-transparent px-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/72"
/>
<span
aria-live="polite"
className={
noResults
? "min-w-10 shrink-0 text-center text-xs tabular-nums text-destructive"
: "min-w-10 shrink-0 text-center text-xs tabular-nums text-muted-foreground"
}
>
{hasQuery ? formatThreadFindCount(activeIndex, matchCount) : ""}
</span>
<Button
type="button"
size="xs"
variant="ghost"
aria-label="Previous match"
disabled={matchCount === 0}
onClick={onPrevious}
className="size-6 p-0"
>
<ChevronUpIcon className="size-3.5" />
</Button>
<Button
type="button"
size="xs"
variant="ghost"
aria-label="Next match"
disabled={matchCount === 0}
onClick={onNext}
className="size-6 p-0"
>
<ChevronDownIcon className="size-3.5" />
</Button>
<Button
type="button"
size="xs"
variant="ghost"
aria-label="Close find"
onClick={onClose}
className="size-6 p-0"
>
<XIcon className="size-3.5" />
</Button>
</div>
);
}
Loading
Loading