feat(web): add in-thread find#7434

closed19
main
GitHub fork: codex/find-in-thread
openedAug 18, 2026byBbj97301
View on GitHubTwo-way sync enabledPR last imported 3d ago
24 unresolved threads Β· Jump to file
Selected thread: apps/web/src/components/chat/MessagesTimeline.tsx:729 β€” `shadow-lg` is cancelled exactly when this bar is in use. `InputGroup`'s base contract includes `has-[input:disabled,textarea:disabled,input:focus-visible,…]:shadow-none`, which compiles to `.has-\[…\]\:shadow-none:has(input:focus-visible, …)` (0-2-1) and outranks the plain `.shadow-lg` utility (0-1-0). The bar focuses its input on open (the `requestAnimationFrame` focus effect), so the floating panel paints with **no** elevation in its normal state and only gains the drop shadow once focus leaves β€” the opposite of the intent, and it leaves the overlay visually unseparated from the messages scrolling underneath it. That state rule is deliberate for an in-flow control (ring replaces shadow on focus), so the elevation belongs on a wrapper surface rather than on the control itself β€” the same composition the review overlay in `PullRequestCodeTab.tsx` uses (`surface-glass … rounded-xl border border-border/60 shadow-lg` wrapping its controls). ```diff - <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg **:[input]:w-48"> + <div className="absolute right-4 top-3 z-30 rounded-[var(--control-radius)] shadow-lg"> + <InputGroup className="w-auto **:[input]:w-48"> ``` …with the matching close (indent the existing children one level): ```diff - </InputGroup> + </InputGroup> + </div> ``` <sup>Posted via Macroscope β€” <a href="https://github.com/pingdotgg/t3code/runs/95870012685">UI Consistency</a></sup>
0 / 8 viewed8 files changedLine totals unavailable

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 6fec1d22 Β· right 729–729

Original diff
@@ -573,6 +725,60 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg dark:bg-background **:[input]:w-48">

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 729–729

M
macroscopeappBotAug 18, 2026

shadow-lg is inert while the field is focused. InputGroup's base contains has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,...]:shadow-none, which emits a :has(input:focus-visible) selector at specificity (0,2,1) and outranks the call-site .shadow-lg (0,1,0); tailwind-merge keeps both because the modifiers differ. The bar auto-focuses its input on open, so the floating panel shows no elevation in its normal state and only pops the shadow back when focus moves to the chevron/close buttons β€” elevation flickers as focus moves inside one widget.

That shadow-none is the inline-field contract (focus ring replaces the shadow), so panel elevation shouldn't be owned by the field. Consider putting the floating treatment on a positioning wrapper and leaving InputGroup its own shadow states:

tsx
1<div className="absolute right-4 top-3 z-30 rounded-[var(--control-radius)] shadow-lg">
2 <InputGroup className="w-auto dark:bg-background **:[input]:w-48">
3 ...
4 </InputGroup>
5</div>

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 673fbf46. Positioning, radius, and shadow-lg now live on a wrapper, while InputGroup retains ownership of its focus ring and inline-field shadow states, so elevation remains stable as focus moves within the find widget.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 4ab4c6ff Β· right 678–691

Original diff
@@ -573,6 +674,57 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <div className="absolute right-4 top-3 z-30 flex h-9 items-center gap-1 rounded-lg border bg-background/95 px-2 shadow-lg backdrop-blur">
+              <SearchIcon className="size-3.5 text-muted-foreground" aria-hidden="true" />
+              <input
+                ref={findInputRef}
+                value={findQuery}
+                onChange={(event) => updateFindQuery(event.target.value)}
+                onKeyDown={(event) => {
+                  if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1);
+                  if (event.key === "Escape") setFindOpen(false);
+                }}
+                aria-label="Find in thread"
+                placeholder="Find in thread"
+                className="w-48 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
+              />

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 678–691

M
macroscopeappBotAug 18, 2026

The find bar rebuilds the shared input shell by hand: the wrapper owns the border/background/shadow and the field is a raw <input>. That skips InputGroup's focus treatment (has-focus-visible:border-ring + ring-[3px]), the text-placeholder token, and the responsive text-base sm:text-sm sizing that every other search field uses (PullRequestListFilters, FileBrowserPanel), so this control focuses and reads differently from the rest of the app. Consider composing the primitives, and using the shared surface-glass utility for the floating shell instead of the one-off bg-background/95 ... backdrop-blur:

tsx
1<InputGroup className="surface-glass absolute top-3 right-4 z-30 w-auto shadow-lg">
2 <InputGroupAddon>
3 <SearchIcon aria-hidden="true" />
4 </InputGroupAddon>
5 <InputGroupInput
6 ref={findInputRef}
7 value={findQuery}
8 onChange={...}
9 onKeyDown={...}
10 aria-label="Find in thread"
11 placeholder="Find in thread"
12 className="w-48"
13 />
14 <InputGroupAddon align="inline-end">{/* count + icon-xs buttons */}</InputGroupAddon>
15</InputGroup>

The addon also sizes the leading icon consistently with the neighbouring icon-xs buttons, replacing the one-off size-3.5.

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 409b73bc. The find bar now uses the shared input-group primitives and surface-glass styling.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 4ab4c6ff Β· right 447–460

Original diff
@@ -420,6 +424,103 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
+  const findTexts = useMemo(
+    () => rows.map((row) => (row.kind === "message" ? row.message.text : null)),
+    [rows],
+  );
+  const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]);
+  const updateFindQuery = useCallback(
+    (query: string) => {
+      setFindQuery(query);
+      setFindMatchIndex(0);
+      const firstMatch = findTextMatches(findTexts, query)[0];
+      if (firstMatch === undefined) return;
+      onManualNavigation();
+      void listRef.current?.scrollToIndex({ index: firstMatch, animated: false, viewOffset: 80 });
+    },
+    [findTexts, listRef, onManualNavigation],
+  );
+  const goToFindMatch = useCallback(
+    (delta: number) => {
+      if (findMatches.length === 0) return;
+      const next = (findMatchIndex + delta + findMatches.length) % findMatches.length;
+      setFindMatchIndex(next);
+      onManualNavigation();
+      void listRef.current?.scrollToIndex({
+        index: findMatches[next]!,
+        animated: false,
+        viewOffset: 80,
+      });
+    },
+    [findMatchIndex, findMatches, listRef, onManualNavigation],
+  );

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 447–460

M
macroscopeappBotAug 18, 2026

findMatches repeats a row index once per occurrence in that row (findTextMatches(["One one", ...], "one") -> [0, 0, ...]), but scrolling and the highlight effect are keyed on the row only. When a message contains two hits, Next/Previous bumps the n/total counter while the scroll position and the highlighted ranges stay identical, so the button reads as dead. Consider navigating over de-duplicated row indices (keeping the occurrence total for the label), or carrying the per-occurrence offset so each step targets a distinct range.

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 409b73bc. Matches carry an occurrence index, and only that exact range is highlighted, so next/previous visibly advances between repeated matches in one message.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 409b73bc Β· right 720–720

Original diff
@@ -573,6 +716,61 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <InputGroup className="surface-glass absolute right-4 top-3 z-30 h-9 w-auto shadow-lg">

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 720–720

M
macroscopeappBotAug 18, 2026

The field geometry is applied to elements that can't render it. InputGroup's base sets *:[[data-slot=input-control]]:contents, and InputGroupInput's className lands on exactly that data-slot="input-control" wrapper β€” so w-48 (line 734) generates no box and the bar falls back to the input's default intrinsic width. Separately, h-9 pins the shell to 36px at every breakpoint, so this control skips the sm: compaction the shared input ramp (h-8.5 sm:h-7.5) gives every other search field, and leaves the 30px input floating inside a 36px shell on desktop.

The established pattern is to size the inner input from the group (PullRequestListFilters uses **:[input]:h-9 sm:**:[input]:h-8):

diff
1- <InputGroup className="surface-glass absolute right-4 top-3 z-30 h-9 w-auto shadow-lg">
2+ <InputGroup className="surface-glass absolute right-4 top-3 z-30 w-auto shadow-lg **:[input]:w-48">

and drop className="w-48" from the InputGroupInput so the primitive keeps ownership of the height ramp.

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 378b338a. Width is now applied from InputGroup with **:[input]:w-48, and the fixed shell height was removed so the shared responsive height ramp remains in control.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision fa38104a Β· right 453

Original diff
@@ -420,6 +423,65 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
+  const findTexts = useMemo(
+    () => rows.map((row) => (row.kind === "message" ? row.message.text : null)),
+    [rows],
+  );
+  const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]);
+  const updateFindQuery = useCallback(
+    (query: string) => {
+      setFindQuery(query);
+      setFindMatchIndex(0);
+      const firstMatch = findTextMatches(findTexts, query)[0];
+      if (firstMatch === undefined) return;
+      onManualNavigation();
+      void listRef.current?.scrollToIndex({ index: firstMatch, animated: true, viewOffset: 80 });
+    },
+    [findTexts, listRef, onManualNavigation],
+  );
+  const goToFindMatch = useCallback(
+    (delta: number) => {
+      if (findMatches.length === 0) return;
+      const next = (findMatchIndex + delta + findMatches.length) % findMatches.length;
+      setFindMatchIndex(next);
+      onManualNavigation();
+      void listRef.current?.scrollToIndex({
+        index: findMatches[next]!,
M
macroscopeappBotAug 18, 2026

🟑 Medium chat/MessagesTimeline.tsx:453

goToFindMatch advances the counter for each occurrence but scrolls only to the matched message row, so later matches within a long message remain off-screen while the UI reports them as selected. Preserve the occurrence offset and reveal or highlight that specific occurrence instead of calling scrollToIndex for the row alone.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 453:
2
3`goToFindMatch` advances the counter for each occurrence but scrolls only to the matched message row, so later matches within a long message remain off-screen while the UI reports them as selected. Preserve the occurrence offset and reveal or highlight that specific occurrence instead of calling `scrollToIndex` for the row alone.
B
bj97301Aug 18, 2026

Addressed in 409b73bc. Navigation now tracks individual occurrences and scrolls the selected DOM range into view, including later matches inside a long message.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 6fec1d22 Β· right 729–729

Original diff
@@ -573,6 +725,60 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg dark:bg-background **:[input]:w-48">

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 729–729

M
macroscopeappBotAug 18, 2026

shadow-lg is cancelled exactly when this bar is in use. InputGroup's base contract includes has-[input:disabled,textarea:disabled,input:focus-visible,…]:shadow-none, which compiles to .has-\[…\]\:shadow-none:has(input:focus-visible, …) (0-2-1) and outranks the plain .shadow-lg utility (0-1-0). The bar focuses its input on open (the requestAnimationFrame focus effect), so the floating panel paints with no elevation in its normal state and only gains the drop shadow once focus leaves β€” the opposite of the intent, and it leaves the overlay visually unseparated from the messages scrolling underneath it.

That state rule is deliberate for an in-flow control (ring replaces shadow on focus), so the elevation belongs on a wrapper surface rather than on the control itself β€” the same composition the review overlay in PullRequestCodeTab.tsx uses (surface-glass … rounded-xl border border-border/60 shadow-lg wrapping its controls).

diff
1- <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg **:[input]:w-48">
2+ <div className="absolute right-4 top-3 z-30 rounded-[var(--control-radius)] shadow-lg">
3+ <InputGroup className="w-auto **:[input]:w-48">

…with the matching close (indent the existing children one level):

diff
1- </InputGroup>
2+ </InputGroup>
3+ </div>

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 673fbf46. Positioning, radius, and shadow-lg now live on a wrapper, while InputGroup retains ownership of its focus ring and inline-field shadow states, so elevation remains stable as focus moves within the find widget.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 48386913 Β· right 822–838

Original diff
@@ -573,6 +802,78 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <div className="absolute right-4 top-3 z-30 rounded-[var(--control-radius)] shadow-lg">
+              <InputGroup className="w-auto dark:bg-background **:[input]:w-48">
+                <InputGroupAddon>
+                  <SearchIcon aria-hidden="true" />
+                </InputGroupAddon>
+                <InputGroupInput
+                  ref={findInputRef}
+                  value={findQuery}
+                  onChange={(event) => updateFindQuery(event.target.value)}
+                  onKeyDown={(event) => {
+                    if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1);
+                  }}
+                  aria-label="Find in thread"
+                  placeholder="Find in thread"
+                />
+                <InputGroupAddon align="inline-end" className="gap-1">
+                  <Button
+                    type="button"
+                    size="icon-xs"
+                    variant="ghost"
+                    className="data-pressed:border-primary! data-pressed:bg-primary! data-pressed:text-primary-foreground!"
+                    data-pressed={findCaseSensitive || undefined}
+                    aria-label="Match case"
+                    aria-pressed={findCaseSensitive}
+                    title="Match case"
+                    onClick={() => {
+                      setFindCaseSensitive(!findCaseSensitive);
+                      setFindMatchIndex(0);
+                      onManualNavigation();
+                    }}
+                  >
+                    <span className="text-[11px] font-semibold leading-none">Aa</span>
+                  </Button>

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 822–838

M
macroscopeappBotAug 18, 2026

chat/MessagesTimeline.tsx:838

This rebuilds the Toggle primitive on top of Button: data-pressed is set by hand (findCaseSensitive || undefined), aria-pressed is mirrored manually, and the ghost variant's pressed state ([:hover,[data-pressed]]:bg-accent) is then overridden with three !important utilities to repaint it as bg-primary/text-primary-foreground. Two consequences: the pressed state no longer matches any other pressed control in the app (Toggle ghost is data-pressed:bg-accent data-pressed:text-accent-foreground), and because data-pressed also drives hover selectors, the un-pressed hover/pressed distinction is now decided entirely by call-site !important rather than the primitive contract.

The app already owns this exact control β€” ProjectContentSearchDialog's SearchOptionButton renders the "Aa" match-case option as <Toggle pressed … variant="ghost">, and PanelLayoutControls uses the same pressed/onPressedChange shape. Consider composing Toggle here (Base UI emits aria-pressed and data-pressed for you) and dropping the ! overrides, keeping only contextual classes:

tsx
1<Toggle
2 size="xs"
3 variant="ghost"
4 pressed={findCaseSensitive}
5 onPressedChange={(pressed) => {
6 setFindCaseSensitive(pressed);
7 setFindMatchIndex(0);
8 onManualNavigation();
9 }}
10 aria-label="Match case"
11 title="Match case"
12>
13 <span className="text-[11px] font-semibold leading-none">Aa</span>
14</Toggle>

(needs import { Toggle } from "../ui/toggle";). If the accent pressed tint really is too subtle inside the input group, extending the shared toggle contract is preferable to an !important override at one call site.

No longer relevant as of 8753a9bd9f7b0e798f99897084f8a5c014eedd7e

apps/web/src/components/chat/MessagesTimeline.tsx Β· File comment

M
macroscopeappBotAug 18, 2026

surface-glass can't own the background on this element: InputGroup's default variant already paints the control surface (bg-background plus dark:bg-input/32), and the dark rule compiles to .dark\:bg-input\/32:is(.dark, .dark *) (0-2-0), which outranks the surface-glass utility (0-1-0). So in dark themes the bar is tinted with --input at 32% instead of --background at --glass-opacity, and in light themes the winner is decided purely by utility emission order rather than by a contract. The runtime glass tokens the earlier composition was meant to pick up therefore still don't apply. Every other glass surface (PullRequestCodeTab, UsageProviderChart, Button variant="glass") puts surface-glass on an element that owns no bg-*.

Smallest fix: let the input primitive own its surface and drop the utility here β€” the floating elevation still comes from shadow-lg. If the glass look is wanted, move surface-glass (and the border/shadow) to a wrapper element and keep the group background-free.

suggestion
1 <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg **:[input]:w-48">

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 89460214. Removed surface-glass from the default InputGroup so the primitive has unambiguous ownership of its background while the floating elevation remains on shadow-lg.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 4ab4c6ff Β· right 432

Original diff
@@ -420,6 +424,103 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
+  const findTexts = useMemo(
+    () => rows.map((row) => (row.kind === "message" ? row.message.text : null)),
M
macroscopeappBotAug 18, 2026

🟠 High chat/MessagesTimeline.tsx:432

Cmd/Ctrl+F cannot find messages inside collapsed turn folds, so text in older turns is omitted from the search results and cannot be navigated to. findTexts is derived from rows, while deriveMessagesTimelineRows removes folded messages; build the search corpus from the underlying timeline messages and expand the containing fold before navigating to a match.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 432:
2
3Cmd/Ctrl+F cannot find messages inside collapsed turn folds, so text in older turns is omitted from the search results and cannot be navigated to. `findTexts` is derived from `rows`, while `deriveMessagesTimelineRows` removes folded messages; build the search corpus from the underlying timeline messages and expand the containing fold before navigating to a match.
B
bj97301Aug 18, 2026

Addressed in 409b73bc. The match corpus now comes from the underlying timeline messages; selecting a hidden match expands its turn fold before scrolling and highlighting.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/ui/toggle.tsx Β· Earlier revision 8753a9bd Β· right 28–29

Original diff
@@ -25,6 +25,8 @@ const toggleVariants = cva(
         default: "border-transparent",
         ghost:
           "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
+        primary:
+          "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-primary data-pressed:text-primary-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",

apps/web/src/components/ui/toggle.tsx Β· right 28–29

M
macroscopeappBotAug 18, 2026

ui/toggle.tsx:29

The new primary variant leaves the base hover:bg-accent in charge of the pressed state. .hover\:bg-accent:hover and .data-pressed\:bg-primary[data-pressed] tie on specificity (0,2,0) and Tailwind emits the hover: utility later, so hovering a pressed toggle repaints the fill to --accent while data-pressed:text-primary-foreground keeps the label white β€” in light themes that is white on zinc-100, so the "Aa" label vanishes exactly while the pointer is over it (in dark themes the on-state just disappears).

Every other filled/pressed surface in the app pins both states together instead of relying on emit order: Button uses [:hover,[data-pressed]]:bg-primary/90, and AddProviderInstanceDialog re-asserts data-checked:hover:bg-primary/8. Consider re-asserting the pressed fill under hover here too.

suggestion
1 primary:
2 "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-primary data-pressed:text-primary-foreground data-pressed:hover:bg-primary/90 disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",

No longer relevant as of 666bcb9cb859963a9889fcd092686b589ca73cde

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 89460214 Β· right 729–729

Original diff
@@ -573,6 +725,60 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg **:[input]:w-48">

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 729–729

M
macroscopeappBotAug 18, 2026

The floating-panel treatment is applied at the call site but loses to InputGroup's own rules by specificity, so it doesn't render as intended:

  • surface-glass sets background at specificity (0,1,0), while the default variant's dark:bg-input/32 compiles to .dark\:bg-input\/32:is(.dark, .dark *) at (0,2,0). In dark mode the panel therefore paints --input at 32% alpha instead of --background at --glass-opacity (80%), i.e. a far more transparent surface than every other glass panel (PullRequestCodeTab, UsageProviderChart, Button glass), with the message text scrolling visibly behind the field. In light mode bg-background and surface-glass tie on specificity, so which one wins depends on utility emit order.
  • shadow-lg is nulled whenever the input has :focus-visible β€” the base class carries has-[...,input:focus-visible,...]:shadow-none at (0,2,1) β€” which is the normal state here since the bar auto-focuses on open. The panel loses its elevation exactly while it is in use.

The smallest durable fix is to give the primitive the treatment rather than stacking it at the call site: add a glass variant to inputGroupVariants that owns the translucent surface and elevation (no bg-*/dark:bg-*, no focus shadow-none), mirroring the existing glass variant in button.tsx, and use variant="glass" here while keeping position/width local. Wrapping the group in a surface-glass div instead won't help, since the default variant's opaque bg-background would still cover the wrapper.

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

This was generated against the previous head and is superseded by 89460214: the current call site no longer applies surface-glass, so neither background-specificity issue remains and there is no glass elevation contract to preserve. The control intentionally uses the standard InputGroup focus treatment; adding a new global glass variant would expand shared API without a current caller need.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/ui/toggle.tsx Β· Earlier revision 8753a9bd Β· right 29–29

Original diff
@@ -25,6 +25,8 @@ const toggleVariants = cva(
         default: "border-transparent",
         ghost:
           "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-accent data-pressed:text-accent-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
+        primary:
+          "border-transparent text-foreground shadow-none [:disabled,:active,[data-pressed]]:shadow-none before:shadow-none data-pressed:bg-primary data-pressed:text-primary-foreground disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",

apps/web/src/components/ui/toggle.tsx Β· right 29–29

M
macroscopeappBotAug 18, 2026

This new primary variant is a verbatim copy of ghost apart from the two data-pressed: colors, and it has exactly one consumer (the find bar's Aa toggle). The app already renders the same "Match case" control in ProjectContentSearchDialog (SearchOptionButton) with variant="ghost", so the identical control now has two different pressed treatments (bg-accent vs a solid bg-primary fill), and any future change to ghost's shared contract has to be duplicated here to stay in sync.

Consider dropping this variant and rendering the find bar toggle with variant="ghost" so the pressed state matches the existing match-case control; if a solid pressed fill is genuinely wanted, it's worth applying it to both call sites so the variant has a shared contract.

Posted via Macroscope β€” UI Consistency

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 8753a9bd Β· right 807–808

Original diff
@@ -573,6 +803,76 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <div className="absolute right-4 top-3 z-30 w-[calc(100%-2rem)] max-w-sm rounded-[var(--control-radius)] shadow-lg">
+              <InputGroup className="w-full dark:bg-background">

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 807–808

M
macroscopeappBotAug 18, 2026

chat/MessagesTimeline.tsx:808

shadow-lg is on a transparent full-width wrapper (inset-x-4), but the visible panel is the max-w-sm InputGroup that ml-auto pushes to the right. An outer box shadow is painted around the wrapper's border box, so the elevation renders as a band across the whole timeline width β€” including the empty region to the left of the field β€” rather than hugging the find bar.

Moving the width constraint onto the shadowed wrapper keeps the shadow on the panel, and still avoids InputGroup's has-[input:disabled,textarea:disabled,input:focus-visible,…]:shadow-none, which would cancel the shadow if it were applied to the group itself.

suggestion
1 <div className="absolute inset-x-4 top-3 z-30 ml-auto w-full max-w-sm rounded-[var(--control-radius)] shadow-lg">
2 <InputGroup className="dark:bg-background">

No longer relevant as of 8753a9bd9f7b0e798f99897084f8a5c014eedd7e

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 673fbf46 Β· right 445

Original diff
@@ -420,10 +427,155 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
-  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
   const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
     null,
   );
+  const findMessageEntries = useMemo(
+    () => timelineEntries.filter((entry) => entry.kind === "message"),
+    [timelineEntries],
+  );
+  const findTexts = useMemo(
+    () => findMessageEntries.map((entry) => renderMarkdownSearchText(entry.message.text)),
+    [findMessageEntries],
+  );
+  const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]);
M
macroscopeappBotAug 18, 2026

🟑 Medium chat/MessagesTimeline.tsx:445

When messages are removed, findMatchIndex can remain past the end of findMatches, so the UI shows invalid counts such as 3/1 and the effect finds no active match to highlight or navigate to. Clamp the selected index to the current match count whenever findMatches changes.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 445:
2
3When messages are removed, `findMatchIndex` can remain past the end of `findMatches`, so the UI shows invalid counts such as `3/1` and the effect finds no active match to highlight or navigate to. Clamp the selected index to the current match count whenever `findMatches` changes.
B
bj97301Aug 18, 2026

Addressed in d6df6773. The effective match index is now synchronously clamped to the current result count, so the counter, active highlight, and navigation never observe an out-of-range cursor after messages are removed.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· File comment

M
macroscopeappBotAug 18, 2026

surface-glass can't own the background on this element: InputGroup's default variant already paints the control surface (bg-background plus dark:bg-input/32), and the dark rule compiles to .dark\:bg-input\/32:is(.dark, .dark *) (0-2-0), which outranks the surface-glass utility (0-1-0). So in dark themes the bar is tinted with --input at 32% instead of --background at --glass-opacity, and in light themes the winner is decided purely by utility emission order rather than by a contract. The runtime glass tokens the earlier composition was meant to pick up therefore still don't apply. Every other glass surface (PullRequestCodeTab, UsageProviderChart, Button variant="glass") puts surface-glass on an element that owns no bg-*.

Smallest fix: let the input primitive own its surface and drop the utility here β€” the floating elevation still comes from shadow-lg. If the glass look is wanted, move surface-glass (and the border/shadow) to a wrapper element and keep the group background-free.

suggestion
1 <InputGroup className="absolute right-4 top-3 z-30 w-auto shadow-lg **:[input]:w-48">

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 89460214. Removed surface-glass from the default InputGroup so the primitive has unambiguous ownership of its background while the floating elevation remains on shadow-lg.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 409b73bc Β· right 535

Original diff
@@ -420,10 +425,148 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
-  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
   const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
     null,
   );
+  const findMessageEntries = useMemo(
+    () => timelineEntries.filter((entry) => entry.kind === "message"),
+    [timelineEntries],
+  );
+  const findTexts = useMemo(
+    () => findMessageEntries.map((entry) => entry.message.text),
+    [findMessageEntries],
+  );
+  const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]);
+  const updateFindQuery = useCallback(
+    (query: string) => {
+      setFindQuery(query);
+      setFindMatchIndex(0);
+      if (findTextMatches(findTexts, query).length > 0) onManualNavigation();
+    },
+    [findTexts, onManualNavigation],
+  );
+  const goToFindMatch = useCallback(
+    (delta: number) => {
+      if (findMatches.length === 0) return;
+      const next = (findMatchIndex + delta + findMatches.length) % findMatches.length;
+      setFindMatchIndex(next);
+      onManualNavigation();
+    },
+    [findMatchIndex, findMatches, onManualNavigation],
+  );
+
+  useEffect(() => {
+    const handleFindShortcut = (event: globalThis.KeyboardEvent) => {
+      if (
+        event.key.toLowerCase() !== "f" ||
+        (!event.metaKey && !event.ctrlKey) ||
+        event.altKey ||
+        event.shiftKey ||
+        !timelineViewportElement ||
+        timelineViewportElement.getBoundingClientRect().width === 0 ||
+        timelineViewportElement.closest('[data-chat-column-maximized-away="true"]') ||
+        (event.target instanceof Element &&
+          event.target !== document.body &&
+          (event.target.closest("[data-terminal-owner]") ||
+            !timelineViewportElement
+              .closest("[data-chat-column-maximized-away]")
+              ?.contains(event.target)))
+      ) {
+        return;
+      }
+      event.preventDefault();
+      event.stopPropagation();
+      setFindOpen(true);
+    };
+    window.addEventListener("keydown", handleFindShortcut, true);
+    return () => window.removeEventListener("keydown", handleFindShortcut, true);
+  }, [timelineViewportElement]);
+
+  useEffect(() => {
+    if (!findOpen) return;
+    const frame = requestAnimationFrame(() => findInputRef.current?.focus());
+    return () => cancelAnimationFrame(frame);
+  }, [findOpen]);
+
+  useEffect(() => {
+    CSS.highlights.delete(THREAD_FIND_HIGHLIGHT);
+    const match = findMatches[findMatchIndex];
+    const entry = match === undefined ? undefined : findMessageEntries[match.textIndex];
+    if (!findOpen || !findQuery || !match || !entry) return;
+
+    const rowIndex = rows.findIndex((row) => row.id === entry.id);
+    if (rowIndex === -1) {
+      const turnId = entry.message.turnId;
+      if (turnId && !expandedTurnIds.has(turnId)) {
+        suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
+        setExpandedTurnIds((existing) => new Set(existing).add(turnId));
+      }
+      return;
+    }
+
+    void listRef.current?.scrollToIndex({ index: rowIndex, animated: false, viewOffset: 80 });
+
+    let paintFrame = 0;
+    const mountFrame = requestAnimationFrame(() => {
+      paintFrame = requestAnimationFrame(() => {
+        const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(entry.id)}"]`);
+        if (!root) return;
+
+        const ranges: Range[] = [];
+        const needle = findQuery.toLowerCase();
+        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+        for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+          const text = node.textContent?.toLowerCase() ?? "";
+          for (
+            let offset = 0;
+            (offset = text.indexOf(needle, offset)) !== -1;
+            offset += needle.length
+          ) {
+            const range = new Range();
+            range.setStart(node, offset);
+            range.setEnd(node, offset + needle.length);
+            ranges.push(range);
+          }
+        }
+        const activeRange = ranges[match.occurrenceIndex];
M
macroscopeappBotAug 18, 2026

🟑 Medium chat/MessagesTimeline.tsx:535

Searching text that exists only in Markdown source, such as the URL in [label](https://example.com), counts a match and scrolls to the row but produces no highlight because findMatches uses raw message.text while ranges scans rendered DOM text. Use the same text representation, or map source offsets to rendered nodes, so match counts and match.occurrenceIndex correspond to the text being highlighted.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 535:
2
3Searching text that exists only in Markdown source, such as the URL in `[label](https://example.com)`, counts a match and scrolls to the row but produces no highlight because `findMatches` uses raw `message.text` while `ranges` scans rendered DOM text. Use the same text representation, or map source offsets to rendered nodes, so match counts and `match.occurrenceIndex` correspond to the text being highlighted.
B
bj97301Aug 18, 2026

Addressed in 378b338a. Search text is now produced by parsing Markdown and extracting its rendered text, so hidden link destinations no longer affect counts or occurrence indexes.

M
macroscopeappBotAug 18, 2026

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 409b73bc Β· right 531

Original diff
@@ -420,10 +425,148 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
-  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
   const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
     null,
   );
+  const findMessageEntries = useMemo(
+    () => timelineEntries.filter((entry) => entry.kind === "message"),
+    [timelineEntries],
+  );
+  const findTexts = useMemo(
+    () => findMessageEntries.map((entry) => entry.message.text),
+    [findMessageEntries],
+  );
+  const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]);
+  const updateFindQuery = useCallback(
+    (query: string) => {
+      setFindQuery(query);
+      setFindMatchIndex(0);
+      if (findTextMatches(findTexts, query).length > 0) onManualNavigation();
+    },
+    [findTexts, onManualNavigation],
+  );
+  const goToFindMatch = useCallback(
+    (delta: number) => {
+      if (findMatches.length === 0) return;
+      const next = (findMatchIndex + delta + findMatches.length) % findMatches.length;
+      setFindMatchIndex(next);
+      onManualNavigation();
+    },
+    [findMatchIndex, findMatches, onManualNavigation],
+  );
+
+  useEffect(() => {
+    const handleFindShortcut = (event: globalThis.KeyboardEvent) => {
+      if (
+        event.key.toLowerCase() !== "f" ||
+        (!event.metaKey && !event.ctrlKey) ||
+        event.altKey ||
+        event.shiftKey ||
+        !timelineViewportElement ||
+        timelineViewportElement.getBoundingClientRect().width === 0 ||
+        timelineViewportElement.closest('[data-chat-column-maximized-away="true"]') ||
+        (event.target instanceof Element &&
+          event.target !== document.body &&
+          (event.target.closest("[data-terminal-owner]") ||
+            !timelineViewportElement
+              .closest("[data-chat-column-maximized-away]")
+              ?.contains(event.target)))
+      ) {
+        return;
+      }
+      event.preventDefault();
+      event.stopPropagation();
+      setFindOpen(true);
+    };
+    window.addEventListener("keydown", handleFindShortcut, true);
+    return () => window.removeEventListener("keydown", handleFindShortcut, true);
+  }, [timelineViewportElement]);
+
+  useEffect(() => {
+    if (!findOpen) return;
+    const frame = requestAnimationFrame(() => findInputRef.current?.focus());
+    return () => cancelAnimationFrame(frame);
+  }, [findOpen]);
+
+  useEffect(() => {
+    CSS.highlights.delete(THREAD_FIND_HIGHLIGHT);
+    const match = findMatches[findMatchIndex];
+    const entry = match === undefined ? undefined : findMessageEntries[match.textIndex];
+    if (!findOpen || !findQuery || !match || !entry) return;
+
+    const rowIndex = rows.findIndex((row) => row.id === entry.id);
+    if (rowIndex === -1) {
+      const turnId = entry.message.turnId;
+      if (turnId && !expandedTurnIds.has(turnId)) {
+        suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
+        setExpandedTurnIds((existing) => new Set(existing).add(turnId));
+      }
+      return;
+    }
+
+    void listRef.current?.scrollToIndex({ index: rowIndex, animated: false, viewOffset: 80 });
+
+    let paintFrame = 0;
+    const mountFrame = requestAnimationFrame(() => {
+      paintFrame = requestAnimationFrame(() => {
+        const root = document.querySelector(`[data-timeline-row-id="${CSS.escape(entry.id)}"]`);
+        if (!root) return;
+
+        const ranges: Range[] = [];
+        const needle = findQuery.toLowerCase();
+        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+        for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+          const text = node.textContent?.toLowerCase() ?? "";
+          for (
+            let offset = 0;
+            (offset = text.indexOf(needle, offset)) !== -1;
+            offset += needle.length
+          ) {
+            const range = new Range();
+            range.setStart(node, offset);
+            range.setEnd(node, offset + needle.length);
M
macroscopeappBotAug 18, 2026

🟑 Medium chat/MessagesTimeline.tsx:531

Searching text containing Unicode characters such as İ throws an uncaught IndexSizeError, so the active match is not highlighted. The offsets come from text.toLowerCase() ("İ" becomes two UTF-16 code units, "i̇") but are applied to the original text node; map normalized offsets back to the original string or use a length-preserving comparison before calling range.setEnd.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 531:
2
3Searching text containing Unicode characters such as `İ` throws an uncaught `IndexSizeError`, so the active match is not highlighted. The offsets come from `text.toLowerCase()` (`"İ"` becomes two UTF-16 code units, `"i̇"`) but are applied to the original text node; map normalized offsets back to the original string or use a length-preserving comparison before calling `range.setEnd`.
B
bj97301Aug 18, 2026

Addressed in 378b338a. Case-insensitive matching now records original UTF-16 boundaries while normalizing each code point, so expanding folds such as Δ° cannot produce an out-of-bounds DOM range.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 8753a9bd Β· right 615

Original diff
@@ -420,10 +430,230 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
-  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findCaseSensitive, setFindCaseSensitive] = useState(false);
+  const [findMatchCursor, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
   const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
     null,
   );
+  const findMessageEntries = useMemo(
+    () => timelineEntries.filter((entry) => entry.kind === "message"),
+    [timelineEntries],
+  );
+  const findTexts = useMemo(
+    () => findMessageEntries.map((entry) => renderMarkdownSearchText(entry.message.text)),
+    [findMessageEntries],
+  );
+  const findMatches = useMemo(
+    () => findTextMatches(findTexts, findQuery, findCaseSensitive),
+    [findCaseSensitive, findQuery, findTexts],
+  );
+  const findMatchIndex = Math.min(findMatchCursor, Math.max(0, findMatches.length - 1));
+  const updateFindQuery = useCallback(
+    (query: string) => {
+      setFindQuery(query);
+      setFindMatchIndex(0);
+      if (findTextMatches(findTexts, query, findCaseSensitive).length > 0) onManualNavigation();
+    },
+    [findCaseSensitive, findTexts, onManualNavigation],
+  );
+  const goToFindMatch = useCallback(
+    (delta: number) => {
+      if (findMatches.length === 0) return;
+      const next = (findMatchIndex + delta + findMatches.length) % findMatches.length;
+      setFindMatchIndex(next);
+      onManualNavigation();
+    },
+    [findMatchIndex, findMatches, onManualNavigation],
+  );
+
+  useEffect(() => {
+    const handleFindShortcut = (event: globalThis.KeyboardEvent) => {
+      const key = event.key.toLowerCase();
+      if (
+        !timelineViewportElement ||
+        timelineViewportElement.getBoundingClientRect().width === 0 ||
+        timelineViewportElement.closest('[data-chat-column-maximized-away="true"]')
+      ) {
+        return;
+      }
+
+      if (key === "escape") {
+        if (
+          !findOpen ||
+          (event.target instanceof Element &&
+            event.target.closest('[role="dialog"], [data-terminal-owner]'))
+        ) {
+          return;
+        }
+        event.preventDefault();
+        event.stopPropagation();
+        setFindOpen(false);
+        return;
+      }
+      if (
+        event.target instanceof Element &&
+        event.target !== document.body &&
+        (event.target.closest("[data-terminal-owner]") ||
+          !timelineViewportElement
+            .closest("[data-chat-column-maximized-away]")
+            ?.contains(event.target))
+      ) {
+        return;
+      }
+      if ((key !== "f" && key !== "g") || (!event.metaKey && !event.ctrlKey) || event.altKey) {
+        return;
+      }
+
+      const inputFocused = document.activeElement === findInputRef.current;
+      if (key === "g") {
+        if (!findOpen || inputFocused) return;
+        event.preventDefault();
+        event.stopPropagation();
+        goToFindMatch(event.shiftKey ? -1 : 1);
+        return;
+      }
+      if (event.shiftKey || (!isElectron && findOpen && inputFocused)) return;
+
+      event.preventDefault();
+      event.stopPropagation();
+      if (findOpen) {
+        findInputRef.current?.focus();
+      } else {
+        setFindOpen(true);
+      }
+    };
+    window.addEventListener("keydown", handleFindShortcut, true);
+    return () => window.removeEventListener("keydown", handleFindShortcut, true);
+  }, [findOpen, goToFindMatch, timelineViewportElement]);
+
+  useEffect(() => {
+    if (!findOpen) return;
+    const frame = requestAnimationFrame(() => findInputRef.current?.focus());
+    return () => cancelAnimationFrame(frame);
+  }, [findOpen]);
+
+  useEffect(() => {
+    CSS.highlights.delete(THREAD_FIND_HIGHLIGHT);
+    CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT);
+    const match = findMatches[findMatchIndex];
+    const entry = match === undefined ? undefined : findMessageEntries[match.textIndex];
+    if (!findOpen || !findQuery || !match || !entry || !timelineViewportElement) return;
+
+    const rowIndex = rows.findIndex((row) => row.id === entry.id);
+    if (rowIndex === -1) {
+      const turnId = entry.message.turnId;
+      if (turnId && !expandedTurnIds.has(turnId)) {
+        suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
+        setExpandedTurnIds((existing) => new Set(existing).add(turnId));
+      }
+      return;
+    }
+
+    void listRef.current?.scrollToIndex({ index: rowIndex, animated: false, viewOffset: 80 });
+
+    let paintFrame = 0;
+    let repaintFrame = 0;
+    const paintHighlights = () => {
+      const allRanges: Range[] = [];
+      let activeRange: Range | undefined;
+      for (const [textIndex, messageEntry] of findMessageEntries.entries()) {
+        const root = timelineViewportElement?.querySelector(
+          `[data-timeline-row-id="${CSS.escape(messageEntry.id)}"]`,
+        );
+        if (!root) continue;
+
+        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+        const textNodes: Array<{ node: Text; start: number; end: number }> = [];
+        let text = "";
+        for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+          const value = node.textContent ?? "";
+          textNodes.push({
+            node: node as Text,
+            start: text.length,
+            end: text.length + value.length,
+          });
+          text += value;
+        }
+        for (const [occurrenceIndex, textRange] of findTextRanges(
+          text,
+          findQuery,
+          findCaseSensitive,
+        ).entries()) {
+          const startNode = textNodes.find(
+            ({ start, end }) => start <= textRange.start && textRange.start < end,
+          );
+          const endNode = textNodes.find(
+            ({ start, end }) => start < textRange.end && textRange.end <= end,
+          );
+          if (!startNode || !endNode) continue;
+          const range = new Range();
+          range.setStart(startNode.node, textRange.start - startNode.start);
+          range.setEnd(endNode.node, textRange.end - endNode.start);
+          allRanges.push(range);
+          if (textIndex === match.textIndex && occurrenceIndex === match.occurrenceIndex) {
+            activeRange = range;
+          }
+        }
+      }
+      CSS.highlights.set(THREAD_FIND_HIGHLIGHT, new Highlight(...allRanges));
+      if (activeRange) {
+        CSS.highlights.set(THREAD_FIND_ACTIVE_HIGHLIGHT, new Highlight(activeRange));
+      }
+      return activeRange;
+    };
+    const scheduleRepaint = () => {
+      cancelAnimationFrame(repaintFrame);
+      repaintFrame = requestAnimationFrame(() => paintHighlights());
+    };
+    const observer = new MutationObserver(scheduleRepaint);
+    observer.observe(timelineViewportElement, { childList: true, subtree: true });
+    const mountFrame = requestAnimationFrame(() => {
+      paintFrame = requestAnimationFrame(() => {
+        const activeRange = paintHighlights();
M
macroscopeappBotAug 18, 2026

🟑 Medium chat/MessagesTimeline.tsx:615

Find navigation reports and selects matches inside collapsed CollapsibleUserMessageBody content without revealing them, so matches beyond the max-h-44 overflow-hidden cutoff remain invisible even after the outer list scrolls. Expand the matched user message (or otherwise reveal the range) before painting and scrolling to it.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 615:
2
3Find navigation reports and selects matches inside collapsed `CollapsibleUserMessageBody` content without revealing them, so matches beyond the `max-h-44 overflow-hidden` cutoff remain invisible even after the outer list scrolls. Expand the matched user message (or otherwise reveal the range) before painting and scrolling to it.

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 48386913 Β· right 822–837

Original diff
@@ -573,6 +802,78 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <div className="absolute right-4 top-3 z-30 rounded-[var(--control-radius)] shadow-lg">
+              <InputGroup className="w-auto dark:bg-background **:[input]:w-48">
+                <InputGroupAddon>
+                  <SearchIcon aria-hidden="true" />
+                </InputGroupAddon>
+                <InputGroupInput
+                  ref={findInputRef}
+                  value={findQuery}
+                  onChange={(event) => updateFindQuery(event.target.value)}
+                  onKeyDown={(event) => {
+                    if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1);
+                  }}
+                  aria-label="Find in thread"
+                  placeholder="Find in thread"
+                />
+                <InputGroupAddon align="inline-end" className="gap-1">
+                  <Button
+                    type="button"
+                    size="icon-xs"
+                    variant="ghost"
+                    className="data-pressed:border-primary! data-pressed:bg-primary! data-pressed:text-primary-foreground!"
+                    data-pressed={findCaseSensitive || undefined}
+                    aria-label="Match case"
+                    aria-pressed={findCaseSensitive}
+                    title="Match case"
+                    onClick={() => {
+                      setFindCaseSensitive(!findCaseSensitive);
+                      setFindMatchIndex(0);
+                      onManualNavigation();
+                    }}
+                  >
+                    <span className="text-[11px] font-semibold leading-none">Aa</span>

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 822–837

M
macroscopeappBotAug 18, 2026

chat/MessagesTimeline.tsx:837

This rebuilds Toggle out of Button: the pressed state is expressed by hand via aria-pressed plus a manually written data-pressed attribute, so the call site now owns toggle semantics that the primitive already owns. data-pressed is a Base UI-emitted attribute everywhere else in the app (only variant class strings reference it) β€” this is the first place it is set as a literal prop, and the app already has this exact "Aa / Match case" control built on the shared primitive in ProjectContentSearchDialog's SearchOptionButton (<Toggle pressed={…} variant="ghost" size="compact">).

Consider using Toggle with pressed/onPressedChange; size="xs" matches the icon-xs geometry (size-7 sm:size-6) used by the sibling addon buttons.

diff
1- <Button
2- type="button"
3- size="icon-xs"
4- variant="ghost"
5- data-pressed={findCaseSensitive || undefined}
6- aria-label="Match case"
7- aria-pressed={findCaseSensitive}
8- title="Match case"
9- onClick={() => {
10- setFindCaseSensitive(!findCaseSensitive);
11- setFindMatchIndex(0);
12- onManualNavigation();
13- }}
14- >
15+ <Toggle
16+ size="xs"
17+ variant="ghost"
18+ aria-label="Match case"
19+ pressed={findCaseSensitive}
20+ title="Match case"
21+ onPressedChange={(pressed) => {
22+ setFindCaseSensitive(pressed);
23+ setFindMatchIndex(0);
24+ onManualNavigation();
25+ }}
26+ >
27 <span className="text-[11px] font-semibold leading-none">Aa</span>
28- </Button>
29+ </Toggle>

(requires import { Toggle } from "../ui/toggle";)

No longer relevant as of 8753a9bd9f7b0e798f99897084f8a5c014eedd7e

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 8753a9bd Β· right 827–827

Original diff
@@ -573,6 +803,76 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     <TimelineRowCtx value={sharedState}>
       <TimelineRowActivityCtx value={activityState}>
         <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
+          {findOpen ? (
+            <div className="absolute right-4 top-3 z-30 w-[calc(100%-2rem)] max-w-sm rounded-[var(--control-radius)] shadow-lg">
+              <InputGroup className="w-full dark:bg-background">
+                <InputGroupAddon>
+                  <SearchIcon aria-hidden="true" />
+                </InputGroupAddon>
+                <InputGroupInput
+                  ref={findInputRef}
+                  value={findQuery}
+                  onChange={(event) => updateFindQuery(event.target.value)}
+                  onKeyDown={(event) => {
+                    if (event.key === "Enter") goToFindMatch(event.shiftKey ? -1 : 1);
+                  }}
+                  aria-label="Find in thread"
+                  placeholder="Find in thread"
+                />
+                <InputGroupAddon align="inline-end" className="gap-1">
+                  <Toggle
+                    pressed={findCaseSensitive}
+                    size="xs"
+                    variant="primary"
+                    className="font-mono text-[11px]"

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 827–827

M
macroscopeappBotAug 18, 2026

chat/MessagesTimeline.tsx:827

text-[11px] only takes effect below the sm breakpoint. Toggle's base string is ... text-base ... sm:text-sm ..., and tailwind-merge keys utilities by modifier, so the unprefixed text-[11px] only replaces text-base while sm:text-sm survives the merge and wins at >=640px (responsive utilities are emitted after base ones at equal specificity). On desktop the "Aa" label therefore renders at 14px inside a 24px-tall size="xs" toggle, larger than the 12px match counter beside it. Consider carrying the override through the sm variant as well.

suggestion
1 className="font-mono text-[11px] sm:text-[11px]"

No longer relevant as of 666bcb9cb859963a9889fcd092686b589ca73cde

apps/web/src/components/chat/MessagesTimeline.tsx Β· Earlier revision 4ab4c6ff Β· right 462–476

Original diff
@@ -420,6 +424,103 @@ export const MessagesTimeline = memo(function MessagesTimeline({
     ],
   );
   const rows = useStableRows(rawRows);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
+  const [findMatchIndex, setFindMatchIndex] = useState(0);
+  const findInputRef = useRef<HTMLInputElement>(null);
+  const findTexts = useMemo(
+    () => rows.map((row) => (row.kind === "message" ? row.message.text : null)),
+    [rows],
+  );
+  const findMatches = useMemo(() => findTextMatches(findTexts, findQuery), [findQuery, findTexts]);
+  const updateFindQuery = useCallback(
+    (query: string) => {
+      setFindQuery(query);
+      setFindMatchIndex(0);
+      const firstMatch = findTextMatches(findTexts, query)[0];
+      if (firstMatch === undefined) return;
+      onManualNavigation();
+      void listRef.current?.scrollToIndex({ index: firstMatch, animated: false, viewOffset: 80 });
+    },
+    [findTexts, listRef, onManualNavigation],
+  );
+  const goToFindMatch = useCallback(
+    (delta: number) => {
+      if (findMatches.length === 0) return;
+      const next = (findMatchIndex + delta + findMatches.length) % findMatches.length;
+      setFindMatchIndex(next);
+      onManualNavigation();
+      void listRef.current?.scrollToIndex({
+        index: findMatches[next]!,
+        animated: false,
+        viewOffset: 80,
+      });
+    },
+    [findMatchIndex, findMatches, listRef, onManualNavigation],
+  );
+
+  useEffect(() => {
+    const handleFindShortcut = (event: globalThis.KeyboardEvent) => {
+      if (
+        event.key.toLowerCase() !== "f" ||
+        (!event.metaKey && !event.ctrlKey) ||
+        event.altKey ||
+        event.shiftKey ||
+        (event.target instanceof Element && event.target.closest("[data-terminal-owner]"))
+      ) {
+        return;
+      }
+      event.preventDefault();
+      event.stopPropagation();
+      setFindOpen(true);
+    };

apps/web/src/components/chat/MessagesTimeline.tsx Β· right 462–476

M
macroscopeappBotAug 18, 2026

The capture-phase window listener claims Cmd/Ctrl+F for the whole app and stopPropagation()s it, with a carve-out only for [data-terminal-owner]. The timeline is mounted side-by-side with the right panel (PR code tab, file preview, browser preview, and the URL input in PreviewChromeRow), and when the right panel is maximized the chat column collapses to w-0 while staying mounted β€” so the shortcut is swallowed and the find bar opens where the user cannot see or use it.

Consider gating the handler on the timeline actually being the active surface (e.g. timelineViewportElement?.contains(event.target as Node) or an equivalent visibility check) and, for discoverability/rebindability, routing it through the shared keybindings registry (packages/shared/src/keybindings.ts / resolveShortcutCommand) like the other app shortcuts rather than a hardcoded key comparison.

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 409b73bc. Cmd/Ctrl+F is now captured only while the chat column is visible and the event target belongs to that chat column; terminal and right-panel controls retain their own behavior.

apps/web/src/components/chat/MessagesTimeline.logic.ts Β· Earlier revision b1ffb46b Β· right 262

Original diff
@@ -242,6 +246,67 @@ export function normalizeCompactToolLabel(value: string): string {
   return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
 }
 
+export interface TextMatch {
+  readonly textIndex: number;
+  readonly occurrenceIndex: number;
+}
+
+export interface TextRange {
+  readonly start: number;
+  readonly end: number;
+}
+
+const markdownSearchTextProcessor = unified().use(remarkParse).use(remarkGfm);
+
+export function renderMarkdownSearchText(markdown: string): string {
+  return toString(markdownSearchTextProcessor.parse(markdown), { includeImageAlt: false });
M
macroscopeappBotAug 18, 2026

🟑 Medium chat/MessagesTimeline.logic.ts:262

renderMarkdownSearchText creates matches across separate Markdown text nodes, so findTextMatches reports occurrences such as oob in **foo**bar that the highlighting path cannot map to any DOM Range. This produces navigation counts like 1/1 without a visible highlight; preserve node boundaries during extraction or make highlighting support ranges spanning nodes.

πŸ€– Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/chat/MessagesTimeline.logic.ts around line 262:
2
3`renderMarkdownSearchText` creates matches across separate Markdown text nodes, so `findTextMatches` reports occurrences such as `oob` in `**foo**bar` that the highlighting path cannot map to any DOM `Range`. This produces navigation counts like `1/1` without a visible highlight; preserve node boundaries during extraction or make highlighting support ranges spanning nodes.

apps/web/src/components/chat/MessagesTimeline.tsx Β· File comment

M
macroscopeappBotAug 18, 2026

This raw <input> reconstructs the core input primitive in ordinary product UI: it loses the shared control chrome owned by Input (focus-visible ring/border, placeholder:text-placeholder token, autofill background handling, h-8.5/sm:h-7.5 size ramp) and re-implements the leading-icon + trailing-action layout that InputGroup / InputGroupAddon / InputGroupInput already own β€” see PreviewChromeRow.tsx for the established composition with size="icon-xs" addon buttons.

Consider composing the bar as InputGroup (variant ghost or default) + InputGroupAddon align="inline-start" for SearchIcon + InputGroupInput size="sm" + InputGroupAddon align="inline-end" for the counter and prev/next/close buttons, keeping only positioning and width (w-48) at the call site.

Posted via Macroscope β€” UI Consistency

B
bj97301Aug 18, 2026

Addressed in 409b73bc. The find control now uses InputGroup, InputGroupAddon, and InputGroupInput.

apps/web/src/components/chat/MessagesTimeline.tsx Β· File comment

M
macroscopeappBotAug 18, 2026

This rebuilds the floating glass surface locally (bg-background/95 + backdrop-blur) instead of using the shared surface-glass utility, so it ignores the runtime theme tokens (--glass-opacity, --glass-blur, --glass-saturation) and the @supports opaque fallback that other floating panels rely on (PullRequestCodeTab, UsageProviderChart). Consider using the shared utility with the same border/shadow treatment.

suggestion
1 <div className="surface-glass absolute right-4 top-3 z-30 flex h-9 items-center gap-1 rounded-lg border border-border/60 px-2 shadow-lg">

Posted via Macroscope β€” UI Consistency