# Files changed · PR \#7434 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/7434/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(web\): add in-thread find](https://git.cafe/pingdotgg/t3code/pull/7434.md)

Version: 53

Head commit: 1e856b41bd1e387aede255e685313d27a300226b

Base commit: 6a687ee43bf222672ab8d3f4c0bab3d8d174f79f

Comparison base: 6a687ee43bf222672ab8d3f4c0bab3d8d174f79f

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/package.json

Status: modified; +3; −0

```diff
@@ -39,6 +39,7 @@
     "jszip": "3.10.1",
     "lexical": "^0.41.0",
     "lucide-react": "^0.564.0",
+    "mdast-util-to-string": "^4.0.0",
     "react": "19.2.6",
     "react-dom": "19.2.6",
     "react-markdown": "^10.1.0",
@@ -46,7 +47,9 @@
     "rehype-sanitize": "^6.0.0",
     "remark-breaks": "^4.0.0",
     "remark-gfm": "^4.0.1",
+    "remark-parse": "^11.0.0",
     "tailwind-merge": "^3.4.0",
+    "unified": "^11.0.5",
     "zustand": "^5.0.11"
   },
   "devDependencies": {
```

## apps/web/src/components/chat/MessagesTimeline.logic.test.ts

Status: modified; +45; −0

````diff
@@ -3,11 +3,56 @@
   computeStableMessagesTimelineRows,
   computeMessageDurationStart,
   deriveMessagesTimelineRows,
+  findTextRanges,
+  findTextMatches,
   normalizeCompactToolLabel,
+  renderMarkdownSearchText,
   resolveAssistantMessageCopyState,
   shouldPreserveAssistantLineBreaks,
 } from "./MessagesTimeline.logic";
 
+describe("findTextMatches", () => {
+  it("finds every case-insensitive occurrence and identifies it within its text", () => {
+    expect(findTextMatches(["One one", null, "another ONE"], "one")).toEqual([
+      { textIndex: 0, occurrenceIndex: 0 },
+      { textIndex: 0, occurrenceIndex: 1 },
+      { textIndex: 2, occurrenceIndex: 0 },
+    ]);
+    expect(findTextMatches(["anything"], "")).toEqual([]);
+  });
+
+  it("uses original string offsets when case folding expands a Unicode character", () => {
+    expect(findTextRanges("İstanbul", "İ")).toEqual([{ start: 0, end: 1 }]);
+  });
+
+  it("optionally matches exact casing", () => {
+    expect(findTextMatches(["One one ONE"], "One", true)).toEqual([
+      { textIndex: 0, occurrenceIndex: 0 },
+    ]);
+    expect(findTextMatches(["One one ONE"], "one", true)).toEqual([
+      { textIndex: 0, occurrenceIndex: 0 },
+    ]);
+  });
+
+  it("searches rendered Markdown text rather than hidden link destinations", () => {
+    const text = renderMarkdownSearchText("Read [the docs](https://example.com/hidden) now");
+    expect(findTextMatches([text], "docs")).toHaveLength(1);
+    expect(findTextMatches([text], "hidden")).toHaveLength(0);
+  });
+
+  it("preserves matches that span adjacent inline Markdown nodes", () => {
+    const text = renderMarkdownSearchText("**foo**bar");
+    expect(text).toBe("foobar");
+    expect(findTextMatches([text], "oob")).toHaveLength(1);
+  });
+
+  it("searches inline and fenced code in prompts and responses", () => {
+    const text = renderMarkdownSearchText("Run `pnpm test`:\n\n```ts\nconst answer = 42;\n```");
+    expect(findTextMatches([text], "pnpm test")).toHaveLength(1);
+    expect(findTextMatches([text], "answer = 42")).toHaveLength(1);
+  });
+});
+
 describe("shouldPreserveAssistantLineBreaks", () => {
   it("preserves Claude insight formatting without changing regular markdown", () => {
     expect(
````

## apps/web/src/components/chat/MessagesTimeline.logic.ts

Status: modified; +75; −0

```diff
@@ -1,4 +1,8 @@
 import * as Equal from "effect/Equal";
+import { toString } from "mdast-util-to-string";
+import remarkGfm from "remark-gfm";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
 import {
   formatDuration,
   workEntryIndicatesToolNeutralStatus,
@@ -242,6 +246,77 @@
   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 });
+}
+
+export function findTextRanges(text: string, query: string, caseSensitive = false): TextRange[] {
+  if (caseSensitive) {
+    if (!query) return [];
+    const ranges: TextRange[] = [];
+    for (let offset = 0; (offset = text.indexOf(query, offset)) !== -1; offset += query.length) {
+      ranges.push({ start: offset, end: offset + query.length });
+    }
+    return ranges;
+  }
+
+  const normalizedOffsets: TextRange[] = [];
+  let normalizedText = "";
+  for (let start = 0; start < text.length; ) {
+    const codePoint = text.codePointAt(start)!;
+    const end = start + (codePoint > 0xffff ? 2 : 1);
+    const normalized = text.slice(start, end).toLowerCase();
+    normalizedText += normalized;
+    for (let index = 0; index < normalized.length; index += 1) {
+      normalizedOffsets.push({ start, end });
+    }
+    start = end;
+  }
+
+  const needle = query.toLowerCase();
+  if (!needle) return [];
+
+  const ranges: TextRange[] = [];
+  for (
+    let offset = 0;
+    (offset = normalizedText.indexOf(needle, offset)) !== -1;
+    offset += needle.length
+  ) {
+    const first = normalizedOffsets[offset];
+    const last = normalizedOffsets[offset + needle.length - 1];
+    if (first && last) ranges.push({ start: first.start, end: last.end });
+  }
+  return ranges;
+}
+
+export function findTextMatches(
+  texts: ReadonlyArray<string | null | undefined>,
+  query: string,
+  caseSensitive = false,
+): TextMatch[] {
+  if (!query) return [];
+
+  const matches: TextMatch[] = [];
+  texts.forEach((text, textIndex) => {
+    findTextRanges(text ?? "", query, caseSensitive).forEach((_, occurrenceIndex) => {
+      matches.push({ textIndex, occurrenceIndex });
+    });
+  });
+  return matches;
+}
+
 export function resolveAssistantMessageCopyState({
   text,
   showCopyButton,
```

## apps/web/src/components/chat/MessagesTimeline.tsx

Status: modified; +314; −1

```diff
@@ -39,6 +39,7 @@
   workLogEntryIsToolLike,
 } from "../../session-logic";
 import { type TurnDiffSummary } from "../../types";
+import { isElectron } from "../../env";
 import {
   getRenderablePatch,
   resolveDiffThemeName,
@@ -59,13 +60,17 @@
   PaintbrushIcon,
   MinusIcon,
   SquarePenIcon,
+  SearchIcon,
   TerminalIcon,
   Undo2Icon,
   WrenchIcon,
   XIcon,
+  ChevronUpIcon,
   ZapIcon,
 } from "lucide-react";
 import { Button } from "../ui/button";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
+import { Toggle } from "../ui/toggle";
 import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
 import { ProposedPlanCard } from "./ProposedPlanCard";
 import { ChangedFilesCard } from "./ChangedFilesTree";
@@ -74,7 +79,10 @@
 import {
   computeStableMessagesTimelineRows,
   deriveMessagesTimelineRows,
+  findTextRanges,
+  findTextMatches,
   normalizeCompactToolLabel,
+  renderMarkdownSearchText,
   resolveAssistantMessageCopyState,
   resolveTimelineIsAtEnd,
   resolveTimelineMinimapHasPersistentGutter,
@@ -143,6 +151,7 @@
   onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
   onToggleTurnFold: (turnId: TurnId) => void;
   onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
+  activeFindMessageId: MessageId | null;
   agentPanelModel: AgentPanelModel;
   onOpenAgents: () => void;
 }
@@ -188,6 +197,8 @@
   );
 }
 const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
+const THREAD_FIND_HIGHLIGHT = "thread-find";
+const THREAD_FIND_ACTIVE_HIGHLIGHT = "thread-find-active";
 const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
 const TIMELINE_MAINTAIN_SCROLL_AT_END = {
   animated: false,
@@ -420,10 +431,235 @@
     ],
   );
   const rows = useStableRows(rawRows);
+  const [findOpen, setFindOpen] = useState(false);
+  const [findQuery, setFindQuery] = useState("");
-  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
+  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 activeFindMessageId =
+    findOpen && findQuery
+      ? (findMessageEntries[findMatches[findMatchIndex]?.textIndex ?? -1]?.message.id ?? null)
+      : null;
+  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 (inputFocused) return;
+        event.preventDefault();
+        event.stopPropagation();
+        if (!findOpen) setFindOpen(true);
+        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();
+        const root = timelineViewportElement.querySelector(
+          `[data-timeline-row-id="${CSS.escape(entry.id)}"]`,
+        );
+        if (!root || !activeRange) return;
+
+        let scrollParent = root.parentElement;
+        while (scrollParent && !/(auto|scroll)/u.test(getComputedStyle(scrollParent).overflowY)) {
+          scrollParent = scrollParent.parentElement;
+        }
+        if (scrollParent) {
+          const matchRect = activeRange.getBoundingClientRect();
+          const viewportRect = scrollParent.getBoundingClientRect();
+          if (matchRect.top < viewportRect.top || matchRect.bottom > viewportRect.bottom) {
+            scrollParent.scrollTop +=
+              matchRect.top - viewportRect.top - scrollParent.clientHeight / 2;
+          }
+        }
+      });
+    });
+    return () => {
+      cancelAnimationFrame(mountFrame);
+      cancelAnimationFrame(paintFrame);
+      cancelAnimationFrame(repaintFrame);
+      observer.disconnect();
+      CSS.highlights.delete(THREAD_FIND_HIGHLIGHT);
+      CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT);
+    };
+  }, [
+    expandedTurnIds,
+    findCaseSensitive,
+    findMatchIndex,
+    findMatches,
+    findMessageEntries,
+    findOpen,
+    findQuery,
+    listRef,
+    rows,
+    suspendEndScrollMaintenanceForDisclosure,
+    timelineViewportElement,
+  ]);
+  const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
   const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
   const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
   const handleAnchorReady = useCallback(
@@ -516,6 +752,7 @@
       onOpenTurnDiff,
       onToggleTurnFold,
       onToggleWorkGroup,
+      activeFindMessageId,
       agentPanelModel,
       onOpenAgents,
     }),
@@ -532,6 +769,7 @@
       onOpenTurnDiff,
       onToggleTurnFold,
       onToggleWorkGroup,
+      activeFindMessageId,
       agentPanelModel,
       onOpenAgents,
     ],
@@ -573,6 +811,76 @@
     <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] sm:text-[11px]"
+                    aria-label="Match case"
+                    title="Match case"
+                    onPressedChange={(pressed) => {
+                      setFindCaseSensitive(pressed);
+                      setFindMatchIndex(0);
+                      onManualNavigation();
+                    }}
+                  >
+                    Aa
+                  </Toggle>
+                  <span className="min-w-12 text-center text-xs text-muted-foreground">
+                    {findQuery
+                      ? `${findMatches.length ? findMatchIndex + 1 : 0}/${findMatches.length}`
+                      : ""}
+                  </span>
+                  <Button
+                    type="button"
+                    size="icon-xs"
+                    variant="ghost"
+                    disabled={findMatches.length === 0}
+                    aria-label="Previous match"
+                    onClick={() => goToFindMatch(-1)}
+                  >
+                    <ChevronUpIcon />
+                  </Button>
+                  <Button
+                    type="button"
+                    size="icon-xs"
+                    variant="ghost"
+                    disabled={findMatches.length === 0}
+                    aria-label="Next match"
+                    onClick={() => goToFindMatch(1)}
+                  >
+                    <ChevronDownIcon />
+                  </Button>
+                  <Button
+                    type="button"
+                    size="icon-xs"
+                    variant="ghost"
+                    aria-label="Close find"
+                    onClick={() => setFindOpen(false)}
+                  >
+                    <XIcon />
+                  </Button>
+                </InputGroupAddon>
+              </InputGroup>
+            </div>
+          ) : null}
           <LegendList<MessagesTimelineRow>
             ref={listRef}
             data={rows}
@@ -1033,6 +1341,7 @@
           terminalContexts={terminalContexts}
           skills={ctx.skills}
           markdownCwd={ctx.markdownCwd}
+          forceExpanded={ctx.activeFindMessageId === row.message.id}
         />
       </div>
       <div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100">
@@ -1602,9 +1911,13 @@
   terminalContexts: ParsedTerminalContextEntry[];
   skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
   markdownCwd: string | undefined;
+  forceExpanded?: boolean;
   footer?: ReactNode;
 }) {
   const [expanded, setExpanded] = useState(false);
+  useEffect(() => {
+    if (props.forceExpanded) setExpanded(true);
+  }, [props.forceExpanded]);
   const hasVisibleBody = props.text.trim().length > 0 || props.terminalContexts.length > 0;
   const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
   const isCollapsed = canCollapse && !expanded;
```

## apps/web/src/components/search/ProjectContentSearchDialog.tsx

Status: modified; +2; −2

```diff
@@ -66,9 +66,9 @@
           <Toggle
             aria-label={props.label}
             pressed={props.active}
-            className="size-8 rounded-[5px] font-mono text-muted-foreground data-pressed:text-foreground sm:size-7"
+            className="size-8 rounded-[5px] font-mono text-muted-foreground sm:size-7"
             size="compact"
-            variant="ghost"
+            variant="primary"
             onClick={props.onClick}
           />
         }
```

## apps/web/src/components/ui/toggle.tsx

Status: modified; +2; −0

```diff
@@ -25,6 +25,8 @@
         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 data-pressed:hover:bg-primary/90 disabled:opacity-100 disabled:text-muted-foreground disabled:[&_svg]:opacity-100",
         outline:
           "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none",
       },
```

## apps/web/src/index.css

Status: modified; +11; −0

```diff
@@ -5,6 +5,17 @@
 /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
 @custom-variant wco (&:is(.wco, .wco *));
 
+::highlight(thread-find) {
+  background-color: color-mix(in srgb, var(--primary) 25%, transparent);
+}
+
+::highlight(thread-find-active) {
+  background-color: color-mix(in srgb, var(--primary) 55%, transparent);
+  text-decoration: underline;
+  text-decoration-color: var(--primary);
+  text-decoration-thickness: 2px;
+}
+
 /* On mobile, morph the expanded hero composer into the compact docked
    composer while it moves; the rest of the app remains visually stationary. */
 html[data-mobile-composer-route-transition="true"]::view-transition-old(root),
```

## pnpm-lock.yaml

Status: modified; +9; −0

```diff
@@ -597,6 +597,9 @@
       lucide-react:
         specifier: ^0.564.0
         version: 0.564.0(react@19.2.6)
+      mdast-util-to-string:
+        specifier: ^4.0.0
+        version: 4.0.0
       react:
         specifier: 19.2.6
         version: 19.2.6
@@ -618,9 +621,15 @@
       remark-gfm:
         specifier: ^4.0.1
         version: 4.0.1
+      remark-parse:
+        specifier: ^11.0.0
+        version: 11.0.0
       tailwind-merge:
         specifier: ^3.4.0
         version: 3.6.0
+      unified:
+        specifier: ^11.0.5
+        version: 11.0.5
       zustand:
         specifier: ^5.0.11
         version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
```
