Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/web
Steps to reproduce
Use the commit under Version or commit, with lockfile dependencies and the repo's Chromium browser-test binary installed. Save the script below outside the checkout as repro.sh and run sh /path/to/repro.sh from the checkout root. The script sets a private HOME, so set PLAYWRIGHT_BROWSERS_PATH to the existing browser installation if it lives outside that default cache. VERIFY_SCRATCH optionally chooses where its temporary directory goes.
- A server test reuses the fixture builders from
ThreadFind.test.ts, persists one completedhtml_rendertool result and one completed empty assistant reply (suppressed) in the same run, and one completed empty assistant reply (control) in a different run, to in-memory SQLite. It calls productionProjectionStore.searchThreadwith queryempty(first, Next, and Next again), and captures the projected rows and results. - A Chromium browser test mounts the real
MessagesTimeline,useThreadFind,ThreadFindBarand find navigation over those rows. Its only search substitution is the network result atom, which returns the captured server result; the HTML render uses the timeline's existingrenderHtmlRendercallback. It opens Find, typesempty, clicks Next twice, and checks the counter, the selected row, each body's text, the matching DOM ranges and the list'sscrollToIndexcall. - Both tests assert the current behavior, so a pass means the defect and the correct control were both observed. The script removes its temporary tests and fixture on exit. No live WebSocket server, dev server or provider is involved.
Reproduction script: repro.sh
#!/bin/sh
set -eu
# Run in an installed checkout at the documented commit. Browser binaries must already exist.
checkout=$(pwd)
tmp=$(mktemp -d "${VERIFY_SCRATCH:-${TMPDIR:-/tmp}}/thread-find-empty.XXXXXX")
export HOME="$tmp/fake-home" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3-home"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
export PATH="$checkout/node_modules/.bin:$PATH"
export VERIFY_FIXTURE="$checkout/apps/web/src/components/chat/zz-verify-empty.fixture.json"
server="$checkout/apps/server/src/orchestration-v2/zz-verify-empty.test.ts"
web="$checkout/apps/web/src/components/chat/zz-verify-empty.browser.test.tsx"
cleanup() { rm -f "$server" "$web" "$VERIFY_FIXTURE"; rm -rf "$tmp"; }
trap cleanup EXIT INT TERM
# Reuse only the baseline test's valid event/SQLite fixture builders.
sed '/^describe("V2 thread find"/,$d' "$checkout/apps/server/src/orchestration-v2/ThreadFind.test.ts" > "$server"
cat >> "$server" <<'SERVER'
import * as FS from "node:fs";
import * as Schema from "effect/Schema";
import { OrchestrationV2ProjectedTurnItemJson } from "@t3tools/contracts";
it.effect("verifies suppressed empty reply through production ThreadFind", () =>
Effect.gen(function* () {
const projection = yield* setup;
const tool: OrchestrationV2TurnItem = {
...item("render", 0, ""), type: "dynamic_tool", toolName: "html_render", input: {},
output: { htmlRender: { attachmentId: "visual", title: "Result", height: 120 } },
};
const controlRunId = RunId.make("run:control");
yield* commit([run(threadId, controlRunId, 2)]);
yield* putItems([tool, item("suppressed", 1, ""), { ...item("control", 2, ""), runId: controlRunId }]);
const first = yield* projection.searchThread({ threadId, query: "empty" });
const next = yield* projection.searchThread({ threadId, query: "empty", start: { entryId: "suppressed", occurrence: 0 }, offset: 1 });
const wrapped = yield* projection.searchThread({ threadId, query: "empty", start: { entryId: "control", occurrence: 0 }, offset: 1 });
assert.equal(first.totalMatches, 2);
assert.equal(first.match?.entryId, "suppressed");
assert.equal(next.totalMatches, 2);
assert.equal(next.match?.entryId, "control");
assert.equal(wrapped.match?.entryId, "suppressed");
const snapshot = yield* projection.getThreadSnapshot(threadId);
const rows = snapshot.projection.visibleTurnItems.map(row => ({ ...row, item: projectTurnItemForWire(row.item) }));
const fixture = { first, next, wrapped, rows: Schema.encodeSync(Schema.Array(OrchestrationV2ProjectedTurnItemJson))(rows) };
FS.writeFileSync(process.env.VERIFY_FIXTURE!, JSON.stringify(fixture, null, 2));
console.log("SERVER " + JSON.stringify({ first, next, wrapped }));
}).pipe(Effect.provide(layerTest)),
);
SERVER
cat > "$web" <<'WEB'
import { createRef, type ComponentProps } from "react";
import { EnvironmentId, ThreadId, OrchestrationV2ProjectedTurnItemJson } from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Atom, AsyncResult } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { render, act } from "../../../test/render";
import { MessagesTimeline } from "./MessagesTimeline";
import { ThreadFindBar } from "./ThreadFindBar";
import { useThreadFind } from "./useThreadFind";
import { openThreadFind, closeThreadFind } from "./threadFindStore";
import { collectThreadFindRanges } from "./threadFindHighlights";
import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic";
import { deriveTimelineEntriesFromVisibleTurnItems } from "../../session-logic";
import fixture from "./zz-verify-empty.fixture.json";
import "~/index.css";
// Replace only the network result atom with the actual result captured from SQLite/ThreadFind.
vi.mock(import("~/state/orchestration"), async (original) => {
const actual = await original();
const { Atom, AsyncResult } = await import("effect/reactivity");
const { default: captured } = await import("./zz-verify-empty.fixture.json");
const data = Atom.make(AsyncResult.success(captured.first));
return { ...actual, orchestrationEnvironment: { ...actual.orchestrationEnvironment, threadFind: () => data } };
});
const environmentId = EnvironmentId.make("verify-environment");
const thread = { environmentId, threadId: ThreadId.make("thread:find") };
const key = "verify-environment:thread:find";
const rows = Schema.decodeUnknownSync(Schema.Array(OrchestrationV2ProjectedTurnItemJson))(fixture.rows);
const entries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: rows, optimisticMessages: [] });
const listRef = createRef<any>();
function Harness() {
const find = useThreadFind({ thread, enabled: true });
const props: Omit<ComponentProps<typeof MessagesTimeline>, "timelineEntries"> = {
isWorking: false, listRef, latestRun: null, turnDiffSummaries: [], runs: [], routeThreadKey: key,
onOpenThread: () => {}, supportsConversationRollback: false, onRevertToTurnCount: () => {},
isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: environmentId,
markdownCwd: undefined, resolvedTheme: "light", timestampFormat: "locale", workspaceRoot: undefined,
anchorMessageId: null, onAnchorReady: () => {}, onAnchorSizeChanged: () => {},
contentInsetEndAdjustment: 0, emptyPlaceholderInset: 0, liveFollowEnabled: true, onIsAtEndChange: () => {},
renderHtmlRender: () => <div style={{height: 120}}>Rendered result</div>,
};
return <>
<ThreadFindBar {...find.barProps} />
<output data-selection>{find.timelineProps.activeFindMatch?.entryId ?? "none"}</output>
<MessagesTimeline {...props} {...find.timelineProps} timelineEntries={entries} />
</>;
}
it("real web find UI counts a suppressed empty reply and navigates to its blank body", async () => {
closeThreadFind();
const derived = deriveMessagesTimelineRows({ timelineEntries: entries, latestRun: null, isWorking: false, activeTurnStartedAt: null, turnDiffSummaries: [], supportsConversationRollback: false });
expect(derived.find(row => row.id === "suppressed")).toMatchObject({ suppressEmptyResponse: true });
expect(derived.find(row => row.id === "control")).toMatchObject({ suppressEmptyResponse: false });
const container = document.createElement("div");
container.style.cssText = "height: 700px; width: 900px; display: flex; flex-direction: column; position: relative";
await using screen = await render(<Harness />, { container });
await act(() => openThreadFind(key));
await screen.getByRole("searchbox", { name: "Find in thread" }).fill("empty");
await expect.element(screen.getByText("1/2", { exact: true })).toBeVisible();
await vi.waitFor(() => expect(screen.container.querySelector("[data-selection]")?.textContent).toBe("suppressed"));
const suppressedBody = () => screen.container.querySelector('[data-timeline-row-id="suppressed"] [data-thread-find-text]');
const controlBody = () => screen.container.querySelector('[data-timeline-row-id="control"] [data-thread-find-text]');
await vi.waitFor(() => expect(suppressedBody()).not.toBeNull());
expect(suppressedBody()!.textContent).toBe("");
await vi.waitFor(() => expect(controlBody()?.textContent).toBe("(empty response)"));
expect(collectThreadFindRanges(screen.container, "empty").map(match => match.rowId)).toEqual(["control"]);
// Observe the actual list method called by production useThreadFindNavigation.
using scroll = vi.spyOn(listRef.current, "scrollToIndex");
await screen.getByRole("button", { name: "Next match", exact: true }).click();
await expect.element(screen.getByText("2/2", { exact: true })).toBeVisible();
await vi.waitFor(() => expect(screen.container.querySelector("[data-selection]")?.textContent).toBe("control"));
await screen.getByRole("button", { name: "Next match", exact: true }).click();
await expect.element(screen.getByText("1/2", { exact: true })).toBeVisible();
await vi.waitFor(() => expect(screen.container.querySelector("[data-selection]")?.textContent).toBe("suppressed"));
const rowIndex = derived.findIndex(row => row.id === "suppressed");
await vi.waitFor(() => expect(scroll).toHaveBeenCalledWith(expect.objectContaining({ index: rowIndex, animated: false })));
expect(suppressedBody()!.textContent).toBe("");
console.log("BROWSER environment: " + navigator.userAgent);
console.log("BROWSER suppressed: count=1/2 selected=suppressed body=" + JSON.stringify(suppressedBody()!.textContent) + " matchingRanges=0 navigatedRowIndex=" + rowIndex);
console.log("BROWSER control: count=2/2 selected=control body=" + JSON.stringify(controlBody()!.textContent) + " matchingRanges=1");
closeThreadFind();
});
WEB
vp test run apps/server/src/orchestration-v2/zz-verify-empty.test.ts --maxWorkers=1 --silent=false --reporter=verbose
node -e 'const f=JSON.parse(require("node:fs").readFileSync(process.env.VERIFY_FIXTURE,"utf8")); console.log("SERVER observed: "+JSON.stringify({first:f.first,next:f.next,wrapped:f.wrapped}));'
cd "$checkout/apps/web"
vp test run --project browser src/components/chat/zz-verify-empty.browser.test.tsx --maxWorkers=1 --silent=false --reporter=verboseExpected behavior
Find counts only text the thread shows. The shared find text builder states this rule for hidden user prompts ("Clients hide this prompt, so find must not match text nobody can see", threadFindText.ts), and #34182 made the assistant row hide the (empty response) fallback when its run already has a completed render (TimelineAssistantRows.tsx). For this input, searching empty should find one match, the visible control reply, and the suppressed reply should contribute none.
Actual behavior
Summary. When an assistant reply is empty and its run also produced a completed HTML render, the timeline hides the reply's (empty response) fallback, but thread find still indexes that fallback. Searching empty counts the hidden reply as a match, selects it first, and navigates to a row whose message body is blank. A visible (empty response) reply in another run is counted and highlighted correctly. The effect is one extra count and one extra navigation stop with nothing to highlight; no message text is lost. Merged #34182 (#34182) added the timeline suppression; thread find was outside its changed files, and no statement was found that find should keep matching hidden fallback text.
Observed in the server test and in Chromium. The server returns totalMatches=2, selects suppressed first, then control, then wraps back to suppressed. The browser find bar shows 1/2 with suppressed selected, while that reply's searchable body is "" and has no matching range. Next shows 2/2 on control, whose body is (empty response) with one matching range. Next again wraps to 1/2 on suppressed, and find navigation scrolls the list to that row (index 1), whose body stays blank. The row keeps its author heading and metadata; only the message body is empty.
The server path receives only message text, role, streaming state and context (ThreadFind.ts), and the shared builder substitutes (empty response) for every completed empty assistant message (threadFindText.ts). The timeline derives suppression from a sibling render entry with the same run ID, which the find index does not see. The input is ordinary: the production html_render handler returns the render reference that session-logic.ts turns into the timeline entry, and #34182 describes this render-without-text reply as the case it fixes. A live provider turn was not run, and how often this occurs was not measured.
On the earlier main build under Version or commit, the same server test still returns the same two matches, and ThreadFind.ts, threadFindText.ts, useThreadFind.ts and useThreadFindNavigation.ts are unchanged from the tested commit. The browser test could not load on that build because the tested commit's dependency copy lacks gt-react, which main now imports, so the browser half on main is confirmed from source only.
Related, not duplicates: merged #34182 changed only the timeline display. Open #34402 fixes large-row highlight crashes, open #29310 changes Find document caching, open #29874 changes message search after rollback or cancellation, and open #27934 changes quoting of thinking replies; none changes what find indexes for a suppressed empty reply.
Evidence
- Expected source: the hidden-text rule at
packages/shared/src/threadFindText.tslines 233-235 and the fallback suppression from fix(web): hide empty replies when renders show the result #34182 atapps/web/src/components/chat/TimelineAssistantRows.tsxlines 34-36. - Failure source: the observed server and Chromium output under Logs or stack traces, and the unconditional fallback at
packages/shared/src/threadFindText.tslines 238-246. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
Restoration check
With the script's input and query empty, the server reports totalMatches=1 with control as the only match, the find bar shows 1/1, and Next never selects suppressed. As a control, the control reply in a run without a render still shows (empty response), still counts as one match, and still gets a highlight range, and a non-empty reply in a run with a render stays searchable. The script's assertions need updating to the restored counts for this check.
Impact
Cosmetic issue
Version or commit
d9805b2 (server half also reproduced on main at b85a21e; the four find files named under Actual behavior are unchanged from the tested commit through main at dc032d7)
Environment
macOS arm64, Node v26.12.1, Vitest v5.0.5, HeadlessChrome/156.0.8078.4 through the repo's browser test project; no dev server, desktop shell, mobile client, provider or remote transport
Logs or stack traces
SERVER: totalMatches=2; first=suppressed; next=control; wrapped=suppressed
BROWSER suppressed: count=1/2 selected=suppressed body="" matchingRanges=0 navigatedRowIndex=1
BROWSER control: count=2/2 selected=control body="(empty response)" matchingRanges=1
Server focused test: 1 passed
Chromium focused test: 1 passedScreenshots, recordings, or supporting files
No response
Workaround
Clicking Next again skips the phantom stop and selects the visible match; the count stays wrong.
Activity