Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5e08741
feat(web): find messages and plans in the current thread
Noojuno Sep 7, 2026
c0c6225
fix(web): keep thread highlight helpers private
Noojuno Sep 7, 2026
8912f9c
fix(web): improve thread search contrast and use an opaque bar
Noojuno Sep 7, 2026
6ac5972
feat(web): make thread search highlights themeable
Noojuno Sep 7, 2026
716a4ad
fix(web): match thread search bar to control radius
Noojuno Sep 7, 2026
3133aba
fix(web): align thread search with rendered text
Noojuno Sep 8, 2026
5d03337
fix(web): search older thread history automatically
Noojuno Sep 8, 2026
9a5ada4
fix(web): keep partial search results when history fails
Noojuno Sep 8, 2026
e7b7d59
fix(web): use shared sizing for the thread find bar
Noojuno Sep 8, 2026
729ecae
refactor(web): simplify thread search rendering and cleanup
Noojuno Sep 8, 2026
a2dd75a
fix(web): preserve attachment extraction and find escape priority
Noojuno Sep 8, 2026
b2970b1
feat(search): find thread messages on the server
Noojuno Sep 8, 2026
11bc859
chore(search): remove relocated Markdown dependencies
Noojuno Sep 8, 2026
33b5dd2
chore(search): keep Markdown extraction helper private
Noojuno Sep 8, 2026
1bf7fe6
fix(search): require the wrapped search error cause
Noojuno Sep 8, 2026
e0136a5
fix(search): align rebased find with upstream APIs
Noojuno Sep 8, 2026
18068ca
refactor(search): consolidate find state and remove forwarding modules
Noojuno Sep 8, 2026
7679ced
fix(search): cover review text and thread lifecycle edge cases
Noojuno Sep 8, 2026
7481e89
fix(search): align rebased find with upstream message contexts
Noojuno Sep 16, 2026
c117b2a
fix(search): match file chips and reveal hidden results
Noojuno Sep 17, 2026
b76026b
refactor(search): clarify shared helper ownership
Noojuno Sep 17, 2026
fb72d50
fix(web): soften idle search highlights and use warm amber
Noojuno Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { fileBasename } from "@t3tools/shared/path";
import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
import {
fileBasename,
formatFilePathPosition,
inlineCodeFilePathCandidate,
normalizeMarkdownLinkDestination,
parseMarkdownFileLink,
} from "@t3tools/client-runtime/markdown-links";
} from "@t3tools/shared/markdownLinks";
import { videoMimeType } from "@t3tools/shared/video";

import type { MARKDOWN_FILE_ICON_SOURCES } from "./markdownFileIcons.generated";
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
import {
appendCodexArtifactTemplateUsePrompt,
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
} from "@t3tools/shared/codexArtifactTemplates";
import type {
CodexFeedbackSubmission,
EnvironmentThreadStatus,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { writeComposerContextClipboard } from "../../lib/composerContextClipboar
import {
codexArtifactTemplatePresentationLabel,
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
} from "@t3tools/shared/codexArtifactTemplates";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
Expand All @@ -40,7 +40,7 @@ import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presen
import {
renderCodexFileCitationsAsMarkdown,
splitCodexArtifactTemplateMarkdown,
} from "@t3tools/client-runtime/codex-markdown-directives";
} from "@t3tools/shared/codexMarkdownDirectives";
import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { imageMimeType } from "@t3tools/shared/image";
import { videoMimeType } from "@t3tools/shared/video";
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/fileChipMenu.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fileBasename } from "@t3tools/client-runtime/markdown-links";
import { fileBasename } from "@t3tools/shared/path";
import type { ThreadId } from "@t3tools/contracts";
import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";
Expand Down
45 changes: 3 additions & 42 deletions apps/mobile/src/features/threads/thread-search-match.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,18 @@
import { Platform } from "react-native";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import { splitThreadSearchText } from "@t3tools/shared/threadSearch";
import { type EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";

import { AppText as Text } from "../../components/AppText";
import { cn } from "../../lib/cn";

function foldAsciiCase(value: string): string {
return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
}

function splitHighlightParts(text: string, query: string) {
const normalizedText = foldAsciiCase(text);
const normalizedQuery = foldAsciiCase(query.trim());
if (normalizedQuery.length === 0) {
return [{ text, highlighted: false, start: 0 }];
}

const parts: Array<{
readonly text: string;
readonly highlighted: boolean;
readonly start: number;
}> = [];
let cursor = 0;
while (cursor < text.length) {
const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
if (matchIndex === -1) {
parts.push({ text: text.slice(cursor), highlighted: false, start: cursor });
break;
}
if (matchIndex > cursor) {
parts.push({
text: text.slice(cursor, matchIndex),
highlighted: false,
start: cursor,
});
}
parts.push({
text: text.slice(matchIndex, matchIndex + normalizedQuery.length),
highlighted: true,
start: matchIndex,
});
cursor = matchIndex + normalizedQuery.length;
}
return parts;
}

export function ThreadSearchMatchExcerpt(props: {
readonly match: EnvironmentThreadSearchMatch;
readonly query: string;
readonly selected?: boolean;
readonly compact?: boolean;
}) {
const isUser = props.match.source === "user";
const parts = splitHighlightParts(props.match.snippet, props.query);
const parts = splitThreadSearchText(props.match.snippet, props.query);
return (
<Text
className={cn(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.searchThread]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
[ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThread: () => Effect.die("searchThread is not used in this test"),
searchThreads: () => Effect.succeed({ matches: [] }),
}),
),
Expand Down Expand Up @@ -218,6 +219,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThread: () => Effect.die("searchThread is not used in this test"),
searchThreads: () => Effect.succeed({ matches: [] }),
}),
),
Expand Down Expand Up @@ -310,6 +312,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThread: () => Effect.die("searchThread is not used in this test"),
searchThreads: () => Effect.succeed({ matches: [] }),
}),
),
Expand Down Expand Up @@ -387,6 +390,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThread: () => Effect.die("searchThread is not used in this test"),
searchThreads: () => Effect.succeed({ matches: [] }),
}),
),
Expand Down Expand Up @@ -449,6 +453,7 @@ describe("CheckpointDiffQuery.layer", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThread: () => Effect.die("searchThread is not used in this test"),
searchThreads: () => Effect.succeed({ matches: [] }),
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ describe("OrchestrationEngine", () => {
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
searchThread: () => Effect.die("searchThread is not used in this test"),
searchThreads: () => Effect.succeed({ matches: [] }),
}),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2353,6 +2353,19 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
)
`;

const withinThread = yield* snapshotQuery.searchThread({
threadId: ThreadId.make("thread-active"),
query: "interim needle",
});
assert.equal(withinThread.totalMatches, 1);
assert.equal(withinThread.match?.source, "message");
assert.equal(
(yield* snapshotQuery.searchThread({
threadId: ThreadId.make("thread-active"),
query: "system needle",
})).totalMatches,
0,
);
const literalPercent = yield* snapshotQuery.searchThreads({ query: "100%" });
assert.deepStrictEqual(
literalPercent.matches.map((match) => [match.threadId, match.source]),
Expand Down Expand Up @@ -3548,3 +3561,36 @@ projectionSnapshotLayer("ProjectionSnapshotQuery activities by kind", (it) => {
}),
);
});

projectionSnapshotLayer("ThreadFindLifecycle", (it) => {
it.effect("invalidates search when a deleted thread ID is recreated", () =>
Effect.gen(function* () {
const query = yield* ProjectionSnapshotQuery;
const sql = yield* SqlClient.SqlClient;
const threadId = ThreadId.make("recreated-search-thread");
const at = "2026-06-01T00:00:00.000Z";
yield* sql`DELETE FROM orchestration_events`;
yield* sql`DELETE FROM projection_state`;
yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, created_at, updated_at)
VALUES (${threadId}, 'project', 'Find', '{"provider":"codex","model":"gpt-5"}', ${at}, ${at})`;
yield* sql`INSERT INTO projection_thread_messages (message_id, thread_id, role, text, is_streaming, created_at, updated_at)
VALUES ('recreated-message', ${threadId}, 'assistant', 'needle', 0, ${at}, ${at})`;
const event = (sequence: number, type: string) => sql`INSERT INTO orchestration_events
(sequence, event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json)
VALUES (${sequence}, ${`search-event-${sequence}`}, 'thread', ${threadId}, ${sequence}, ${type}, ${at}, 'client', '{}', '{}')`;
yield* event(1, "thread.message-sent");
for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) {
yield* sql`INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES (${projector}, 1, ${at})`;
}
assert.equal((yield* query.searchThread({ threadId, query: "needle" })).totalMatches, 1);
yield* event(2, "thread.deleted");
yield* event(3, "thread.created");
yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${threadId}`;
yield* sql`UPDATE projection_state SET last_applied_sequence = 3`;
const result = yield* query.searchThread({ threadId, query: "needle" });
assert.equal(result.threadSequence, 3);
assert.equal(result.totalMatches, 0);
assert.equal(result.match, null);
}),
);
});
24 changes: 24 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ import {
type ProjectionSnapshotQueryShape,
} from "../Services/ProjectionSnapshotQuery.ts";

import { makeThreadFindQuery } from "./ThreadFindQuery.ts";

const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot);
const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread);
Expand Down Expand Up @@ -3757,6 +3759,27 @@ pending_approval_requests AS (
),
);

// Unlike the detail subscription watermark, a search index must include lifecycle
// events: creating a previously deleted ID resets all of its projected messages.
const getThreadSearchSequence = SqlSchema.findOne({
Request: Schema.Struct({ threadId: ThreadId, maxSequence: Schema.Number }),
Result: Schema.Struct({ sequence: Schema.NullOr(Schema.Number) }),
execute: ({ threadId, maxSequence }) => sql`
SELECT MAX(sequence) AS sequence FROM orchestration_events
WHERE aggregate_kind = 'thread' AND stream_id = ${threadId}
AND sequence <= ${maxSequence}`,
});
const searchThread = yield* makeThreadFindQuery(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ProjectionSnapshotQuery.ts:3772

searchThread returns archived threads as searchable because makeThreadFindQuery filters only deleted_at IS NULL, unlike getActiveThreadRowById, which also requires archived_at IS NULL. An RPC using an archived thread ID therefore returns its messages and plans even though the ProjectionSnapshotQueryShape contract is for active threads and the normal detail endpoint rejects that thread. Add the archived_at IS NULL predicate to the query used by makeThreadFindQuery, or avoid exposing this search path for archived threads.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts around line 3772:

`searchThread` returns archived threads as searchable because `makeThreadFindQuery` filters only `deleted_at IS NULL`, unlike `getActiveThreadRowById`, which also requires `archived_at IS NULL`. An RPC using an archived thread ID therefore returns its messages and plans even though the `ProjectionSnapshotQueryShape` contract is for active threads and the normal detail endpoint rejects that thread. Add the `archived_at IS NULL` predicate to the query used by `makeThreadFindQuery`, or avoid exposing this search path for archived threads.

Effect.fn("ThreadFindQuery.sequence")(function* (threadId) {
const { snapshotSequence } = yield* getSnapshotSequence();
const row = yield* getThreadSearchSequence({
threadId,
maxSequence: snapshotSequence,
}).pipe(Effect.mapError(toPersistenceSqlError("searchThread:sequence")));
return row.sequence ?? 0;
}),
);

return {
getCommandReadModel,
getUserInputActivity,
Expand All @@ -3766,6 +3789,7 @@ pending_approval_requests AS (
getArchivedShellSnapshot,
getDeletedWorktreeThreads,
searchThreads,
searchThread,
getSnapshotSequence,
getCounts,
getEventReplayStats,
Expand Down
Loading
Loading