diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 4143b2fc8a28..8f0e293533bf 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -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"; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e7b4650e2117..5ec528ecc47a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -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, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a860612cb922..30142ba11f98 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -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"; @@ -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"; diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 9f82e089444d..9835c0c37cc1 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -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"; diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 618cf15ee142..fda52bcc2a96 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -1,49 +1,10 @@ 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; @@ -51,7 +12,7 @@ export function ThreadSearchMatchExcerpt(props: { 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 ( { 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: [] }), }), ), @@ -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: [] }), }), ), @@ -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: [] }), }), ), @@ -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: [] }), }), ), @@ -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: [] }), }), ), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 078750967471..fb71d7e7b5e4 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -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: [] }), }), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 843eb8343d84..76684f013683 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -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]), @@ -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); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 03bf17329286..6318b5e67085 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -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); @@ -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( + 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, @@ -3766,6 +3789,7 @@ pending_approval_requests AS ( getArchivedShellSnapshot, getDeletedWorktreeThreads, searchThreads, + searchThread, getSnapshotSequence, getCounts, getEventReplayStats, diff --git a/apps/server/src/orchestration/Layers/ThreadFindQuery.test.ts b/apps/server/src/orchestration/Layers/ThreadFindQuery.test.ts new file mode 100644 index 000000000000..3bb4f268d9b0 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadFindQuery.test.ts @@ -0,0 +1,192 @@ +import * as Schema from "effect/Schema"; +import { ThreadId, OrchestrationMessageContext } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { makeThreadFindQuery } from "./ThreadFindQuery.ts"; + +const decodeContext = Schema.decodeUnknownEffect(OrchestrationMessageContext); +const encodeContext = Schema.encodeEffect(Schema.fromJsonString(OrchestrationMessageContext)); + +const threadId = ThreadId.make("find-thread"); +const timestamp = "2026-06-01T00:00:00.000Z"; +const setup = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_threads`; + 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"}', ${timestamp}, ${timestamp})`; + const revision = yield* Ref.make(1); + const search = yield* makeThreadFindQuery(() => Ref.get(revision)); + const message = (id: string, text: string, role = "assistant", target = threadId) => + sql`INSERT INTO projection_thread_messages (message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at) + VALUES (${id}, ${target}, 'turn-1', ${role}, ${text}, 0, ${timestamp}, ${timestamp})`; + const plan = (id: string, text: string) => + sql`INSERT INTO projection_thread_proposed_plans (plan_id, thread_id, turn_id, plan_markdown, created_at, updated_at) + VALUES (${id}, ${threadId}, 'turn-1', ${text}, ${timestamp}, ${timestamp})`; + return { sql, revision, search, message, plan }; +}); + +it.layer(SqlitePersistenceMemory)("ThreadFindQuery", (it) => { + it.effect( + "matches rendered Markdown, progress, code, user text and plan titles in stable order", + () => + Effect.gen(function* () { + const { search, message, plan } = yield* setup; + yield* message("a", "orbit**needle** orbitneedle"); + yield* message("b", "`orbitneedle`", "user"); + yield* message("c", "```ts\norbitneedle\n```"); + yield* message("d", "orbitneedle", "system"); + yield* message("e", "[label](https://orbitneedle.invalid)"); + yield* message("foreign", "orbitneedle", "user", ThreadId.make("other")); + yield* plan("a-plan", "# orbitneedle\n\nAn orbitneedle plan."); + const results = yield* Effect.forEach([0, 1, 2, 3, 4, 5], (index) => + search({ threadId, query: "ORBITNEEDLE", index }), + ); + assert.deepStrictEqual( + results.map((result) => [result.match?.sourceId, result.match?.occurrence]), + [ + ["a", 0], + ["a", 1], + ["b", 0], + ["c", 0], + ["a-plan", 0], + ["a-plan", 1], + ], + ); + assert.ok(results.every((result) => result.totalMatches === 6)); + assert.equal(results[4]?.proposedPlans[0]?.id, "a-plan"); + assert.equal( + (yield* search({ threadId, query: "orbitneedle", index: 999 })).activeIndex, + 5, + ); + }), + ); + + it.effect("preserves message context and tolerates reasoning rows in search windows", () => + Effect.gen(function* () { + const { search, message, sql } = yield* setup; + yield* message("a", "needle", "reasoning"); + yield* message("b", "needle [hidden label](t3-context://v1/terminal/terminal_1)", "user"); + const context = yield* decodeContext({ + version: 1, + records: [ + { + version: 1, + contextId: "terminal_1", + kind: "terminal", + label: "hidden label", + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 1, + text: "hidden payload", + }, + ], + }); + const contextJson = yield* encodeContext(context); + yield* sql`UPDATE projection_thread_messages SET context_json = ${contextJson} WHERE message_id = 'b'`; + const result = yield* search({ threadId, query: "needle" }); + assert.equal(result.totalMatches, 1); + assert.equal(result.match?.sourceId, "b"); + assert.deepStrictEqual( + result.messages.find((message) => message.id === "b")?.context, + context, + ); + assert.equal(result.messages[0]?.role, "reasoning"); + assert.equal((yield* search({ threadId, query: "hidden" })).totalMatches, 0); + }), + ); + + it.effect( + "uses the thread workspace to search displayed file chips and invalidate cached labels", + () => + Effect.gen(function* () { + const { sql, search, message } = yield* setup; + yield* message("file", "[important description](src/main.ts#L42) and `/tmp/actual.ts:3`"); + assert.equal((yield* search({ threadId, query: "important" })).totalMatches, 1); + yield* sql`UPDATE projection_threads SET worktree_path = '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/workspace/repo' WHERE thread_id = ${threadId}`; + assert.equal((yield* search({ threadId, query: "important" })).totalMatches, 0); + assert.equal((yield* search({ threadId, query: "main.ts · L42" })).totalMatches, 1); + assert.equal((yield* search({ threadId, query: "actual.ts · L3" })).totalMatches, 1); + assert.equal((yield* search({ threadId, query: "/tmp/" })).totalMatches, 0); + }), + ); + + it.effect("preserves substring, punctuation, block boundaries and Unicode matching", () => + Effect.gen(function* () { + const { search, message } = yield* setup; + yield* message("a", "100% foo_bar İ X orbit\n\nneedle"); + for (const query of ["%", "_", "İ", "X", "00"]) { + assert.equal((yield* search({ threadId, query })).totalMatches, 1); + } + assert.equal((yield* search({ threadId, query: "orbit needle" })).totalMatches, 0); + assert.equal((yield* search({ threadId, query: "absent" })).match, null); + }), + ); + + it.effect("searches 3,000 messages across keyset batches but returns at most six messages", () => + Effect.gen(function* () { + const { sql, search } = yield* setup; + const rows = Array.from({ length: 3000 }, (_, index) => ({ + message_id: `message-${String(index).padStart(4, "0")}`, + thread_id: threadId, + role: "assistant", + text: index % 999 === 0 ? "orbit**needle**" : "Ordinary conversation", + is_streaming: 0, + created_at: timestamp, + updated_at: timestamp, + })); + for (let index = 0; index < rows.length; index += 100) { + yield* sql`INSERT INTO projection_thread_messages ${sql.insert(rows.slice(index, index + 100))}`; + } + for (let index = 0; index < 4; index++) { + const result = yield* search({ threadId, query: "orbitneedle", index }); + assert.equal(result.totalMatches, 4); + assert.equal(result.match?.sourceId, rows[index * 999]?.message_id); + assert.ok(result.messages.length <= 6); + assert.ok(result.messages.some((message) => message.id === result.match?.sourceId)); + } + }), + ); + + it.effect( + "invalidates cached counts after edits/reverts and never searches deleted threads", + () => + Effect.gen(function* () { + const { sql, revision, search, message } = yield* setup; + yield* message("a", "needle"); + assert.equal((yield* search({ threadId, query: "needle" })).totalMatches, 1); + yield* sql`UPDATE projection_thread_messages SET text = 'needle needle' WHERE message_id = 'a'`; + yield* Ref.update(revision, (value) => value + 1); + assert.equal((yield* search({ threadId, query: "needle" })).totalMatches, 2); + yield* sql`DELETE FROM projection_thread_messages WHERE message_id = 'a'`; + yield* Ref.update(revision, (value) => value + 1); + assert.equal((yield* search({ threadId, query: "needle" })).totalMatches, 0); + yield* message("a", "needle"); + yield* Ref.update(revision, (value) => value + 1); + assert.equal((yield* search({ threadId, query: "needle" })).totalMatches, 1); + yield* sql`UPDATE projection_threads SET deleted_at = ${timestamp} WHERE thread_id = ${threadId}`; + assert.equal((yield* search({ threadId, query: "needle" })).totalMatches, 0); + assert.equal( + (yield* search({ threadId: ThreadId.make("missing"), query: "needle" })).totalMatches, + 0, + ); + }), + ); + + it.effect("does not cache an index built across different projection revisions", () => + Effect.gen(function* () { + const { revision, message } = yield* setup; + yield* message("a", "needle"); + const search = yield* makeThreadFindQuery(() => + Ref.updateAndGet(revision, (value) => value + 1), + ); + const result = yield* Effect.result(search({ threadId, query: "needle" })); + assert.equal(result._tag, "Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ThreadFindQuery.ts b/apps/server/src/orchestration/Layers/ThreadFindQuery.ts new file mode 100644 index 000000000000..90754b9e2981 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ThreadFindQuery.ts @@ -0,0 +1,245 @@ +import { + ChatAttachment, + MessageId, + OrchestrationProposedPlan, + OrchestrationMessageRole, + OrchestrationMessageContext, + ThreadId, + TurnId, + type OrchestrationSearchThreadInput, + type OrchestrationSearchThreadResult, +} from "@t3tools/contracts"; +import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText"; +import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import * as Cache from "effect/Cache"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { + PersistenceSqlError, + isPersistenceError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; + +const SourceRow = Schema.Struct({ + id: Schema.String, + turnId: Schema.NullOr(TurnId), + text: Schema.String, + role: OrchestrationMessageRole, + context: Schema.NullOr(Schema.fromJsonString(OrchestrationMessageContext)), + streaming: Schema.Number, + createdAt: Schema.String, +}); +const Cursor = Schema.Struct({ threadId: ThreadId, createdAt: Schema.String, id: Schema.String }); +class SearchKey extends Data.Class<{ + threadId: ThreadId; + query: string; + sequence: number; + cwd: string | undefined; +}> {} +interface MatchingDocument { + source: "message" | "plan"; + sourceId: string; + turnId: TurnId | null; + createdAt: string; + count: number; +} + +/** Search projected visible text without sending or retaining a full transcript in the client. */ +export const makeThreadFindQuery = Effect.fn("makeThreadFindQuery")(function* ( + getSequence: (threadId: ThreadId) => Effect.Effect, +) { + const sql = yield* SqlClient.SqlClient; + const messageBatch = SqlSchema.findAll({ + Request: Cursor, + Result: SourceRow, + execute: ({ threadId, createdAt, id }) => sql` + SELECT message_id AS id, turn_id AS "turnId", text, role, + is_streaming AS streaming, context_json AS context, created_at AS "createdAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} AND (created_at, message_id) > (${createdAt}, ${id}) + ORDER BY created_at, message_id LIMIT 128`, + }); + const planBatch = SqlSchema.findAll({ + Request: Cursor, + Result: SourceRow, + execute: ({ threadId, createdAt, id }) => sql` + SELECT plan_id AS id, turn_id AS "turnId", plan_markdown AS text, + 'assistant' AS role, NULL AS context, 0 AS streaming, created_at AS "createdAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} AND (created_at, plan_id) > (${createdAt}, ${id}) + ORDER BY created_at, plan_id LIMIT 128`, + }); + const activeThread = SqlSchema.findAll({ + Request: ThreadId, + Result: Schema.Struct({ id: ThreadId, cwd: Schema.NullOr(Schema.String) }), + execute: (threadId) => sql` + SELECT t.thread_id AS id, COALESCE(t.worktree_path, p.workspace_root) AS cwd + FROM projection_threads t LEFT JOIN projection_projects p ON p.project_id = t.project_id + WHERE t.thread_id = ${threadId} AND t.deleted_at IS NULL`, + }); + const changed = () => + new PersistenceSqlError({ + operation: "searchThread", + detail: "Thread changed during search. Please retry.", + }); + const scan = Effect.fn("ThreadFindQuery.scan")(function* (key: SearchKey) { + const documents: MatchingDocument[] = []; + let totalMatches = 0; + for (const source of ["message", "plan"] as const) { + let cursor = { threadId: key.threadId, createdAt: "", id: "" }; + while (true) { + const rows = yield* source === "message" ? messageBatch(cursor) : planBatch(cursor); + for (const row of rows) { + const segments = + source === "plan" + ? searchablePlanSegments(row.text, key.cwd) + : searchableMessageSegments( + { + ...row, + context: row.context ?? undefined, + streaming: row.streaming === 1, + }, + key.cwd, + ); + const count = + segments?.reduce( + (sum, text) => sum + countThreadSearchOccurrences(text, key.query), + 0, + ) ?? 0; + if (count > 0) { + documents.push({ + source, + sourceId: row.id, + turnId: row.turnId, + createdAt: row.createdAt, + count, + }); + totalMatches += count; + } + } + const last = rows.at(-1); + if (rows.length < 128 || !last) break; + cursor = { threadId: key.threadId, createdAt: last.createdAt, id: last.id }; + yield* Effect.yieldNow; + } + } + // Scans are already ID-ordered; stable sort keeps messages before plans on ties. + documents.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0)); + if ((yield* getSequence(key.threadId)) !== key.sequence) return yield* changed(); + return { documents, totalMatches }; + }); + // Cache counts/positions, not message bodies or one object per occurrence. + const cache = yield* Cache.makeWith(scan, { + capacity: 16, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.documents.length <= 10_000 ? "1 minute" : 0, + }); + const messages = SqlSchema.findAll({ + Request: Schema.Struct({ threadId: ThreadId, createdAt: Schema.String, id: Schema.String }), + Result: Schema.Struct({ + id: MessageId, + turnId: Schema.NullOr(TurnId), + role: SourceRow.fields.role, + context: SourceRow.fields.context, + text: Schema.String, + streaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + createdAt: Schema.String, + updatedAt: Schema.String, + }), + execute: ({ threadId, createdAt, id }) => sql` + SELECT message_id AS id, turn_id AS "turnId", role, text, + is_streaming AS streaming, attachments_json AS attachments, context_json AS context, + created_at AS "createdAt", updated_at AS "updatedAt" + FROM ( + SELECT * FROM (SELECT * FROM projection_thread_messages + WHERE thread_id = ${threadId} AND (created_at, message_id) <= (${createdAt}, ${id}) + ORDER BY created_at DESC, message_id DESC LIMIT 3) + UNION ALL + SELECT * FROM (SELECT * FROM projection_thread_messages + WHERE thread_id = ${threadId} AND (created_at, message_id) > (${createdAt}, ${id}) + ORDER BY created_at, message_id LIMIT 3) + ) ORDER BY created_at, message_id`, + }); + const plans = SqlSchema.findAll({ + Request: Schema.Struct({ threadId: ThreadId, id: Schema.String }), + Result: OrchestrationProposedPlan, + execute: ({ threadId, id }) => sql` + SELECT plan_id AS id, turn_id AS "turnId", plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM projection_thread_proposed_plans WHERE thread_id = ${threadId} AND plan_id = ${id}`, + }); + return Effect.fn("ThreadFindQuery.searchThread")( + function* (input: OrchestrationSearchThreadInput) { + const threadSequence = yield* getSequence(input.threadId); + const empty: OrchestrationSearchThreadResult = { + threadSequence, + totalMatches: 0, + activeIndex: 0, + match: null, + messages: [], + proposedPlans: [], + }; + const thread = (yield* activeThread(input.threadId))[0]; + if (!thread) return empty; + const { documents, totalMatches } = yield* Cache.get( + cache, + new SearchKey({ + threadId: input.threadId, + query: input.query, + sequence: threadSequence, + cwd: thread.cwd ?? undefined, + }), + ); + const activeIndex = Math.min(input.index ?? 0, Math.max(0, totalMatches - 1)); + let occurrence = activeIndex; + const selected = documents.find((document) => { + if (occurrence < document.count) return true; + occurrence -= document.count; + return false; + }); + if (!selected) return empty; + const contextMessages = yield* messages({ + threadId: input.threadId, + createdAt: selected.createdAt, + id: selected.source === "message" ? selected.sourceId : "", + }); + const proposedPlans = + selected.source === "plan" + ? yield* plans({ threadId: input.threadId, id: selected.sourceId }) + : []; + if ( + (yield* getSequence(input.threadId)) !== threadSequence || + (yield* activeThread(input.threadId)).length === 0 + ) + return yield* changed(); + return { + threadSequence, + totalMatches, + activeIndex, + match: { + source: selected.source, + sourceId: selected.sourceId, + turnId: selected.turnId, + occurrence, + }, + messages: contextMessages.map(({ streaming, attachments, context, ...message }) => ({ + ...message, + streaming: streaming === 1, + ...(context === null ? {} : { context }), + ...(attachments === null ? {} : { attachments }), + })), + proposedPlans, + }; + }, + Effect.mapError((error) => + isPersistenceError(error) ? error : toPersistenceSqlError("searchThread")(error), + ), + ); +}); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index eac3ede9c1ee..6abae3d5879b 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -17,6 +17,8 @@ import type { OrchestrationProjectShell, OrchestrationReadModel, OrchestrationSearchThreadsInput, + OrchestrationSearchThreadInput, + OrchestrationSearchThreadResult, OrchestrationSearchThreadsResult, OrchestrationShellSnapshot, OrchestrationThread, @@ -148,6 +150,10 @@ export interface ProjectionSnapshotQueryShape { * Search active thread navigation metadata, user messages, and canonical * assistant outputs without hydrating thread detail snapshots. */ + readonly searchThread: ( + input: OrchestrationSearchThreadInput, + ) => Effect.Effect; + readonly searchThreads: ( input: OrchestrationSearchThreadsInput, ) => Effect.Effect; diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index fd383a4074b9..bd97ad03463e 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -64,6 +64,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getTurnStartMessage: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.die("unused"), }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index dd341a7f7859..8d7455f5454a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -54,6 +54,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), }); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b9997e1df312..1d27172043fd 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -5007,6 +5007,7 @@ describe("agent browser access", () => { }).pipe(Effect.orDie), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.die("unused"), }); const providerLayer = makeProviderServiceLive({ diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index d8226648e9f3..8b4c9e6cbed0 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -261,6 +261,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index be16a3f7fb5d..7483af36a541 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1021,6 +1021,7 @@ const buildAppUnderTest = (options?: { threads: [], updatedAt: "1970-01-01T00:00:00.000Z", }), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getProjectShellById: () => Effect.succeed(Option.none()), @@ -8389,6 +8390,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { layers: { projectionSnapshotQuery: { getSnapshot: () => Effect.succeed(snapshot), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [ diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index ab654e15c70b..95979d0cac22 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -200,6 +200,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -330,6 +331,7 @@ it.effect.each([ getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -403,6 +405,7 @@ it.effect( getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -468,6 +471,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + searchThread: () => Effect.die("searchThread is not used in this test"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 802a0396d68f..312385b0b7c4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -44,6 +44,7 @@ import { OrchestrationGetFullThreadDiffError, OrchestrationGetSnapshotError, OrchestrationSearchThreadsError, + OrchestrationSearchThreadError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, ProjectId, @@ -1820,6 +1821,7 @@ const makeWsRpcLayer = ( threadResumeCompletionMarker: true, threadSnapshotPagination: true, reasoningMessages: true, + threadFind: true, }; }); @@ -1950,6 +1952,20 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_WS_METHODS.searchThread]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.searchThread, + projectionSnapshotQuery.searchThread(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationSearchThreadError({ + message: "Could not search this thread. Try again.", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_WS_METHODS.searchThreads]: (input) => observeRpcEffect( ORCHESTRATION_WS_METHODS.searchThreads, diff --git a/apps/web/package.json b/apps/web/package.json index e6980cf778ea..6f5a28bd0528 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,9 +50,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", - "rehype-raw": "^7.0.0", - "rehype-sanitize": "^6.0.0", - "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 4e22e68ca902..41d22337781a 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,3 +1,4 @@ +import { MarkdownFindContext } from "./chat/markdownFindContext"; import { EnvironmentId } from "@t3tools/contracts"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -860,3 +861,63 @@ describe("ChatMarkdown Windows file links", () => { expect(html).not.toContain("chat-markdown-file-link"); }); }); + +it("opens nested disclosures for find and restores their prior state when find closes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const render = (searching: boolean) => ( + + + + ); + try { + await act(async () => { + renderer = create(render(false)); + }); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("needle"); + await act(async () => { + renderer!.update(render(true)); + }); + expect(JSON.stringify(renderer!.toJSON())).toContain("needle"); + await act(async () => { + renderer!.update(render(false)); + }); + expect( + renderer!.root + .findAll( + (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", + ) + .every((node) => node.props["aria-expanded"] === false), + ).toBe(true); + const trigger = renderer!.root.findAll( + (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", + )[0]!; + await act(async () => { + trigger.props.onClick({ nativeEvent: new Event("click") }); + }); + await act(async () => { + renderer!.update(render(true)); + }); + await act(async () => { + renderer!.update(render(false)); + }); + expect( + renderer!.root.findAllByProps({ "data-markdown-details-open": "true" }).length, + ).toBeGreaterThan(0); + expect( + renderer!.root + .findAll( + (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", + ) + .map((node) => node.props["aria-expanded"]), + ).toEqual([true, false]); + } finally { + await act(async () => { + renderer?.unmount(); + }); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9136900fe0f7..791c7ab2b48e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,23 @@ +import { MarkdownFindContext } from "./chat/markdownFindContext"; +import { + buildFileLinkParentSuffixByPath, + fileLinkLabel, + resolvePathLinkTarget, +} from "@t3tools/shared/fileLinks"; +import { + isWindowsDrivePathHref, + normalizeMarkdownLinkDestination, + extractInlineCodeSpans, + extractMarkdownLinkHrefs, + inlineCodeFilePathCandidate, +} from "@t3tools/shared/markdownLinks"; +import { isAbsolutePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { + CHAT_MARKDOWN_REMARK_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + CHAT_MARKDOWN_REHYPE_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, @@ -44,12 +63,11 @@ import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, type CodexArtifactTemplateKind, -} from "@t3tools/client-runtime/codex-artifact-templates"; +} from "@t3tools/shared/codexArtifactTemplates"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; @@ -82,20 +100,13 @@ import ReactMarkdown from "react-markdown"; import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; -import remarkGfm from "remark-gfm"; -import { remarkGithubAlerts } from "../markdown-github-alerts"; import { artifactTemplateFromHastProperties, - CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, - remarkCodexDirectives, renderCodexFileCitationsAsMarkdown, -} from "@t3tools/client-runtime/codex-markdown-directives"; +} from "@t3tools/shared/codexMarkdownDirectives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { resolveMarkdownMediaPreview, @@ -145,11 +156,7 @@ import { serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; -import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - extractMarkdownLinkHrefs, - isWindowsDrivePathHref, - normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -186,7 +193,6 @@ import { import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -385,131 +391,6 @@ function orderedListGutterStyle( return { "--list-gutter": `${markerWidth + 1}ch` }; } -type MarkdownImageHastNode = { - type?: string; - tagName?: string; - properties?: Record; - children?: MarkdownImageHastNode[]; -}; - -function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { - return (node.children ?? []).filter( - (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), - ); -} - -/** - * An image that is the only content of its block (optionally wrapped in a - * link) is almost always a screenshot or figure, so it gets a reserved slot - * while it loads. Images mixed with text or other images — badge rows, icons - * in a sentence — stay inline at their natural size, since a placeholder taller - * than the image would move the page more than the image itself does. - */ -/** Containers whose sole child image reads as a figure rather than part of a sentence. */ -const STANDALONE_IMAGE_BLOCKS = new Set([ - "p", - "div", - "li", - "td", - "th", - "figure", - "center", - "blockquote", -]); - -function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { - const children = meaningfulHastChildren(node); - if (children.length !== 1) return undefined; - const only = children[0]; - if (only?.type !== "element") return undefined; - if (only.tagName === "img") return only; - // A link, emphasis, or similar inline wrapper around the image still counts - // as long as nothing else shares the block. - return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" - ? soleImageDescendant(only) - : undefined; -} - -function markStandaloneImages(node: MarkdownImageHastNode) { - // A raw `` on its own line reaches the root without a paragraph. - if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { - const image = soleImageDescendant(node); - if (image) image.properties = { ...image.properties, dataStandalone: true }; - } - node.children?.forEach((child) => { - if (child.type === "element") markStandaloneImages(child); - }); -} - -/** Carries authored image source metadata through the sanitizer to the image renderer. */ -function rehypePreserveImageSourceMeta() { - return (tree: MarkdownImageHastNode) => { - const visit = (node: MarkdownImageHastNode) => { - const src = node.properties?.src; - const title = node.properties?.title; - if (node.type === "element" && node.tagName === "img") { - node.properties = { - ...node.properties, - ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), - ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), - }; - } - node.children?.forEach(visit); - }; - - visit(tree); - markStandaloneImages(tree); - }; -} - -const CHAT_MARKDOWN_SANITIZE_SCHEMA = { - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], - blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], - div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], - a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], - img: [ - ...(defaultSchema.attributes?.img ?? []), - "dataLocalSrc", - "dataMarkdownTitle", - "dataStandalone", - ], - }, - protocols: { - ...defaultSchema.protocols, - href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "t3-context"], - src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"], - }, -} satisfies Parameters[0]; - -const CHAT_MARKDOWN_REMARK_PLUGINS = [ - remarkGfm, - remarkGithubAlerts, - remarkNormalizeListItemIndentation, - remarkCodexDirectives, - remarkPreserveCodeMeta, - remarkNormalizeLinksAndTagInlineCode, -] satisfies NonNullable; - -const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ - remarkGfm, - remarkGithubAlerts, - remarkNormalizeListItemIndentation, - remarkCodexDirectives, - remarkBreaks, - remarkPreserveCodeMeta, - remarkNormalizeLinksAndTagInlineCode, -] satisfies NonNullable; - -const CHAT_MARKDOWN_REHYPE_PLUGINS = [ - rehypeRaw, - rehypePreserveImageSourceMeta, - [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], -] satisfies NonNullable; - /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, @@ -584,67 +465,6 @@ function extractPreCodeMeta(node: unknown): string | undefined { return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; } -type MarkdownAstNode = { - type?: string; - meta?: unknown; - url?: string; - data?: { - hProperties?: Record; - }; - children?: MarkdownAstNode[]; -}; - -function remarkPreserveCodeMeta() { - return (tree: MarkdownAstNode) => { - const visit = (node: MarkdownAstNode) => { - if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) { - node.data = { - ...node.data, - hProperties: { - ...node.data?.hProperties, - dataCodeMeta: node.meta.trim(), - }, - }; - } - node.children?.forEach(visit); - }; - - visit(tree); - }; -} - -/** - * Preserve Windows drive links as allowed `file:` URLs before sanitization. - * The same traversal tags inline code while it can still be distinguished - * from fenced code. Code inside links stays untagged to avoid nested anchors. - */ -function remarkNormalizeLinksAndTagInlineCode() { - return (tree: MarkdownAstNode) => { - const visit = (node: MarkdownAstNode, insideLink: boolean) => { - if ( - (node.type === "link" || node.type === "definition") && - typeof node.url === "string" && - WINDOWS_DRIVE_PATH_REGEX.test(node.url) - ) { - node.url = `file:///${node.url.replaceAll("\\", "/")}`; - } - if (node.type === "inlineCode" && !insideLink) { - node.data = { - ...node.data, - hProperties: { - ...node.data?.hProperties, - dataInlineCode: "", - }, - }; - } - const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; - node.children?.forEach((child) => visit(child, childInsideLink)); - }; - - visit(tree, false); - }; -} - function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -833,6 +653,8 @@ function MarkdownDetails({ open = false, }: Pick, "children" | "open">) { const [isOpen, setIsOpen] = useState(open); + const searching = use(MarkdownFindContext); + const expanded = searching || isOpen; const childNodes = Children.toArray(children); const summaryIndex = childNodes.findIndex( (child) => isValidElement(child) && child.type === "summary", @@ -846,11 +668,11 @@ function MarkdownDetails({ return ( -
+
segment.length > 0); - return segments.slice(0, -1); -} - -function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { - const groups = new Map>(); - for (const filePath of filePaths) { - const normalizedPath = filePath.replaceAll("\\", "/"); - const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); - const basename = pathSegments[pathSegments.length - 1]; - if (!basename) continue; - const group = groups.get(basename) ?? new Set(); - group.add(normalizedPath); - groups.set(basename, group); - } - - const suffixByPath = new Map(); - for (const group of groups.values()) { - const uniquePaths = [...group]; - if (uniquePaths.length < 2) continue; - - const parentSegmentsByPath = new Map( - uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), - ); - const minUniqueDepthByPath = new Map(); - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - let resolvedDepth = segments.length; - for (let depth = 1; depth <= segments.length; depth += 1) { - const candidate = segments.slice(-depth).join("/"); - const collision = uniquePaths.some((otherPath) => { - if (otherPath === filePath) return false; - const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; - return otherSegments.slice(-depth).join("/") === candidate; - }); - if (!collision) { - resolvedDepth = depth; - break; - } - } - minUniqueDepthByPath.set(filePath, resolvedDepth); - } - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - if (segments.length === 0) continue; - const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; - const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); - suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); - } - } - - return suffixByPath; -} - -const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; -const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; - -function extractInlineCodeSpans(text: string): string[] { - const spans: string[] = []; - const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); - for (let index = 0; index < segments.length; index += 2) { - for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { - const span = match[1]?.trim(); - if (span) spans.push(span); - } - } - return spans; -} - function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; @@ -2577,18 +2329,10 @@ function useChatMarkdownState({ className?: string, mediaSource?: string, ) => { - const parentSuffix = fileLinkParentSuffixByPath.get( - fileLinkMeta.filePath.replaceAll("\\", "/"), + const label = fileLinkLabel( + { ...fileLinkMeta, path: fileLinkMeta.filePath }, + fileLinkParentSuffixByPath, ); - const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } - if (fileLinkMeta.line) { - labelParts.push( - `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, - ); - } const mediaPath = mediaSource ?? fileLinkMeta.filePath; const canPreviewMedia = mediaMimeTypeFromExtension( @@ -2608,7 +2352,7 @@ function useChatMarkdownState({ displayPath={fileLinkMeta.displayPath} panelPath={panelPath} line={fileLinkMeta.line} - label={labelParts.join(" · ")} + label={label} copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} @@ -2779,7 +2523,10 @@ const CHAT_MARKDOWN_COMPONENTS = { // text under a colored title — which is how the host renders it. return (
-

+

{alert.label}

diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 799bd0bde7f4..962a1cf3dad4 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -19,7 +19,7 @@ import { environmentThreadDetails } from "../state/threads"; import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { type RightPanelSurface, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bc68a9fcfa01..2bede1ccb760 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -30,7 +30,7 @@ import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, type CodexArtifactTemplate, -} from "@t3tools/client-runtime/codex-artifact-templates"; +} from "@t3tools/shared/codexArtifactTemplates"; import { type ChatMessage, isImageAttachment, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 267d420c9d44..5db442a468e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { useThreadFind } from "./chat/useThreadFind"; import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; @@ -52,7 +53,7 @@ import { } from "@t3tools/client-runtime/errors"; import { readPastedComposerContext } from "./composerInlineTokenPaste"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; -import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import { type CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { parseCodexFeedbackCommand, @@ -371,6 +372,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; +import { ThreadFindBar } from "./chat/ThreadFindBar"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -1576,9 +1578,9 @@ export default function ChatView(props: ChatViewProps) { loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, cursor: routeThreadState.page._tag === "Some" ? routeThreadState.page.value.beforeCursor : null, - onLoadEarlier: () => { - requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); - }, + onLoadEarlier: () => + routeThreadState.status === "live" && + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId), }; }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); @@ -6636,6 +6638,15 @@ export default function ChatView(props: ChatViewProps) { }), [composerRef, previewPanelOpen, terminalUiState.terminalOpen], ); + const threadFind = useThreadFind({ + cwd: gitCwd ?? undefined, + thread: activeThreadRef, + serverSearch: isServerThread && serverConfig?.threadFind === true, + content: activeThread, + entries: timelineEntries, + history: loadEarlierTurns, + }); + const { isOpen: isThreadFindActive, open: openThreadFind, close: closeThreadFind } = threadFind; useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -6657,6 +6668,14 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } + // Dismiss find before resolving Escape's global thread.stop binding. + if (isThreadFindActive && event.key === "Escape") { + if (event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + event.stopPropagation(); + closeThreadFind(); + return; + } const shortcutContext = getShortcutContext(); if ( @@ -6734,6 +6753,13 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "chat.find") { + event.preventDefault(); + event.stopPropagation(); + openThreadFind(); + return; + } + if (command === "rightPanel.toggle") { event.preventDefault(); event.stopPropagation(); @@ -6916,6 +6942,9 @@ export default function ChatView(props: ChatViewProps) { confirmAndUnpinThread, copyActiveThreadReference, getShortcutContext, + openThreadFind, + closeThreadFind, + isThreadFindActive, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -9793,6 +9822,7 @@ export default function ChatView(props: ChatViewProps) { onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} + findBar={} /> @@ -9881,6 +9911,7 @@ export default function ChatView(props: ChatViewProps) { paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn } isRevertingCheckpoint={!paintOnlyDisplayedTimeline && isRevertingCheckpoint} + {...(!paintOnlyDisplayedTimeline ? threadFind.timelineProps : {})} onImageExpand={onExpandTimelineImage} onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} onFileDownload={ @@ -9926,7 +9957,7 @@ export default function ChatView(props: ChatViewProps) { /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} - {showScrollToBottom && ( + {showScrollToBottom && threadFind.timelineProps.searchEntries === null && (
, + shortcutCommand: "chat.find", + run: async () => { + requestThreadFindOpen(); + }, + }); + } + actionItems.push({ kind: "action", value: "action:add-project", diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b0609..1191032acccb 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -1,4 +1,5 @@ import { type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { splitThreadSearchText } from "@t3tools/shared/threadSearch"; import { ChevronRightIcon } from "lucide-react"; import { shortcutLabelForCommand } from "../keybindings"; import { @@ -16,45 +17,8 @@ import { } from "./ui/command"; import { cn } from "~/lib/utils"; -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - function HighlightedSearchText(props: { text: string; query: string }) { - const query = props.query.trim(); - if (query.length === 0) return props.text; - - const normalizedText = foldAsciiCase(props.text); - const normalizedQuery = foldAsciiCase(query); - const parts: Array<{ - readonly text: string; - readonly highlighted: boolean; - readonly start: number; - }> = []; - let cursor = 0; - - while (cursor < props.text.length) { - const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); - if (matchIndex === -1) { - parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor }); - break; - } - if (matchIndex > cursor) { - parts.push({ - text: props.text.slice(cursor, matchIndex), - highlighted: false, - start: cursor, - }); - } - parts.push({ - text: props.text.slice(matchIndex, matchIndex + query.length), - highlighted: true, - start: matchIndex, - }); - cursor = matchIndex + query.length; - } - - return parts.map((part) => + return splitThreadSearchText(props.text, props.query).map((part) => part.highlighted ? ( {part.text} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 99db055b667b..24c5ca928233 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -101,7 +101,7 @@ import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; import { randomUUID } from "~/lib/utils"; -import { resolvePathLinkTarget } from "~/terminal-links"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useOpenLink } from "~/browser/useOpenLink"; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91c7cc855596..c428b45103dd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -57,7 +57,8 @@ import { } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { isTerminalUrl } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8f91f08c7093..72315b4730d0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -938,7 +938,7 @@ import { ShieldIcon, XIcon, } from "lucide-react"; -import { proposedPlanTitle } from "../../proposedPlan"; +import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; import { applyProviderInstanceSettings, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fbebc323a950..7fc9078a52c1 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -19,6 +19,7 @@ import { useMemo, useRef, useState, + type ReactNode, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; @@ -66,6 +67,7 @@ interface ChatHeaderProps { readonly onOpenPullRequest?: ((number: number) => void) | undefined; onNewThreadInProject: () => void; onOpenProjectSettings?: (() => void) | undefined; + findBar: ReactNode; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -139,6 +141,7 @@ export const ChatHeader = memo(function ChatHeader({ onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, + findBar, }: ChatHeaderProps) { const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); @@ -314,7 +317,7 @@ export const ChatHeader = memo(function ChatHeader({ ); return (
)}
+ {findBar}
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 57ed45a89d1d..bebb13adb8d4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -2,7 +2,7 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu export { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as Equal from "effect/Equal"; import { shallow } from "zustand/vanilla/shallow"; -import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; +import { renderCodexDirectivesForCopy } from "@t3tools/shared/codexMarkdownDirectives"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; import { liveActivityToolStatus, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 03775a22f109..e8d0c3546f03 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import { MarkdownFindContext } from "./markdownFindContext"; import { ArrowUpIcon, ClockIcon } from "lucide-react"; import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -24,7 +25,7 @@ import { } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; -import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; +import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { resolveWorkEntryToolPresentation, resolveViewedImageAsset, @@ -197,6 +198,8 @@ import { type TimelineLatestTurn, type WorkGroupScrollAnchor, } from "./MessagesTimeline.logic"; +import { type ThreadFindMatch } from "./threadFind"; +import { useThreadFindHighlights } from "./threadFindHighlights"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Spinner } from "../ui/spinner"; @@ -277,6 +280,7 @@ interface TimelineRowSharedState { activeThreadEnvironmentId: EnvironmentId; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; + findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; onFileDownload: (attachment: ChatFileAttachment) => void; @@ -386,6 +390,7 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { ...TIMELINE_MAINTAIN_SCROLL_AT_END, animated: true, } as const satisfies MaintainScrollAtEndOptions; +const FIND_MATCH_VIEW_MARGIN = 96; // --------------------------------------------------------------------------- // Props (public API) @@ -463,13 +468,71 @@ interface MessagesTimelineProps { onSteerQueuedMessage?: (id: string) => void; steerQueuedMessageShortcutLabel?: string | null; onRemoveQueuedMessage?: (id: string) => void; + searchEntries?: ReturnType | null; + onCloseSearch?: () => void; + findQuery?: string; + activeFindMatch?: ThreadFindMatch | null; + findNavigationId?: number; } // --------------------------------------------------------------------------- // MessagesTimeline — list owner // --------------------------------------------------------------------------- -export const MessagesTimeline = memo(function MessagesTimeline({ +export function MessagesTimeline(props: MessagesTimelineProps) { + const searchListRef = useRef(null); + const searchEntries = props.searchEntries; + const searching = searchEntries != null; + return ( +
+
+ +
+ {searchEntries && ( + <> +
+ Search result · nearby messages + +
+ + + )} +
+ ); +} + +const ConversationTimeline = memo(function ConversationTimeline({ citationRequest = null, citationHistoryLoading = false, onCiteAssistantText, @@ -520,6 +583,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onSteerQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, steerQueuedMessageShortcutLabel = null, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, + findQuery = "", + activeFindMatch = null, + findNavigationId = 0, }: MessagesTimelineProps) { const listIdentityKey = displayThreadKey ?? routeThreadKey; const rememberedPosition = useMemo( @@ -597,6 +663,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); + const normalizedFindQuery = findQuery.trim(); + const findActive = normalizedFindQuery.length > 0; + useEffect(() => { return () => { if (disclosureSettleFrameRef.current !== null) { @@ -736,6 +805,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); }, [latestTurn]); + const activeFindTurnId = activeFindMatch?.turnId; + const visibleExpandedTurnIds = useMemo(() => { + if (!activeFindTurnId || paintedExpandedTurnIds.has(activeFindTurnId)) { + return paintedExpandedTurnIds; + } + return new Set(paintedExpandedTurnIds).add(activeFindTurnId); + }, [activeFindTurnId, paintedExpandedTurnIds]); + const rowsProjectionRef = useRef<{ threadKey: string; workspaceRoot: string | undefined; @@ -771,7 +848,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - expandedTurnIds: paintedExpandedTurnIds, + expandedTurnIds: visibleExpandedTurnIds, expandedWorkGroupIds: paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, @@ -794,7 +871,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - paintedExpandedTurnIds, + visibleExpandedTurnIds, paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, @@ -1138,6 +1215,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeThreadEnvironmentId, onRevertToTurnCount, onUseArtifactTemplate, + findActive, onImageExpand, onFileOpen, onFileDownload, @@ -1173,6 +1251,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ activeThreadEnvironmentId, onRevertToTurnCount, onUseArtifactTemplate, + findActive, onImageExpand, onFileOpen, onFileDownload, @@ -1230,6 +1309,71 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); + const activeFindMatchKey = activeFindMatch + ? `${findNavigationId}:${normalizedFindQuery}:${activeFindMatch.entryId}:${activeFindMatch.occurrence}` + : null; + const revealActiveFindRange = useCallback( + (range: Range | null) => { + if (!range) return; + + const codeScroller = range.startContainer.parentElement?.closest("pre"); + if (codeScroller) { + const match = range.getBoundingClientRect(); + const viewport = codeScroller.getBoundingClientRect(); + if (match.left < viewport.left) codeScroller.scrollLeft += match.left - viewport.left - 16; + else if (match.right > viewport.right) + codeScroller.scrollLeft += match.right - viewport.right + 16; + } + const matchRect = range.getBoundingClientRect(); + const viewportRect = timelineViewportElement?.getBoundingClientRect(); + if (!viewportRect || matchRect.height === 0) return; + + const topBoundary = viewportRect.top + FIND_MATCH_VIEW_MARGIN; + const bottomBoundary = + viewportRect.bottom - FIND_MATCH_VIEW_MARGIN - contentInsetEndAdjustment; + let delta = 0; + if (matchRect.top < topBoundary) delta = matchRect.top - topBoundary; + else if (matchRect.bottom > bottomBoundary) delta = matchRect.bottom - bottomBoundary; + if (Math.abs(delta) < 1) return; + + const currentScroll = listRef.current?.getState?.().scroll; + if (typeof currentScroll === "number") { + listRef.current?.scrollToOffset({ offset: currentScroll + delta, animated: false }); + } + }, + [contentInsetEndAdjustment, listRef, timelineViewportElement], + ); + + const navigatedFindMatchKeyRef = useRef(null); + + useEffect(() => { + if (!activeFindMatch || !activeFindMatchKey) { + navigatedFindMatchKeyRef.current = null; + return; + } + + const rowIndex = rows.findIndex((row) => row.id === activeFindMatch.entryId); + if (rowIndex === -1) return; + + if (navigatedFindMatchKeyRef.current === activeFindMatchKey) return; + navigatedFindMatchKeyRef.current = activeFindMatchKey; + + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: rowIndex, + animated: false, + viewOffset: FIND_MATCH_VIEW_MARGIN, + }); + }, [activeFindMatch, activeFindMatchKey, listRef, onManualNavigation, rows]); + + useThreadFindHighlights({ + container: timelineViewportElement, + query: normalizedFindQuery, + activeRowId: activeFindMatch?.entryId ?? null, + activeOccurrence: activeFindMatch?.occurrence ?? 0, + onActiveRange: revealActiveFindRange, + }); + // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( @@ -1255,92 +1399,94 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } return ( - - -
- {onCiteAssistantText && citationThreadRef ? ( - 0}> + + +
+ {onCiteAssistantText && citationThreadRef ? ( + + ) : null} + + ref={listRef} + data={rows} + extraData={`${listIdentityKey}:${rows.length}`} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} + // Legend needs a data refresh to mount new pins without a scroll event. + dataVersion={readyCitationRequest?.key ?? listIdentityKey} + {...(alwaysRender ? { alwaysRender } : {})} + onLoad={onCitationListLoad} + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} + maintainScrollAtEnd={ + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || + anchoredEndSpace || + !liveFollowEnabled || + disclosureToggleSettling + ? false + : isWorking && !prefersReducedMotion && settlingListIdentity === null + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END + } + maintainVisibleContentPosition={ + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition + } + maintainScrollAtEndThreshold={1} + onScroll={handleScroll} + onItemSizeChanged={reportContentOverflow} + className={cn( + "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", + topFadeEnabled && "topbar-scroll-fade", + )} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } + ListFooterComponent={timelineListFooter} /> - ) : null} - - ref={listRef} - data={rows} - extraData={`${listIdentityKey}:${rows.length}`} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} - // Legend needs a data refresh to mount new pins without a scroll event. - dataVersion={readyCitationRequest?.key ?? listIdentityKey} - {...(alwaysRender ? { alwaysRender } : {})} - onLoad={onCitationListLoad} - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} - maintainScrollAtEnd={ - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) || - anchoredEndSpace || - !liveFollowEnabled || - disclosureToggleSettling - ? false - : isWorking && !prefersReducedMotion && settlingListIdentity === null - ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH - : TIMELINE_MAINTAIN_SCROLL_AT_END - } - maintainVisibleContentPosition={ - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) - ? false - : maintainVisibleContentPosition - } - maintainScrollAtEndThreshold={1} - onScroll={handleScroll} - onItemSizeChanged={reportContentOverflow} - className={cn( - "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "topbar-scroll-fade", - )} - ListHeaderComponent={ - loadEarlier !== null ? ( - - ) : topFadeEnabled ? ( - TIMELINE_LIST_FADE_HEADER - ) : ( - TIMELINE_LIST_HEADER - ) - } - ListFooterComponent={timelineListFooter} - /> - { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - }} - /> -
-
-
+ { + onManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + }} + /> +
+
+
+ ); }); @@ -2051,21 +2197,23 @@ function UserTimelineRow({ row }: { row: Extract file.id === record.attachmentId) ?? null) : null; return ( - { - const preview = buildExpandedImagePreview(userImages, image.id); - if (preview) onImageExpand(preview); - }} - onOpenFile={onFileOpen} - onExpandVideo={(file) => { - const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); - if (preview) onImageExpand(preview); - }} - /> + + { + const preview = buildExpandedImagePreview(userImages, image.id); + if (preview) onImageExpand(preview); + }} + onOpenFile={onFileOpen} + onExpandVideo={(file) => { + const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); + if (preview) onImageExpand(preview); + }} + /> + ); }, [ @@ -2188,6 +2336,7 @@ function UserTimelineRow({ row }: { row: Extract
T3 Code - - - +
+ + + +
); @@ -3941,12 +4093,14 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; skills: ReadonlyArray>; markdownCwd: string | undefined; + expandForFind?: boolean; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); const hasVisibleBody = props.text.trim().length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); - const isCollapsed = canCollapse && !expanded; + const isCollapsed = canCollapse && !expanded && !props.expandForFind; + const showCollapseControl = canCollapse && !props.expandForFind; return (
@@ -3954,6 +4108,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
) : null} - {canCollapse || props.footer ? ( + {showCollapseControl || props.footer ? (
- {canCollapse ? ( + {showCollapseControl ? (
-
- {canCollapse && !expanded ? ( +
+ {isCollapsed ? ( )} - {canCollapse && !expanded ? ( + {isCollapsed ? (
) : null}
- {canCollapse ? ( + {canCollapse && !expandForFind ? (
+ ) : null} + + + + + + ); +} diff --git a/apps/web/src/components/chat/markdownFindContext.ts b/apps/web/src/components/chat/markdownFindContext.ts new file mode 100644 index 000000000000..f765c9e1b716 --- /dev/null +++ b/apps/web/src/components/chat/markdownFindContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +// Temporarily reveal Markdown disclosures while finding; preserve the user's fold state. +export const MarkdownFindContext = createContext(false); diff --git a/apps/web/src/components/chat/threadFind.test.ts b/apps/web/src/components/chat/threadFind.test.ts new file mode 100644 index 000000000000..a40e55534299 --- /dev/null +++ b/apps/web/src/components/chat/threadFind.test.ts @@ -0,0 +1,252 @@ +import { MessageId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import type { TimelineEntry } from "../../session-logic"; +import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic"; +import { + buildThreadFindMatches, + clampThreadFindIndex, + formatThreadFindCount, + stepThreadFindIndex, +} from "./threadFind"; + +const CREATED_AT = "2026-01-01T00:00:00.000Z"; + +function messageEntry( + id: string, + role: "user" | "assistant" | "system", + text: string, + turnId: TurnId | null = null, +): TimelineEntry { + return { + id, + kind: "message", + createdAt: CREATED_AT, + message: { + id: MessageId.make(id), + role, + text, + turnId, + streaming: false, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }, + }; +} + +function workEntry(id: string): TimelineEntry { + return { + id, + kind: "work", + createdAt: CREATED_AT, + entry: { + id, + label: "deploy sentinel", + tone: "tool", + createdAt: CREATED_AT, + }, + }; +} + +function proposedPlanEntry(id: string, planMarkdown: string, turnId: TurnId | null): TimelineEntry { + return { + id, + kind: "proposed-plan", + createdAt: CREATED_AT, + proposedPlan: { + id, + turnId, + planMarkdown, + implementedAt: null, + implementationThreadId: null, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }, + }; +} + +describe("searchable thread entries", () => { + it("searches displayed user text without appended context payloads", () => { + const prompt = [ + "check the build", + "", + "", + "- Terminal 1 line 12:", + " secret sentinel output", + "", + ].join("\n"); + + const entries = [messageEntry("m1", "user", prompt)]; + expect(buildThreadFindMatches(entries, "check the build")).toHaveLength(1); + expect(buildThreadFindMatches(entries, "sentinel")).toHaveLength(0); + }); + + it("excludes terminal labels that render as non-searchable chips", () => { + const prompt = [ + "check @terminal-1:12", + "", + "", + "- Terminal 1 line 12:", + " 12 | output", + "", + ].join("\n"); + + const entries = [messageEntry("m1", "user", prompt)]; + expect(buildThreadFindMatches(entries, "check")).toHaveLength(1); + expect(buildThreadFindMatches(entries, "@terminal-1:12")).toHaveLength(0); + }); + + it("keeps repeated terminal labels that are still visible after the chip", () => { + const prompt = + "check @terminal-1:12 and @terminal-1:12\n\n\n- Terminal 1 line 12:\n 12 | output\n"; + expect( + buildThreadFindMatches([messageEntry("m1", "user", prompt)], "@terminal-1:12"), + ).toHaveLength(1); + }); + + it("excludes terminal chips even when their labels are out of context order", () => { + const prompt = + "@terminal-2:12 then @terminal-1:12\n\n\n- Terminal 1 line 12:\n 12 | first\n- Terminal 2 line 12:\n 12 | second\n"; + expect(buildThreadFindMatches([messageEntry("m1", "user", prompt)], "@terminal-")).toHaveLength( + 0, + ); + }); + + it.each(["user", "assistant"] as const)( + "searches rendered %s Markdown, not link destinations or formatting", + (role) => { + const entries = [ + messageEntry( + "m1", + role, + "[documentation](https://hidden.example/path) foo**bar** and `inline code`", + ), + ]; + expect(buildThreadFindMatches(entries, "hidden.example")).toHaveLength(0); + expect(buildThreadFindMatches(entries, "documentation")).toHaveLength(1); + expect(buildThreadFindMatches(entries, "foobar")).toHaveLength(1); + expect(buildThreadFindMatches(entries, "inline code")).toHaveLength(1); + }, + ); + + it("searches code, escaped punctuation, entities and sanitized HTML as displayed", () => { + const entries = [ + messageEntry( + "m1", + "assistant", + "```ts\nconst value = 1;\n```\n\n\\*literal\\* & bold", + ), + ]; + for (const query of ["const value", "*literal* & bold"]) { + expect(buildThreadFindMatches(entries, query)).toHaveLength(1); + } + for (const query of ["hidden", "strong", "```ts"]) { + expect(buildThreadFindMatches(entries, query)).toHaveLength(0); + } + }); + + it("preserves literal HTML in user messages", () => { + expect( + buildThreadFindMatches([messageEntry("m1", "user", "bold")], ""), + ).toHaveLength(1); + }); + + it("searches plan titles before body matches, including the default title", () => { + const entries = [proposedPlanEntry("p1", "# Release\n\n## Summary\n\nRelease **ready**", null)]; + expect(buildThreadFindMatches(entries, "Release").map((match) => match.occurrence)).toEqual([ + 0, 1, + ]); + expect(buildThreadFindMatches(entries, "Summary")).toHaveLength(0); + expect( + buildThreadFindMatches([proposedPlanEntry("p2", "Body", null)], "Proposed plan"), + ).toHaveLength(1); + }); + + it("does not join separate blocks or the plan title and body into a phrase", () => { + expect( + buildThreadFindMatches([messageEntry("m1", "assistant", "first\n\nsecond")], "firstsecond"), + ).toHaveLength(0); + expect( + buildThreadFindMatches([proposedPlanEntry("p1", "# first\n\nsecond", null)], "firstsecond"), + ).toHaveLength(0); + }); + + it("indexes the rendered placeholder for empty assistant responses", () => { + expect( + buildThreadFindMatches([messageEntry("m1", "assistant", "")], "(empty response)"), + ).toHaveLength(1); + }); + + it("skips work rows and system messages", () => { + expect( + buildThreadFindMatches( + [workEntry("w1"), messageEntry("s1", "system", "sentinel")], + "sentinel", + ), + ).toHaveLength(0); + }); + + it("uses the displayed proposed-plan title and body", () => { + const entries = [proposedPlanEntry("p1", "# Visible title\n\n## Summary\n\nship it", null)]; + expect(buildThreadFindMatches(entries, "Visible title")).toHaveLength(1); + expect(buildThreadFindMatches(entries, "ship it")).toHaveLength(1); + expect(buildThreadFindMatches(entries, "Summary")).toHaveLength(0); + }); +}); + +describe("buildThreadFindMatches", () => { + it("carries turn ownership for folded messages and plans", () => { + const turnId = TurnId.make("turn-1"); + const entries = [ + messageEntry("m1", "assistant", "deploy twice: deploy", turnId), + proposedPlanEntry("p1", "deploy the plan", turnId), + ]; + + expect(buildThreadFindMatches(entries, "deploy")).toEqual([ + { entryId: "m1", turnId, occurrence: 0 }, + { entryId: "m1", turnId, occurrence: 1 }, + { entryId: "p1", turnId, occurrence: 0 }, + ]); + }); + + it("reveals a search result inside a settled turn and restores its fold afterward", () => { + const turnId = TurnId.make("settled-turn"); + const entries = [ + messageEntry("prompt", "user", "Check the release"), + messageEntry("progress", "assistant", "Found the sentinel", turnId), + messageEntry("final", "assistant", "Done", turnId), + ]; + const input = { + timelineEntries: entries, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }; + const collapsed = deriveMessagesTimelineRows(input); + expect(collapsed.some((row) => row.id === "progress")).toBe(false); + + const match = buildThreadFindMatches(entries, "sentinel")[0]!; + const expanded = deriveMessagesTimelineRows({ + ...input, + expandedTurnIds: new Set(match.turnId ? [match.turnId] : []), + }); + expect(expanded.some((row) => row.id === match.entryId)).toBe(true); + expect(deriveMessagesTimelineRows(input).map((row) => row.id)).toEqual( + collapsed.map((row) => row.id), + ); + }); + + it("ignores blank queries", () => { + expect(buildThreadFindMatches([messageEntry("m1", "user", "deploy")], " ")).toEqual([]); + }); +}); + +describe("thread find navigation", () => { + it("clamps, wraps, and formats positions", () => { + expect(clampThreadFindIndex(4, 2)).toBe(1); + expect(stepThreadFindIndex(2, 3, 1)).toBe(0); + expect(stepThreadFindIndex(0, 3, -1)).toBe(2); + expect(formatThreadFindCount(4, 2)).toBe("2/2"); + expect(formatThreadFindCount(0, 0)).toBe("0/0"); + }); +}); diff --git a/apps/web/src/components/chat/threadFind.ts b/apps/web/src/components/chat/threadFind.ts new file mode 100644 index 000000000000..861442995fba --- /dev/null +++ b/apps/web/src/components/chat/threadFind.ts @@ -0,0 +1,85 @@ +import type { TurnId } from "@t3tools/contracts"; +import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import type { TimelineEntry } from "../../session-logic"; +import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText"; + +/** One occurrence of the query inside a searchable timeline entry. */ +export interface ThreadFindMatch { + readonly entryId: string; + /** The turn to expand when the matching entry is folded. */ + readonly turnId: TurnId | null; + /** Zero-based occurrence within this timeline entry. */ + readonly occurrence: number; +} + +// Message/plan records are immutable and survive timeline rebuilds during streaming. +// Weak keys reuse parsed text across keystrokes without retaining old messages. +const entryTextCache = new WeakMap< + object, + { cwd: string | undefined; segments: readonly string[] | null } +>(); + +function searchableThreadEntrySegments( + entry: TimelineEntry, + cwd?: string, +): readonly string[] | null { + if (entry.kind !== "message" && entry.kind !== "proposed-plan") return null; + const key = entry.kind === "message" ? entry.message : entry.proposedPlan; + const cached = entryTextCache.get(key); + if (cached && cached.cwd === cwd) return cached.segments; + const segments = + entry.kind === "message" + ? searchableMessageSegments(entry.message, cwd) + : searchablePlanSegments(entry.proposedPlan.planMarkdown, cwd); + entryTextCache.set(key, { cwd, segments }); + return segments; +} + +function threadEntryTurnId(entry: TimelineEntry): TurnId | null { + if (entry.kind === "message") return entry.message.turnId ?? null; + if (entry.kind === "proposed-plan") return entry.proposedPlan.turnId; + return null; +} + +export function buildThreadFindMatches( + entries: ReadonlyArray, + query: string, + cwd?: string, +): ThreadFindMatch[] { + const normalizedQuery = query.trim(); + if (normalizedQuery.length === 0) return []; + + const matches: ThreadFindMatch[] = []; + for (const entry of entries) { + const segments = searchableThreadEntrySegments(entry, cwd); + if (segments === null) continue; + + const total = segments.reduce( + (count, text) => count + countThreadSearchOccurrences(text, normalizedQuery), + 0, + ); + for (let occurrence = 0; occurrence < total; occurrence += 1) { + matches.push({ + entryId: entry.id, + turnId: threadEntryTurnId(entry), + occurrence, + }); + } + } + return matches; +} + +export function clampThreadFindIndex(index: number, total: number): number { + if (total <= 0 || !Number.isFinite(index) || index < 0) return 0; + return Math.min(Math.trunc(index), total - 1); +} + +export function stepThreadFindIndex(index: number, total: number, delta: number): number { + if (total <= 0) return 0; + const clamped = clampThreadFindIndex(index, total); + return (((clamped + delta) % total) + total) % total; +} + +export function formatThreadFindCount(index: number, total: number): string { + return total <= 0 ? "0/0" : `${clampThreadFindIndex(index, total) + 1}/${total}`; +} diff --git a/apps/web/src/components/chat/threadFindActionBus.ts b/apps/web/src/components/chat/threadFindActionBus.ts new file mode 100644 index 000000000000..375610fbc077 --- /dev/null +++ b/apps/web/src/components/chat/threadFindActionBus.ts @@ -0,0 +1,10 @@ +const THREAD_FIND_OPEN_EVENT = "t3:thread-find-open"; + +export function requestThreadFindOpen(): void { + window.dispatchEvent(new Event(THREAD_FIND_OPEN_EVENT)); +} + +export function subscribeThreadFindOpen(listener: () => void): () => void { + window.addEventListener(THREAD_FIND_OPEN_EVENT, listener); + return () => window.removeEventListener(THREAD_FIND_OPEN_EVENT, listener); +} diff --git a/apps/web/src/components/chat/threadFindHighlights.ts b/apps/web/src/components/chat/threadFindHighlights.ts new file mode 100644 index 000000000000..c899db21fe29 --- /dev/null +++ b/apps/web/src/components/chat/threadFindHighlights.ts @@ -0,0 +1,139 @@ +import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import { useEffect } from "react"; +import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText"; + +const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; +const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; + +const THREAD_FIND_TEXT_SELECTOR = "[data-thread-find-text]"; +const THREAD_FIND_IGNORE_SELECTOR = "[data-thread-find-ignore]"; + +interface ThreadFindRange { + readonly rowId: string; + readonly occurrence: number; + readonly range: Range; +} + +/** Collects visible occurrences without modifying rendered markdown. */ +function collectThreadFindRanges(container: HTMLElement, query: string): ThreadFindRange[] { + if (query.length === 0) return []; + + const ranges: ThreadFindRange[] = []; + const occurrenceByRowId = new Map(); + + for (const scope of container.querySelectorAll(THREAD_FIND_TEXT_SELECTOR)) { + if (scope.parentElement?.closest(THREAD_FIND_TEXT_SELECTOR)) continue; + const rowId = scope.closest("[data-timeline-row-id]")?.getAttribute("data-timeline-row-id"); + if (!rowId) continue; + + let text = ""; + let nodes: { node: Node; start: number; end: number }[] = []; + const flush = () => { + for (const offset of findThreadSearchOccurrences(text, query)) { + const start = nodes.find((part) => part.end > offset); + const end = nodes.find((part) => part.end >= offset + query.length); + if (!start || !end) continue; + const range = container.ownerDocument.createRange(); + range.setStart(start.node, offset - start.start); + range.setEnd(end.node, offset + query.length - end.start); + const occurrence = occurrenceByRowId.get(rowId) ?? 0; + ranges.push({ rowId, occurrence, range }); + occurrenceByRowId.set(rowId, occurrence + 1); + } + text = ""; + nodes = []; + }; + const visit = (node: Node, inPre = false) => { + const element = node.nodeType === 1 ? (node as Element) : null; + if (element?.matches("svg")) return; + if (element?.matches(`${THREAD_FIND_IGNORE_SELECTOR}, [role="toolbar"]`)) { + flush(); + return; + } + const tag = element?.tagName.toLowerCase() ?? ""; + const block = THREAD_FIND_BLOCK_TAGS.has(tag); + if (block) flush(); + if (node.nodeType === 3) { + const value = node.nodeValue ?? ""; + const start = text.length; + text += inPre ? value : value.replace(/\n/g, " "); + nodes.push({ node, start, end: text.length }); + } + for (const child of node.childNodes) visit(child, inPre || tag === "pre"); + if (block) flush(); + }; + visit(scope); + flush(); + } + return ranges; +} + +export function useThreadFindHighlights(input: { + readonly container: HTMLElement | null; + readonly query: string; + readonly activeRowId: string | null; + readonly activeOccurrence: number; + readonly onActiveRange: (range: Range | null) => void; +}): void { + const { container, query, activeRowId, activeOccurrence, onActiveRange } = input; + + useEffect(() => { + if (typeof CSS === "undefined" || !CSS.highlights || typeof Highlight === "undefined") { + onActiveRange(null); + return; + } + const clearHighlights = () => { + CSS.highlights.delete(THREAD_FIND_HIGHLIGHT_NAME); + CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME); + }; + if (!container || query.length === 0) { + onActiveRange(null); + clearHighlights(); + return; + } + + const repaint = () => { + let active: Range | null = null; + const inactive: Range[] = []; + for (const match of collectThreadFindRanges(container, query)) { + if ( + active === null && + match.rowId === activeRowId && + match.occurrence === activeOccurrence + ) { + active = match.range; + } else { + inactive.push(match.range); + } + } + onActiveRange(active); + CSS.highlights.set(THREAD_FIND_HIGHLIGHT_NAME, new Highlight(...inactive)); + CSS.highlights.set( + THREAD_FIND_ACTIVE_HIGHLIGHT_NAME, + new Highlight(...(active ? [active] : [])), + ); + }; + repaint(); + + let frame: number | null = null; + const observer = new MutationObserver(() => { + if (frame !== null) return; + frame = requestAnimationFrame(() => { + frame = null; + repaint(); + }); + }); + observer.observe(container, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["data-wrap"], + }); + return () => { + observer.disconnect(); + if (frame !== null) cancelAnimationFrame(frame); + clearHighlights(); + }; + }, [activeOccurrence, activeRowId, container, onActiveRange, query]); +} diff --git a/apps/web/src/components/chat/useThreadFind.ts b/apps/web/src/components/chat/useThreadFind.ts new file mode 100644 index 000000000000..d11ad7d40754 --- /dev/null +++ b/apps/web/src/components/chat/useThreadFind.ts @@ -0,0 +1,175 @@ +import type { OrchestrationThread, ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { deriveTimelineEntries, type TimelineEntry } from "~/session-logic"; +import { orchestrationEnvironment } from "~/state/orchestration"; +import { useEnvironmentQuery } from "~/state/query"; +import { useDebouncedValue } from "~/state/queries"; +import { buildThreadFindMatches, clampThreadFindIndex, stepThreadFindIndex } from "./threadFind"; +import { subscribeThreadFindOpen } from "./threadFindActionBus"; +import { useThreadFindHistory } from "./useThreadFindHistory"; + +const CLOSED_FIND = { + threadKey: null as string | null, + query: "", + activeIndex: 0, + focusRequestId: 0, + navigationId: 0, +}; + +/** Owns find state and presents the same controls for server search and older-server history. */ +export function useThreadFind({ + thread, + serverSearch, + cwd, + content, + entries, + history, +}: { + thread: ScopedThreadRef | null; + serverSearch: boolean; + cwd: string | undefined; + content: Pick | undefined; + entries: ReadonlyArray; + history: Parameters[1]; +}) { + const threadKey = thread ? scopedThreadKey(thread) : null; + const [state, setState] = useState(CLOSED_FIND); + const isOpen = threadKey !== null && state.threadKey === threadKey; + const open = useCallback(() => { + if (threadKey === null) return; + setState((previous) => ({ + ...(previous.threadKey === threadKey ? previous : CLOSED_FIND), + threadKey, + focusRequestId: previous.focusRequestId + 1, + })); + }, [threadKey]); + const close = useCallback(() => setState(CLOSED_FIND), []); + useEffect(() => subscribeThreadFindOpen(open), [open]); + + const remote = useServerResults( + serverSearch && isOpen ? thread : null, + state.query, + state.activeIndex, + content, + ); + const localStatus = useThreadFindHistory( + !serverSearch && isOpen && state.query.trim() ? `${threadKey}:${state.focusRequestId}` : null, + history, + ); + let status: "loading" | "incomplete" | "error" | null = localStatus; + if (serverSearch) { + status = null; + if (remote.isPending) status = "loading"; + if (remote.error) status = "error"; + } + const localMatches = useMemo( + () => + buildThreadFindMatches( + entries, + !serverSearch && isOpen && status !== "loading" ? state.query : "", + cwd, + ), + [cwd, entries, isOpen, serverSearch, state.query, status], + ); + const count = serverSearch ? (remote.data?.totalMatches ?? 0) : localMatches.length; + const activeIndex = serverSearch + ? (remote.data?.activeIndex ?? 0) + : clampThreadFindIndex(state.activeIndex, count); + const searchEntries = useMemo( + () => + remote.data?.match + ? deriveTimelineEntries(remote.data.messages, remote.data.proposedPlans, []) + : null, + [remote.data], + ); + const selected = remote.data?.match; + const activeMatch = serverSearch + ? selected && { + entryId: selected.sourceId, + turnId: selected.turnId, + occurrence: selected.occurrence, + } + : localMatches[activeIndex]; + const step = (delta: number) => + setState((previous) => ({ + ...previous, + activeIndex: stepThreadFindIndex(previous.activeIndex, count, delta), + navigationId: previous.navigationId + 1, + })); + + return { + isOpen, + open, + close, + barProps: { + open: isOpen, + query: state.query, + matchCount: count, + activeIndex, + historyState: status, + focusRequestId: state.focusRequestId, + onRetryHistory: serverSearch ? remote.refresh : open, + onQueryChange: (query: string) => + setState((previous) => ({ ...previous, query, activeIndex: 0 })), + onNext: () => step(1), + onPrevious: () => step(-1), + onClose: close, + }, + timelineProps: { + searchEntries, + onCloseSearch: close, + findQuery: isOpen && (!serverSearch || searchEntries !== null) ? state.query : "", + activeFindMatch: activeMatch ?? null, + findNavigationId: state.navigationId, + }, + }; +} + +/** Query atoms cancel obsolete requests; navigation retains only the current query's result. */ +function useServerResults( + thread: ScopedThreadRef | null, + query: string, + index: number, + content: Pick | undefined, +) { + const normalizedQuery = query.trim(); + const debouncedQuery = useDebouncedValue(normalizedQuery, 200); + const atom = + thread && debouncedQuery && normalizedQuery === debouncedQuery + ? orchestrationEnvironment.threadFind({ + environmentId: thread.environmentId, + input: { threadId: thread.threadId, query: debouncedQuery, index }, + }) + : null; + const result = useEnvironmentQuery(atom); + const { refresh } = result; + const messages = content?.messages; + const plans = content?.proposedPlans; + const revision = useMemo(() => ({ messages, plans }), [messages, plans]); + const settledRevision = useDebouncedValue(revision, 300); + const lastRevision = useRef(settledRevision); + useEffect(() => { + if (lastRevision.current === settledRevision) return; + lastRevision.current = settledRevision; + if (atom !== null) refresh(); + }, [atom, refresh, settledRevision]); + const key = thread + ? JSON.stringify([thread.environmentId, thread.threadId, normalizedQuery]) + : null; + const [previous, setPrevious] = useState({ key, data: result.data }); + if ( + previous.key !== key || + (result.data !== null && !result.isPending && previous.data !== result.data) + ) { + setPrevious({ key, data: result.data }); + } + return { + ...result, + data: key === previous.key && !result.error ? (result.data ?? previous.data) : null, + isPending: + thread !== null && + normalizedQuery.length > 0 && + (normalizedQuery !== debouncedQuery || result.isPending), + }; +} diff --git a/apps/web/src/components/chat/useThreadFindHistory.test.tsx b/apps/web/src/components/chat/useThreadFindHistory.test.tsx new file mode 100644 index 000000000000..20ea1b30a48a --- /dev/null +++ b/apps/web/src/components/chat/useThreadFindHistory.test.tsx @@ -0,0 +1,104 @@ +import { act, StrictMode, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { useThreadFindHistory } from "./useThreadFindHistory"; + +type CitationHistoryPage = NonNullable[1]>; + +let renderer: ReactTestRenderer; +let status: ReturnType; + +function Probe({ + requestKey, + page, +}: { + requestKey: string | null; + page: CitationHistoryPage | null; +}) { + const value = useThreadFindHistory(requestKey, page); + useLayoutEffect(() => { + status = value; + }, [value]); + return null; +} + +async function render(requestKey: string | null, page: CitationHistoryPage | null) { + await act(() => { + renderer.update( + + + , + ); + }); +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("thread find history loading", () => { + it("loads sequential pages without duplicate requests and finishes only at the oldest page", async () => { + const load = vi.fn(() => true); + const page = { cursor: "recent", loading: false, onLoadEarlier: load }; + await render("thread:1", page); + await render("thread:1", { ...page }); + expect(load).toHaveBeenCalledTimes(1); + expect(status).toBe("loading"); + + await render("thread:1", { ...page, loading: true }); + expect(status).toBe("loading"); + expect(load).toHaveBeenCalledTimes(1); + + await render("thread:1", { ...page, cursor: "older" }); + expect(load).toHaveBeenCalledTimes(2); + expect(status).not.toBeNull(); + await render("thread:1", null); + expect(status).toBeNull(); + }); + + it("does not loop on a failed page and allows an explicit retry", async () => { + const load = vi.fn(() => true); + const page = { cursor: "recent", loading: false, onLoadEarlier: load }; + await render("thread:1", page); + await render("thread:1", { ...page, loading: true }); + await render("thread:1", { ...page }); + expect(status).toBe("incomplete"); + expect(load).toHaveBeenCalledTimes(1); + + await render("thread:2", page); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("offers retry if the thread cannot start a history request", async () => { + const load = vi.fn(() => false); + await render("thread:1", { cursor: "recent", loading: false, onLoadEarlier: load }); + expect(status).toBe("incomplete"); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("stops when find closes or the query clears, and isolates requests between threads", async () => { + const load = vi.fn(() => true); + const page = { cursor: "recent", loading: false, onLoadEarlier: load }; + await render(null, page); + expect(load).not.toHaveBeenCalled(); + await render("thread-a:1", page); + await render(null, { ...page, cursor: "older" }); + expect(load).toHaveBeenCalledTimes(1); + expect(status).toBeNull(); + + await render("thread-b:1", page); + expect(load).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/components/chat/useThreadFindHistory.ts b/apps/web/src/components/chat/useThreadFindHistory.ts new file mode 100644 index 000000000000..2b4bd3b0b41a --- /dev/null +++ b/apps/web/src/components/chat/useThreadFindHistory.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from "react"; +import type { CitationHistoryPage } from "./useAssistantCitationTarget"; + +type FindHistoryPage = Omit & { + readonly onLoadEarlier: () => boolean; +}; + +/** Search needs every history page, but leaves row mounting to the virtual list. */ +export function useThreadFindHistory( + requestKey: string | null, + page: FindHistoryPage | null, +): "loading" | "incomplete" | null { + const requested = useRef<{ key: string; cursors: Set; loading: boolean } | null>(null); + const [failedKey, setFailedKey] = useState(null); + if (requestKey === null && failedKey !== null) setFailedKey(null); + + useEffect(() => { + if (requestKey === null) { + requested.current = null; + return; + } + if (requested.current?.key !== requestKey) { + requested.current = { key: requestKey, cursors: new Set(), loading: false }; + } + const wasLoading = requested.current.loading; + requested.current.loading = page?.loading ?? false; + if (!page || page.loading || failedKey === requestKey) return; + + const cursor = page.cursor ?? "first"; + // StrictMode replay is not a failed request. Only a completed fetch that + // leaves the cursor unchanged needs a manual retry. + if (requested.current.cursors.has(cursor)) { + if (wasLoading) setFailedKey(requestKey); + return; + } + requested.current.cursors.add(cursor); + if (!page.onLoadEarlier()) setFailedKey(requestKey); + }, [failedKey, page, requestKey]); + + if (requestKey === null || page === null) return null; + return failedKey === requestKey ? "incomplete" : "loading"; +} diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index 3021ff0fab6d..55c1561476a9 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -17,7 +17,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; import { cn } from "~/lib/utils"; -import { isAbsolutePath } from "~/terminal-links"; +import { isAbsolutePath } from "@t3tools/shared/path"; import { type FileBreadcrumb, diff --git a/apps/web/src/components/files/FileMarkdownPreview.tsx b/apps/web/src/components/files/FileMarkdownPreview.tsx index e36ada48acb8..2d4fd14e4b6e 100644 --- a/apps/web/src/components/files/FileMarkdownPreview.tsx +++ b/apps/web/src/components/files/FileMarkdownPreview.tsx @@ -1,7 +1,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import ChatMarkdown from "~/components/ChatMarkdown"; -import { resolvePathLinkTarget } from "~/terminal-links"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; export function FileMarkdownPreview(props: { readonly cwd: string; diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 1d89d58bc67f..45f6ddfaef71 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -39,7 +39,8 @@ import { resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; -import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; +import { isAbsolutePath } from "@t3tools/shared/path"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { ScrollArea } from "~/components/ui/scroll-area"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index e819315310b8..aa6225381be8 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -1,7 +1,5 @@ import type { ProjectEntry } from "@t3tools/contracts"; -import { isWindowsAbsolutePath } from "@t3tools/shared/path"; - -import { isAbsolutePath } from "~/terminal-links"; +import { isWindowsAbsolutePath, isAbsolutePath } from "@t3tools/shared/path"; export interface FileBreadcrumb { label: string; diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 9770d36fa2c3..cfe11bc603ef 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,4 +1,4 @@ -import { isAbsolutePath } from "~/terminal-links"; +import { isAbsolutePath } from "@t3tools/shared/path"; export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 02d1dad458f1..cbefd2088bdb 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -167,6 +167,24 @@ const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ }, ], }, + { + id: "search", + title: "Search", + families: [ + { + id: "search-matches", + label: "Search matches", + role: "searchMatchBackground", + roles: ["searchMatchBackground", "searchMatchForeground"], + }, + { + id: "current-match", + label: "Current match", + role: "searchMatchActiveBackground", + roles: ["searchMatchActiveBackground", "searchMatchActiveForeground"], + }, + ], + }, { id: "context", title: "Context", diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx index 04e34e5611ee..428722889794 100644 --- a/apps/web/src/components/ui/input-group.tsx +++ b/apps/web/src/components/ui/input-group.tsx @@ -12,11 +12,17 @@ const inputGroupVariants = cva( { defaultVariants: { variant: "default", + size: "default", }, variants: { + size: { + default: null, + lg: "h-9", + }, variant: { default: "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", + popover: "border-input bg-popover shadow-sm", ghost: "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background", }, @@ -27,11 +33,12 @@ const inputGroupVariants = cva( function InputGroup({ className, variant, + size, ...props }: React.ComponentProps<"div"> & VariantProps) { return (
{ ); }); + it("matches chat.find outside terminal and preview focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false, previewFocus: false }, + }), + "chat.find", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "f", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false, previewFocus: true }, + }), + "chat.find", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx index cf06355f3b49..3682deb281fa 100644 --- a/apps/web/src/markdown-incremental.test.tsx +++ b/apps/web/src/markdown-incremental.test.tsx @@ -1,16 +1,15 @@ +import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; import type { Root } from "mdast"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize from "rehype-sanitize"; import remarkGfm from "remark-gfm"; import type { Plugin } from "unified"; import { describe, expect, it } from "vite-plus/test"; -import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives"; -import { remarkGithubAlerts } from "./markdown-github-alerts"; +import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives"; +import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; -import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; +import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { let tree: Root | undefined; @@ -37,7 +36,7 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: remarkCodexDirectives, ...(incremental ? [incremental] : []), ]} - rehypePlugins={[rehypeRaw, rehypeSanitize]} + rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS} > {source} , diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5421c0d8781d..61686ab89b48 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -4,26 +4,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown from "react-markdown"; import { - extractMarkdownLinkHrefs, - isWindowsDrivePathHref, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, - resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, } from "./markdown-links"; -describe("isWindowsDrivePathHref", () => { - it.each([ - ["C:\\repo\\image.png", true], - ["C:%5Crepo%5Cimage.png", true], - ["https://example.com/image.png", false], - ])("classifies %s as %s", (href, expected) => { - expect(isWindowsDrivePathHref(href)).toBe(expected); - }); -}); - function renderMarkdownLinkHref(markdown: string): string | undefined { let renderedHref: string | undefined; renderToStaticMarkup( @@ -43,24 +30,6 @@ function renderMarkdownLinkHref(markdown: string): string | undefined { return renderedHref; } -describe("extractMarkdownLinkHrefs", () => { - it("extracts angle-bracketed paths containing spaces", () => { - expect( - extractMarkdownLinkHrefs( - "[Open the Bike Receipts folder]()", - ), - ).toEqual(["/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts"]); - }); - - it("preserves ordinary destinations and ignores link titles", () => { - expect( - extractMarkdownLinkHrefs( - '[source](apps/web/src/markdown-links.ts "implementation") and [docs](https://example.com)', - ), - ).toEqual(["apps/web/src/markdown-links.ts", "https://example.com"]); - }); -}); - describe("shouldOpenMarkdownFileLinkInEditor", () => { it("uses command-click on macOS", () => { expect(shouldOpenMarkdownFileLinkInEditor({ metaKey: true, ctrlKey: false }, "MacIntel")).toBe( @@ -130,213 +99,6 @@ describe("rewriteMarkdownFileUriHref", () => { }); }); -describe("resolveMarkdownFileLinkTarget", () => { - it("resolves absolute posix file paths", () => { - expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe( - "/Users/julius/project/AGENTS.md", - ); - }); - - it("resolves relative file paths against cwd", () => { - expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe( - "/Users/julius/project/src/processRunner.ts:71", - ); - }); - - it("does not treat filename line references as external schemes", () => { - expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe( - "/Users/julius/project/script.ts:10", - ); - }); - - it("resolves bare file names against cwd", () => { - expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe( - "/Users/julius/project/AGENTS.md", - ); - }); - - it("maps #L line anchors to editor line suffixes", () => { - expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe( - "/Users/julius/project/src/main.ts:42:7", - ); - }); - - it("ignores external urls", () => { - expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull(); - expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull(); - }); - - it("does not double-decode file URLs", () => { - expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe( - "/Users/julius/project/file%20name.md", - ); - }); - - it("resolves file uri authorities as windows UNC paths", () => { - expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe( - "\\\\server\\share\\workspace-image.svg", - ); - }); - - it("resolves a localhost file uri as a local path", () => { - expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe( - "/home/me/notes.md", - ); - }); - - it("formats tooltip display paths relative to the cwd when possible", () => { - expect( - resolveMarkdownFileLinkMeta( - "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501", - "C:/Users/mike/dev-stuff/t3code", - ), - ).toMatchObject({ - displayPath: "t3code/apps/web/src/session-logic.ts:501", - workspaceRelativePath: "apps/web/src/session-logic.ts", - }); - }); - - it("resolves the encoded spaces emitted by the markdown renderer", () => { - expect( - resolveMarkdownFileLinkMeta( - "/Users/dara/Downloads/Lime%20Ride%20Artifacts/Bike%20Receipts", - "/Users/dara/Downloads/Lime Ride Artifacts", - ), - ).toMatchObject({ - targetPath: "/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts", - workspaceRelativePath: "Bike Receipts", - basename: "Bike Receipts", - }); - }); - - it("resolves relative spaced folders from the markdown renderer", () => { - const href = renderMarkdownLinkHref("[folder]()"); - - expect(href).toBe("docs/My%20Folder"); - expect(resolveMarkdownFileLinkMeta(href, "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/repo/project")).toMatchObject({ - targetPath: "/repo/project/docs/My Folder", - workspaceRelativePath: "docs/My Folder", - basename: "My Folder", - }); - }); - - it.each(["md", "html", "xml"])( - "resolves a bare spaced .%s filename from the markdown renderer", - (extension) => { - const href = renderMarkdownLinkHref(`[checklist]()`); - - expect(href).toBe(`Updated%20cutover%20checklist.${extension}`); - expect(resolveMarkdownFileLinkMeta(href, "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/repo/project")).toMatchObject({ - targetPath: `/repo/project/Updated cutover checklist.${extension}`, - workspaceRelativePath: `Updated cutover checklist.${extension}`, - basename: `Updated cutover checklist.${extension}`, - }); - }, - ); - - it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => { - expect( - resolveMarkdownFileLinkMeta( - "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", - "C:/Users/mike/dev-stuff/t3code", - ), - ).toMatchObject({ - displayPath: - "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", - workspaceRelativePath: - "apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", - }); - }); - - it("does not create a preview path for files outside the workspace", () => { - expect(resolveMarkdownFileLinkMeta("/tmp/report.ts", "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/repo/project")).toMatchObject({ - workspaceRelativePath: null, - }); - }); - - it("does not classify a case-distinct POSIX sibling as a workspace file", () => { - expect( - resolveMarkdownFileLinkMeta( - "/tmp/t3code-case-test/project/probe.txt", - "/tmp/t3code-case-test/Project", - ), - ).toMatchObject({ - displayPath: "/tmp/t3code-case-test/project/probe.txt", - workspaceRelativePath: null, - }); - }); - - it("keeps Windows workspace comparisons case-insensitive", () => { - expect( - resolveMarkdownFileLinkMeta("C:/Users/MIKE/Project/src/main.ts", "c:/users/mike/project"), - ).toMatchObject({ - displayPath: "project/src/main.ts", - workspaceRelativePath: "src/main.ts", - }); - }); - - it("keeps drive-root workspace comparisons case-insensitive", () => { - expect(resolveMarkdownFileLinkMeta("C:/Users/MIKE/project.ts", "c:/")).toMatchObject({ - displayPath: "c:/Users/MIKE/project.ts", - workspaceRelativePath: "Users/MIKE/project.ts", - }); - }); - - it("keeps backslash UNC workspace comparisons case-insensitive", () => { - expect( - resolveMarkdownFileLinkMeta( - "\\\\server\\share\\PROJECT\\src\\main.ts", - "\\\\Server\\Share\\Project", - ), - ).toMatchObject({ - displayPath: "Project/src/main.ts", - workspaceRelativePath: "src/main.ts", - }); - }); - - it.each([ - ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], - ["C:/Users/MIKE/file.ts", "c:/", "Users/MIKE/file.ts"], - ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], - ["/tmp/repo/file.ts%20", "/tmp/repo", "file.ts "], - ])("preserves the preview target for %s in workspace %s", (href, cwd, workspaceRelativePath) => { - expect(resolveMarkdownFileLinkMeta(href, cwd)).toMatchObject({ workspaceRelativePath }); - }); - - it("keeps an encoded final space in the absolute target", () => { - expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe( - "/tmp/repo/file.ts ", - ); - }); - - it("normalizes slash-prefixed windows drive paths before resolving", () => { - expect( - resolveMarkdownFileLinkTarget( - "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69", - ), - ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69"); - }); - - it("resolves angle-bracketed windows drive paths", () => { - expect( - resolveMarkdownFileLinkTarget( - "", - ), - ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); - }); - - it("does not treat app routes as file links, even with a line anchor", () => { - expect(resolveMarkdownFileLinkTarget("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/chat/settings")).toBeNull(); - expect(resolveMarkdownFileLinkTarget("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/chat/settings#L3", "/repo")).toBeNull(); - }); - - it("decodes an encoded drive colon in a file uri before dropping its slash", () => { - expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe( - "c:/Users/x/shot.png", - ); - }); -}); - describe("relative links inside a rendered host file", () => { it("anchor to the file's directory while workspace membership follows cwd", () => { const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report"); @@ -522,3 +284,124 @@ describe("directory paths with a trailing separator", () => { expect(meta?.basename).not.toBe(""); }); }); + +describe("resolveMarkdownFileLinkMeta", () => { + it("formats tooltip display paths relative to the cwd when possible", () => { + expect( + resolveMarkdownFileLinkMeta( + "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: "t3code/apps/web/src/session-logic.ts:501", + workspaceRelativePath: "apps/web/src/session-logic.ts", + }); + }); + + it("resolves the encoded spaces emitted by the markdown renderer", () => { + expect( + resolveMarkdownFileLinkMeta( + "/Users/dara/Downloads/Lime%20Ride%20Artifacts/Bike%20Receipts", + "/Users/dara/Downloads/Lime Ride Artifacts", + ), + ).toMatchObject({ + targetPath: "/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts", + workspaceRelativePath: "Bike Receipts", + basename: "Bike Receipts", + }); + }); + + it("resolves relative spaced folders from the markdown renderer", () => { + const href = renderMarkdownLinkHref("[folder]()"); + + expect(href).toBe("docs/My%20Folder"); + expect(resolveMarkdownFileLinkMeta(href, "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/repo/project")).toMatchObject({ + targetPath: "/repo/project/docs/My Folder", + workspaceRelativePath: "docs/My Folder", + basename: "My Folder", + }); + }); + + it.each(["md", "html", "xml"])( + "resolves a bare spaced .%s filename from the markdown renderer", + (extension) => { + const href = renderMarkdownLinkHref(`[checklist]()`); + + expect(href).toBe(`Updated%20cutover%20checklist.${extension}`); + expect(resolveMarkdownFileLinkMeta(href, "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/repo/project")).toMatchObject({ + targetPath: `/repo/project/Updated cutover checklist.${extension}`, + workspaceRelativePath: `Updated cutover checklist.${extension}`, + basename: `Updated cutover checklist.${extension}`, + }); + }, + ); + + it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => { + expect( + resolveMarkdownFileLinkMeta( + "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + "C:/Users/mike/dev-stuff/t3code", + ), + ).toMatchObject({ + displayPath: + "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + workspaceRelativePath: + "apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx", + }); + }); + + it("does not create a preview path for files outside the workspace", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/report.ts", "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/repo/project")).toMatchObject({ + workspaceRelativePath: null, + }); + }); + + it("does not classify a case-distinct POSIX sibling as a workspace file", () => { + expect( + resolveMarkdownFileLinkMeta( + "/tmp/t3code-case-test/project/probe.txt", + "/tmp/t3code-case-test/Project", + ), + ).toMatchObject({ + displayPath: "/tmp/t3code-case-test/project/probe.txt", + workspaceRelativePath: null, + }); + }); + + it("keeps Windows workspace comparisons case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta("C:/Users/MIKE/Project/src/main.ts", "c:/users/mike/project"), + ).toMatchObject({ + displayPath: "project/src/main.ts", + workspaceRelativePath: "src/main.ts", + }); + }); + + it("keeps drive-root workspace comparisons case-insensitive", () => { + expect(resolveMarkdownFileLinkMeta("C:/Users/MIKE/project.ts", "c:/")).toMatchObject({ + displayPath: "c:/Users/MIKE/project.ts", + workspaceRelativePath: "Users/MIKE/project.ts", + }); + }); + + it("keeps backslash UNC workspace comparisons case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "\\\\server\\share\\PROJECT\\src\\main.ts", + "\\\\Server\\Share\\Project", + ), + ).toMatchObject({ + displayPath: "Project/src/main.ts", + workspaceRelativePath: "src/main.ts", + }); + }); + + it.each([ + ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], + ["C:/Users/MIKE/file.ts", "c:/", "Users/MIKE/file.ts"], + ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], + ["/tmp/repo/file.ts%20", "/tmp/repo", "file.ts "], + ])("preserves the preview target for %s in workspace %s", (href, cwd, workspaceRelativePath) => { + expect(resolveMarkdownFileLinkMeta(href, cwd)).toMatchObject({ workspaceRelativePath }); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 8ed59eb8775b..d25d089f6528 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,23 +1,13 @@ +import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; import { - fileBasename, - formatFilePathPosition, inlineCodeFilePathCandidate, - isRelativeFilePath, normalizeMarkdownLinkDestination, - parseFileUrlHref, - parseMarkdownFileLink, - safeDecodeURIComponent, - splitFilePathPosition, - workspaceRelativeFilePath, -} from "@t3tools/client-runtime/markdown-links"; + resolveMarkdownFileLinkTarget, +} from "@t3tools/shared/markdownLinks"; +import { parseFileUrlHref, splitFilePathPosition } from "@t3tools/shared/fileLinks"; import { formatWorkspaceRelativePath } from "./filePathDisplay"; -import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links"; - -export { normalizeMarkdownLinkDestination }; - -const MARKDOWN_LINK_HREF_PATTERN = - /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; +import { isTerminalLinkActivation } from "./terminal-links"; export interface MarkdownFileLinkMeta { filePath: string; @@ -29,15 +19,6 @@ export interface MarkdownFileLinkMeta { column?: number; } -export function extractMarkdownLinkHrefs(markdown: string): string[] { - const hrefs: string[] = []; - for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = (match[1] ?? match[2])?.trim(); - if (href) hrefs.push(href); - } - return hrefs; -} - export function shouldOpenMarkdownFileLinkInEditor( event: Pick, platform?: string, @@ -49,36 +30,12 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); } -export function isWindowsDrivePathHref(href: string): boolean { - return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); -} - export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href)); return target ? `${target.path}${target.hash}` : null; } -/** - * `baseDir` anchors relative links; it defaults to the workspace root and is the - * file's own directory when rendering a markdown file. `cwd` stays the workspace - * root so the result still knows whether the target is inside it. - */ -export function resolveMarkdownFileLinkTarget( - href: string | undefined, - cwd?: string, - baseDir: string | undefined = cwd, -): string | null { - if (!href) return null; - const target = parseMarkdownFileLink(href); - if (!target) return null; - - const pathWithPosition = formatFilePathPosition(target); - if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; - if (!baseDir) return null; - return resolvePathLinkTarget(pathWithPosition, baseDir); -} - /** * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, * `origin/main`) rather than deliberate link destinations, so auto-linking diff --git a/apps/web/src/markdown-github-alerts.test.tsx b/apps/web/src/markdownGithubAlerts.test.tsx similarity index 96% rename from apps/web/src/markdown-github-alerts.test.tsx rename to apps/web/src/markdownGithubAlerts.test.tsx index 22bb711103e1..ff577a78e536 100644 --- a/apps/web/src/markdown-github-alerts.test.tsx +++ b/apps/web/src/markdownGithubAlerts.test.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { describe, expect, it } from "vite-plus/test"; -import { remarkGithubAlerts } from "./markdown-github-alerts"; +import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; function renderMarkdown(markdown: string): string { return renderToStaticMarkup( diff --git a/apps/web/src/markdown-list-indentation.test.tsx b/apps/web/src/markdownListIndentation.test.tsx similarity index 96% rename from apps/web/src/markdown-list-indentation.test.tsx rename to apps/web/src/markdownListIndentation.test.tsx index 2c04ed5fa832..3ce4ad6a7ebe 100644 --- a/apps/web/src/markdown-list-indentation.test.tsx +++ b/apps/web/src/markdownListIndentation.test.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { describe, expect, it } from "vite-plus/test"; -import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; +import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; function renderMarkdown(markdown: string): string { return renderToStaticMarkup( diff --git a/apps/web/src/openVsxThemes.test.ts b/apps/web/src/openVsxThemes.test.ts index b4794f61986a..ba6eb482e8cf 100644 --- a/apps/web/src/openVsxThemes.test.ts +++ b/apps/web/src/openVsxThemes.test.ts @@ -305,6 +305,10 @@ describe("Open VSX themes", () => { "colors": { "editor.foreground": "#eeeeee", "focusBorder": "#8b5cf6", + "editor.findMatchBackground": "#663399", + "editor.findMatchForeground": "#ffffff", + "editor.findMatchHighlightBackground": "#445566", + "editor.findMatchHighlightForeground": "#ffffff", }, }`, ); @@ -312,7 +316,10 @@ describe("Open VSX themes", () => { "extension/themes/demo-dark.json", `{ "include": "./base.jsonc", - "colors": { "editor.background": "#111111" } + "colors": { + "editor.background": "#111111", + "editor.findMatchHighlightBackground": "#ffffff20" + } }`, ); zip.file( @@ -321,7 +328,11 @@ describe("Open VSX themes", () => { "colors": { "editor.background": "#fafafa", "editor.foreground": "#222222", - "focusBorder": "#8b5cf6" + "focusBorder": "#8b5cf6", + "editor.findMatchBackground": "#335577", + "editor.findMatchForeground": "#ffffff", + "editor.findMatchHighlightBackground": "#bbddff", + "editor.findMatchHighlightForeground": "#112233" } }`, ); @@ -410,6 +421,15 @@ describe("Open VSX themes", () => { expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa"); expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111"); expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee"); + const darkColors = getThemeColorsForMode(paired, "dark")!; + expect(themeColorToHex(darkColors.searchMatchActiveBackground)).toBe("#663399"); + expect(themeColorToHex(darkColors.searchMatchActiveForeground)).toBe("#ffffff"); + expect(themeColorToHex(darkColors.searchMatchBackground)).toBe("#2f2f2f"); + expect(themeColorToHex(darkColors.searchMatchForeground)).toBe("#ffffff"); + expect(themeColorToHex(paired.colors.searchMatchActiveBackground)).toBe("#335577"); + expect(themeColorToHex(paired.colors.searchMatchActiveForeground)).toBe("#ffffff"); + expect(themeColorToHex(paired.colors.searchMatchBackground)).toBe("#bbddff"); + expect(themeColorToHex(paired.colors.searchMatchForeground)).toBe("#112233"); packagedManifest.contributes.themes[0]!.label = "Renamed Dark"; packagedManifest.contributes.themes[1]!.label = "Renamed Light"; diff --git a/apps/web/src/openVsxThemes.ts b/apps/web/src/openVsxThemes.ts index 8b03d84869b5..81617e3cd21d 100644 --- a/apps/web/src/openVsxThemes.ts +++ b/apps/web/src/openVsxThemes.ts @@ -49,6 +49,10 @@ const USED_WORKBENCH_COLORS = new Set([ "dropdown.border", "editor.background", "editor.foreground", + "editor.findMatchBackground", + "editor.findMatchForeground", + "editor.findMatchHighlightBackground", + "editor.findMatchHighlightForeground", "editor.selectionBackground", "editorCursor.foreground", "editorError.foreground", diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index a8700ab924e6..30e114a0eb7c 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -5,21 +5,9 @@ import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, buildProposedPlanMarkdownFilename, - proposedPlanTitle, resolvePlanFollowUpSubmission, - stripDisplayedPlanMarkdown, } from "./proposedPlan"; -describe("proposedPlanTitle", () => { - it("reads the first markdown heading as the plan title", () => { - expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC"); - }); - - it("returns null when the plan has no heading", () => { - expect(proposedPlanTitle("- step 1")).toBeNull(); - }); -}); - describe("buildPlanImplementationPrompt", () => { it("formats the plan exactly like the Codex follow-up handoff prompt", () => { expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe( @@ -49,20 +37,6 @@ describe("buildCollapsedProposedPlanPreviewMarkdown", () => { }); }); -describe("stripDisplayedPlanMarkdown", () => { - it("drops the leading title heading from displayed plan markdown", () => { - expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe( - "- step 1", - ); - }); - - it("preserves non-summary headings after dropping the title heading", () => { - expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe( - "## Scope\n\n- step 1", - ); - }); -}); - describe("resolvePlanFollowUpSubmission", () => { it.each(["", "Consider this "])( "keeps context-bearing feedback in plan mode with prose %j", diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 525be17a72e0..15f90c59ae23 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -1,23 +1,4 @@ -export function proposedPlanTitle(planMarkdown: string): string | null { - const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); - return heading && heading.length > 0 ? heading : null; -} - -export function stripDisplayedPlanMarkdown(planMarkdown: string): string { - const lines = planMarkdown.trimEnd().split(/\r?\n/); - const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; - while (sourceLines[0]?.trim().length === 0) { - sourceLines.shift(); - } - const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/); - if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") { - sourceLines.shift(); - while (sourceLines[0]?.trim().length === 0) { - sourceLines.shift(); - } - } - return sourceLines.join("\n"); -} +import { stripDisplayedPlanMarkdown, proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; export function buildCollapsedProposedPlanPreviewMarkdown( planMarkdown: string, diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 3c466378ba8b..bc78a7b34560 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -5,7 +5,6 @@ import { extractTerminalLinks, isTerminalLinkActivation, isTerminalUrl, - resolvePathLinkTarget, type TerminalBufferLineLike, } from "./terminal-links"; @@ -152,29 +151,6 @@ describe("collectWrappedTerminalLinkLine", () => { }); }); -describe("resolvePathLinkTarget", () => { - it("resolves relative paths against cwd", () => { - expect( - resolvePathLinkTarget( - "src/components/ThreadTerminalDrawer.tsx:42:7", - "/Users/julius/project", - ), - ).toBe("/Users/julius/project/src/components/ThreadTerminalDrawer.tsx:42:7"); - }); - - it("keeps absolute paths unchanged", () => { - expect( - resolvePathLinkTarget("/Users/julius/project/src/main.ts:12", "/Users/julius/project"), - ).toBe("/Users/julius/project/src/main.ts:12"); - }); - - it("keeps Windows absolute paths with forward slashes unchanged", () => { - expect( - resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"), - ).toBe("C:/Users/julius/project/src/main.ts:12"); - }); -}); - describe("isTerminalLinkActivation", () => { it("requires cmd on macOS", () => { expect( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 59e2082a7359..8e95bf56ad68 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -1,8 +1,3 @@ -import { - formatFilePathPosition, - splitFilePathPosition, -} from "@t3tools/client-runtime/markdown-links"; - import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; @@ -93,45 +88,6 @@ function collectMatches( return matches; } -function isWindowsAbsolutePath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); -} - -export function isAbsolutePath(value: string): boolean { - return value.startsWith("/") || isWindowsAbsolutePath(value); -} - -function isWindowsPathStyle(value: string): boolean { - return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); -} - -function joinPath(base: string, next: string, separator: "/" | "\\"): string { - const cleanBase = base.replace(/[\\/]+$/, ""); - if (separator === "\\") { - return `${cleanBase}\\${next.replaceAll("/", "\\")}`; - } - return `${cleanBase}/${next.replace(/^\/+/, "")}`; -} - -function inferHomeFromCwd(cwd: string): string | undefined { - const posixUser = cwd.match(/^\/Users\/([^/]+)/); - if (posixUser?.[1]) { - return `/Users/${posixUser[1]}`; - } - - const posixHome = cwd.match(/^\/home\/([^/]+)/); - if (posixHome?.[1]) { - return `/home/${posixHome[1]}`; - } - - const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/); - if (windowsUser?.[1]) { - return windowsUser[1]; - } - - return undefined; -} - export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const urlMatches = collectMatches(line, "url", URL_PATTERN, []); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches); @@ -198,22 +154,3 @@ export function isTerminalLinkActivation( ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; } - -export function resolvePathLinkTarget(rawPath: string, cwd: string): string { - const position = splitFilePathPosition(rawPath); - const { path } = position; - - let resolvedPath = path; - if (path.startsWith("~/")) { - const home = inferHomeFromCwd(cwd); - if (home) { - const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; - resolvedPath = joinPath(home, path.slice(2), separator); - } - } else if (!isAbsolutePath(path)) { - const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; - resolvedPath = joinPath(cwd, path, separator); - } - - return formatFilePathPosition({ ...position, path: resolvedPath }); -} diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index e1d9bfb74cab..e99189f4b383 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -33,6 +33,7 @@ import { IRIS_THEME, OCEAN_THEME, updateCustomTheme, + updateThemeColorFamily, CUSTOM_THEMES_STORAGE_KEY, createVividThemeColors, getDefaultThemeColors, @@ -90,6 +91,68 @@ describe("theme files", () => { } }); + it("keeps built-in and standard search pairs readable and distinct", () => { + const palettes = [ + getStandardThemeColors("light"), + getStandardThemeColors("dark"), + ...BUILT_IN_THEMES.flatMap((theme) => [theme.colors, ...Object.values(theme.variants ?? {})]), + ]; + for (const colors of palettes) { + expect( + contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground), + ).toBeGreaterThanOrEqual(4.5); + expect(asHex(colors.searchMatchBackground)).not.toBe( + asHex(colors.searchMatchActiveBackground), + ); + } + }); + + it("round-trips custom search colours and fills them in for older theme files", () => { + const legacy = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Legacy", + appearance: "dark", + colors: { canvas: "#101010" }, + }); + expect(legacy.colors.searchMatchBackground).toBe( + getDefaultThemeColors("dark").searchMatchBackground, + ); + const theme = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Search", + appearance: "dark", + colors: { + searchMatchBackground: "#224466", + searchMatchForeground: "#ffffff", + searchMatchActiveBackground: "#aaddff", + searchMatchActiveForeground: "#112233", + }, + variants: { light: { searchMatchBackground: "#ddeeff", searchMatchForeground: "#112233" } }, + }); + const restored = parseThemeFile(JSON.parse(serializeThemeFile(theme))); + expect(restored.colors).toEqual(theme.colors); + expect(restored.variants).toEqual(theme.variants); + }); + + it.each(["searchMatchBackground", "searchMatchActiveBackground"] as const)( + "editing %s derives readable text without changing the other match or warning colours", + (role) => { + const original = getDefaultThemeColors("dark"); + const foreground = + role === "searchMatchBackground" ? "searchMatchForeground" : "searchMatchActiveForeground"; + for (const value of ["#ffffff", "#000000", "#33669980"]) { + const updated = updateThemeColorFamily("dark", original, role, value); + expect(contrastRatio(updated[foreground], updated[role])).toBeGreaterThanOrEqual(4.5); + for (const key of Object.keys(original) as Array) { + if (key !== role && key !== foreground) expect(updated[key]).toBe(original[key]); + } + } + }, + ); + it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", @@ -140,6 +203,12 @@ describe("theme files", () => { ); expect(colors.secondaryLabel).toBe(colors.textMuted); expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground), + ).toBeGreaterThanOrEqual(4.5); expect( contrastRatio(colors.messageActionForeground, colors.messageAction), ).toBeGreaterThanOrEqual(4.5); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 4de6dd7bd532..623d87b91448 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -377,6 +377,10 @@ const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { messageActionHover: "#3160db", codeBackground: "#ffffff", codeForeground: "#27272a", + searchMatchBackground: "#fce8c0", + searchMatchForeground: "#27272a", + searchMatchActiveBackground: "#eea52b", + searchMatchActiveForeground: "#21180a", sidebar: "#fafafa", sidebarForeground: "#27272a", sidebarMutedForeground: "#71717b", @@ -437,6 +441,10 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { messageActionHover: "#3061d9", codeBackground: "#111111", codeForeground: "#f5f5f5", + searchMatchBackground: "#533e16", + searchMatchForeground: "#f5f5f5", + searchMatchActiveBackground: "#eea52b", + searchMatchActiveForeground: "#21180a", sidebar: "#000000", sidebarForeground: "#f1f3f7", sidebarMutedForeground: "#a3a3a3", @@ -868,6 +876,10 @@ export function createVividThemeColors( const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6)); const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6)); + // Keep matches visible even when an imported theme has no accent colour. + const searchMatch = themeOklchToRgb(surfaceAt(0.22, Math.min(0.12, accent.C))); + const searchMatchActive = themeOklchToRgb(surfaceAt(0.4, Math.min(0.18, accent.C))); + const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) }; return { @@ -911,6 +923,10 @@ export function createVividThemeColors( messageActionHover: themeColor(actionHover), codeBackground: themeColor(codeBackground), codeForeground: themeRgbToThemeColor(textRgb), + searchMatchBackground: themeRgbToThemeColor(searchMatch), + searchMatchForeground: themeRgbToThemeColor(readableThemeForeground(searchMatch)), + searchMatchActiveBackground: themeRgbToThemeColor(searchMatchActive), + searchMatchActiveForeground: themeRgbToThemeColor(readableThemeForeground(searchMatchActive)), sidebar: themeColor(sidebar), sidebarForeground: foregroundOn(sidebarRgb), sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)), @@ -1130,6 +1146,18 @@ export function updateThemeColorFamily( messageSurface: normalized, messageForeground: foregroundOn(selectedOnCanvas), }; + case "searchMatchBackground": + return { + ...colors, + searchMatchBackground: colorOf(selectedOnCanvas), + searchMatchForeground: foregroundOn(selectedOnCanvas), + }; + case "searchMatchActiveBackground": + return { + ...colors, + searchMatchActiveBackground: colorOf(selectedOnCanvas), + searchMatchActiveForeground: foregroundOn(selectedOnCanvas), + }; case "codeBackground": return { ...colors, @@ -1594,6 +1622,10 @@ const APP_THEME_VARIABLES: Readonly> = { messageActionHover: "--app-theme-message-action-hover", codeBackground: "--app-theme-code-background", codeForeground: "--app-theme-code-foreground", + searchMatchBackground: "--app-theme-search-match-background", + searchMatchForeground: "--app-theme-search-match-foreground", + searchMatchActiveBackground: "--app-theme-search-match-active-background", + searchMatchActiveForeground: "--app-theme-search-match-active-foreground", sidebar: "--app-theme-sidebar", sidebarForeground: "--app-theme-sidebar-foreground", sidebarMutedForeground: "--app-theme-sidebar-muted-foreground", diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index 59f732bf4a12..13a026dc93b4 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -112,6 +112,101 @@ describe("VS Code theme import", () => { ); }); + it("imports current and other find matches as independent background/text pairs", () => { + const { colors } = parseVsCodeThemeFile({ + type: "dark", + colors: { + "editor.background": "#202020", + "editor.findMatchBackground": "#663399", + "editor.findMatchForeground": "#ffffff", + "editor.findMatchHighlightBackground": "#bce4ff", + "editor.findMatchHighlightForeground": "#17314a", + }, + }); + + expect(asHex(colors.searchMatchActiveBackground)).toBe("#663399"); + expect(asHex(colors.searchMatchActiveForeground)).toBe("#ffffff"); + expect(asHex(colors.searchMatchBackground)).toBe("#bce4ff"); + expect(asHex(colors.searchMatchForeground)).toBe("#17314a"); + }); + + it("composites find backgrounds onto the canvas and foregrounds onto their match", () => { + const { colors } = parseVsCodeThemeFile({ + type: "dark", + colors: { + "editor.background": "#202020", + "editor.findMatchBackground": "#33669980", + "editor.findMatchForeground": "#ffffffcc", + "editor.findMatchHighlightBackground": "#ffcc0040", + "editor.findMatchHighlightForeground": "#ffffff", + }, + }); + + expect(asHex(colors.searchMatchActiveBackground)).toBe("#2a435d"); + expect(asHex(colors.searchMatchActiveForeground)).toBe("#d4d9df"); + expect(asHex(colors.searchMatchBackground)).toBe("#584b18"); + expect(asHex(colors.searchMatchForeground)).toBe("#ffffff"); + }); + + it("uses editor text when no find foreground is specified", () => { + const { colors } = parseVsCodeThemeFile({ + type: "dark", + colors: { + "editor.background": "#101010", + "editor.foreground": "#eeeeee", + "editor.findMatchBackground": "#304050", + "editor.findMatchHighlightBackground": "#403020", + }, + }); + + expect(asHex(colors.searchMatchActiveForeground)).toBe("#eeeeee"); + expect(asHex(colors.searchMatchForeground)).toBe("#eeeeee"); + }); + + it.each(["light", "dark"])( + "keeps missing or invalid find colours readable in %s themes", + (type) => { + const base = { + "editor.background": type === "dark" ? "#101010" : "#fafafa", + }; + const missing = parseVsCodeThemeFile({ type, colors: base }).colors; + const invalid = parseVsCodeThemeFile({ + type, + colors: { + ...base, + "editor.findMatchBackground": "not-a-colour", + "editor.findMatchForeground": 42, + "editor.findMatchHighlightBackground": null, + "editor.findMatchHighlightForeground": "invalid", + }, + }).colors; + const unreadable = parseVsCodeThemeFile({ + type, + colors: { + ...base, + "editor.findMatchBackground": "#ffffcc", + "editor.findMatchForeground": "#ffffff", + "editor.findMatchHighlightBackground": "#101020", + "editor.findMatchHighlightForeground": "#111111", + }, + }).colors; + + for (const colors of [missing, invalid, unreadable]) { + expect( + contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground), + ).toBeGreaterThanOrEqual(4.5); + } + expect(invalid.searchMatchBackground).toBe(missing.searchMatchBackground); + expect(invalid.searchMatchActiveBackground).toBe(missing.searchMatchActiveBackground); + expect(missing.searchMatchBackground).not.toBe(missing.searchMatchActiveBackground); + expect(missing.searchMatchBackground).not.toBe(missing.canvas); + expect(missing.searchMatchActiveBackground).not.toBe(missing.canvas); + }, + ); + it("fills every role the file omits with a readable derived value", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 186e03fb6a34..fdafb9477d47 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -256,6 +256,13 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { return relativeLuminance(surfaceRgb) < 0.179 ? "#ffffff" : "#000000"; }; + // Find overlays can cover prose, code, or message bubbles. Flatten them onto + // the editor canvas so each imported foreground has one predictable surface. + const searchMatchBackground = + solidOver(canvas, "editor.findMatchHighlightBackground") ?? derived.searchMatchBackground; + const searchMatchActiveBackground = + solidOver(canvas, "editor.findMatchBackground") ?? derived.searchMatchActiveBackground; + const overrides: Partial> = { canvas: canvasHex, text: readableOn(canvasHex, derived.text, "editor.foreground", "foreground"), @@ -281,6 +288,22 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition { solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ?? derived.accentSurface, codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground, + searchMatchBackground, + searchMatchForeground: readableOn( + searchMatchBackground, + derived.searchMatchForeground, + "editor.findMatchHighlightForeground", + "editor.foreground", + "foreground", + ), + searchMatchActiveBackground, + searchMatchActiveForeground: readableOn( + searchMatchActiveBackground, + derived.searchMatchActiveForeground, + "editor.findMatchForeground", + "editor.foreground", + "foreground", + ), sidebar: sidebarHex, sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"), sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f9284811de3a..1453a2a02f45 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -92,6 +92,14 @@ Combine keys with `!` for not, `&&` for and, `||` for or, and parentheses: { "key": "mod+j", "command": "terminal.toggle", "when": "terminalOpen && !terminalFocus" } ``` +## Find in the current thread + +`chat.find` searches conversation messages and proposed plans in the active thread. Entering a +query searches the entire thread, including older messages. When connected to an older server, +search loads earlier history first. Select **Retry** if a search or history load fails. It defaults to +`mod+f` outside terminals and previews. Press **Enter** or **Shift+Enter** to move between matches, +and **Escape** to close find. + ## Precedence The last rule whose key and condition both match wins, even if it belongs to a diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 65e13c251edc..ed2b7d1b4748 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -35,10 +35,6 @@ "types": "./src/markdownImages.ts", "default": "./src/markdownImages.ts" }, - "./markdown-links": { - "types": "./src/markdownLinks.ts", - "default": "./src/markdownLinks.ts" - }, "./media-reference": { "types": "./src/mediaReference.ts", "default": "./src/mediaReference.ts" @@ -51,18 +47,6 @@ "types": "./src/mediaActions.ts", "default": "./src/mediaActions.ts" }, - "./codex-file-citations": { - "types": "./src/codexFileCitations.ts", - "default": "./src/codexFileCitations.ts" - }, - "./codex-artifact-templates": { - "types": "./src/codexArtifactTemplates.ts", - "default": "./src/codexArtifactTemplates.ts" - }, - "./codex-markdown-directives": { - "types": "./src/codexMarkdownDirectives.ts", - "default": "./src/codexMarkdownDirectives.ts" - }, "./errors": { "types": "./src/errors/index.ts", "default": "./src/errors/index.ts" @@ -267,16 +251,10 @@ "dependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "effect": "catalog:", - "mdast-util-directive": "^3.1.0", - "micromark-extension-directive": "^4.0.0", - "micromark-util-character": "^2.1.1", - "remark-parse": "^11.0.0", - "unified": "^11.0.5" + "effect": "catalog:" }, "devDependencies": { "@effect/vitest": "catalog:", - "micromark-util-types": "^2.0.2", "vite-plus": "catalog:" } } diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index f4092674c5ae..59a3228037b6 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -1,12 +1,11 @@ -import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "@t3tools/shared/path"; import { normalizeMarkdownLinkDestination, - parseFileUrlHref, safeDecodeURIComponent, splitMarkdownLinkSearchAndHash, - stripSlashPrefixedWindowsDrive, -} from "./markdownLinks.ts"; +} from "@t3tools/shared/markdownLinks"; +import { parseFileUrlHref } from "@t3tools/shared/fileLinks"; const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts deleted file mode 100644 index 4aea947b87d6..000000000000 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - fileBasename, - inlineCodeFilePathCandidate, - parseFileUrlHref, - parseMarkdownFileLink, - splitFilePathPosition, - workspaceRelativeFilePath, -} from "./markdownLinks.ts"; - -describe("inlineCodeFilePathCandidate", () => { - it.each([ - ["src\\main.ts", "src/main.ts"], - ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"], - ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], - ["conf.d/nginx.conf", "conf.d/nginx.conf"], - ["script.pl:10", "script.pl:10"], - ["node.meta", null], - ["Recorded evidence here: /tmp/image.png", null], - ["origin/main", null], - ["127.0.0.1:3000", null], - ["example.com/index.html", null], - ["example.pl/index.html", null], - ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { - expect(inlineCodeFilePathCandidate(source)).toBe(candidate); - }); -}); - -describe("parseFileUrlHref", () => { - it.each([ - ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], - [ - "file:///D:/Programme/t3code/OpenInPicker.tsx#L69", - "D:/Programme/t3code/OpenInPicker.tsx", - "#L69", - ], - ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""], - ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""], - ])("parses %s", (href, path, hash) => { - expect(parseFileUrlHref(href)).toEqual({ path, hash }); - }); - - it("keeps percent-encoding so the caller decodes once", () => { - expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe( - "/Users/julius/project/file%2520name.md", - ); - expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png"); - }); - - it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => { - expect(parseFileUrlHref(href)).toBeNull(); - }); -}); - -describe("splitFilePathPosition", () => { - it.each([ - ["src/main.ts", "", { path: "src/main.ts" }], - ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], - ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], - ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], - ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], - ["src/main.ts:0", "", { path: "src/main.ts" }], - ["src/main.ts", "#section", { path: "src/main.ts" }], - ])("splits %s%s", (path, hash, expected) => { - expect(splitFilePathPosition(path, hash)).toEqual(expected); - }); -}); - -describe("parseMarkdownFileLink", () => { - // Both clients consume this table, so a path the web app recognizes is one - // the mobile app recognizes too. - it.each([ - ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], - ["/home/me/notes.md", "/home/me/notes.md"], - ["/usr/local/bin/tool", "/usr/local/bin/tool"], - ["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/workspace/Makefile", "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/workspace/Makefile"], - ["/tmp/favicons/", "/tmp/favicons/"], - ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"], - ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], - ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], - ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"], - ["", "D:/Programme/t3code/ChatMarkdown.tsx"], - ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"], - ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"], - ["file://localhost/home/me/notes.md", "/home/me/notes.md"], - ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"], - ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"], - ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"], - ["./scripts/deploy", "./scripts/deploy"], - ["~/notes/today.md", "~/notes/today.md"], - ["AGENTS.md", "AGENTS.md"], - ["script.ts:10", "script.ts"], - ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], - ])("recognizes %s as a file", (href, path) => { - expect(parseMarkdownFileLink(href)?.path).toBe(path); - }); - - it.each([ - "", - "#anchor", - "//cdn.example.com/clip.mp4", - "https://example.com/docs", - "mailto:someone@example.com", - "javascript:alert(1)", - "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/chat/settings", - "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/chat/settings#L3", - "/app#L1", - "readme", - "TODO:12", - ])("does not treat %s as a file", (href) => { - expect(parseMarkdownFileLink(href)).toBeNull(); - }); - - it("accepts conventional extensionless names with or without a position", () => { - expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); - expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); - expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" }); - }); - - it("reads positions from suffixes and line anchors", () => { - expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ - path: "/Users/julius/project/src/main.ts", - line: 42, - column: 7, - }); - expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({ - path: "\\\\server\\share\\src\\main.ts", - line: 42, - column: 7, - }); - }); -}); - -describe("fileBasename", () => { - it.each([ - ["/tmp/favicons/", "favicons"], - ["C:\\Users\\kelchm\\.claude\\", ".claude"], - ["/tmp/", "tmp"], - ["AGENTS.md", "AGENTS.md"], - ["/", "/"], - ])("labels %s as %s", (path, basename) => { - expect(fileBasename(path)).toBe(basename); - }); -}); - -describe("workspaceRelativeFilePath", () => { - it.each([ - ["/repo/project/src/main.ts", "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/repo/project", "src/main.ts"], - ["/repo/project/src/main.ts", "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/repo/project/", "src/main.ts"], - ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], - ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], - ["/Repo/Project/src/main.ts", "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/repo/project", null], - ["/tmp/case/project/probe.txt", "/tmp/case/Project", null], - ["//tmp/case/project/probe.txt", "//tmp/case/Project", null], - ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"], - ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"], - ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"], - ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"], - ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], - ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"], - ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], - ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "], - ["/tmp/report.ts", "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/repo/project", null], - ["/repo/project-two/a.ts", "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/repo/project", null], - ["/repo/project/a.ts", undefined, null], - ])("relates %s to %s", (path, workspaceRoot, relativePath) => { - expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); - }); -}); diff --git a/packages/client-runtime/src/mediaReference.ts b/packages/client-runtime/src/mediaReference.ts index f44ec90cd879..da98d4b7ee5e 100644 --- a/packages/client-runtime/src/mediaReference.ts +++ b/packages/client-runtime/src/mediaReference.ts @@ -1,6 +1,6 @@ import { isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { safeDecodeURIComponent } from "./markdownLinks.ts"; +import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks"; /** The authored media location, never the temporary URL used to load its bytes. */ export type MediaReference = diff --git a/packages/client-runtime/src/mediaSource.ts b/packages/client-runtime/src/mediaSource.ts index 6c285e21ba99..5760536a7220 100644 --- a/packages/client-runtime/src/mediaSource.ts +++ b/packages/client-runtime/src/mediaSource.ts @@ -6,11 +6,9 @@ import { markdownImageSourceFragment, type MarkdownImageSource, } from "./markdownImages.ts"; -import { - fileBasename, - splitFilePathPosition, - splitMarkdownLinkSearchAndHash, -} from "./markdownLinks.ts"; +import { fileBasename } from "@t3tools/shared/path"; +import { splitFilePathPosition } from "@t3tools/shared/fileLinks"; +import { splitMarkdownLinkSearchAndHash } from "@t3tools/shared/markdownLinks"; import { mediaFileReference, mediaReferenceFileName, diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index ba80275bffb3..ccd72f87d697 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -23,6 +23,12 @@ export function createOrchestrationEnvironmentAtoms( label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, }), + threadFind: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:thread-find", + tag: ORCHESTRATION_WS_METHODS.searchThread, + staleTimeMs: 0, + idleTtlMs: 0, + }), threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:thread-search", tag: ORCHESTRATION_WS_METHODS.searchThreads, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 14579c741198..66209707fc7e 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -90,6 +90,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedFind = yield* decode(KeybindingRule, { + key: "mod+f", + command: "chat.find", + }); + assert.strictEqual(parsedFind.command, "chat.find"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { key: "mod+shift+m", command: "modelPicker.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 44d76ded1703..8f1871c05f56 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -87,6 +87,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.branch", "chat.new", "chat.newLocal", + "chat.find", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e4a6e1166e85..9b1cf372ddab 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -38,6 +38,7 @@ export const ORCHESTRATION_WS_METHODS = { getTurnDiff: "orchestration.getTurnDiff", getFullThreadDiff: "orchestration.getFullThreadDiff", searchThreads: "orchestration.searchThreads", + searchThread: "orchestration.searchThread", getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", subscribeShell: "orchestration.subscribeShell", subscribeThread: "orchestration.subscribeThread", @@ -2286,6 +2287,38 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({ }); export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type; +/** A position in the thread's chronological, rendered-text search results. */ +export const OrchestrationSearchThreadInput = Schema.Struct({ + threadId: ThreadId, + query: TrimmedString.check(Schema.isMinLength(1), Schema.isMaxLength(200)), + index: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationSearchThreadInput = typeof OrchestrationSearchThreadInput.Type; + +export const OrchestrationThreadFindMatch = Schema.Struct({ + source: Schema.Literals(["message", "plan"]), + sourceId: TrimmedNonEmptyString, + turnId: Schema.NullOr(TurnId), + occurrence: NonNegativeInt, +}); +export type OrchestrationThreadFindMatch = typeof OrchestrationThreadFindMatch.Type; + +export const OrchestrationSearchThreadResult = Schema.Struct({ + threadSequence: NonNegativeInt, + totalMatches: NonNegativeInt, + activeIndex: NonNegativeInt, + match: Schema.NullOr(OrchestrationThreadFindMatch), + // A separate search context, never merged into contiguous conversation history. + messages: Schema.Array(OrchestrationMessage), + proposedPlans: Schema.Array(OrchestrationProposedPlan), +}); +export type OrchestrationSearchThreadResult = typeof OrchestrationSearchThreadResult.Type; + +export class OrchestrationSearchThreadError extends Schema.TaggedError()( + "OrchestrationSearchThreadError", + { message: Schema.String, cause: Schema.Defect() }, +) {} + export const OrchestrationGetWorkflowScriptInput = Schema.Struct({ threadId: ThreadId, /** Absolute path from the workflow's runHandles.scriptPath. The server @@ -2351,6 +2384,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetFullThreadDiffInput, output: OrchestrationGetFullThreadDiffResult, }, + searchThread: { + input: OrchestrationSearchThreadInput, + output: OrchestrationSearchThreadResult, + }, searchThreads: { input: OrchestrationSearchThreadsInput, output: OrchestrationSearchThreadsResult, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dec42aac1530..6c53e3ab4d27 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -93,6 +93,8 @@ import { OrchestrationGetFullThreadDiffInput, OrchestrationGetSnapshotError, OrchestrationSearchThreadsError, + OrchestrationSearchThreadError, + OrchestrationSearchThreadInput, OrchestrationSearchThreadsInput, OrchestrationGetTurnDiffError, OrchestrationGetTurnDiffInput, @@ -1278,6 +1280,12 @@ const WsOrchestrationGetFullThreadDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.ge error: Schema.Union([OrchestrationGetFullThreadDiffError, EnvironmentAuthorizationError]), }); +const WsOrchestrationSearchThreadRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThread, { + payload: OrchestrationSearchThreadInput, + success: OrchestrationRpcSchemas.searchThread.output, + error: Schema.Union([OrchestrationSearchThreadError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationSearchThreadsRpc = Rpc.make(ORCHESTRATION_WS_METHODS.searchThreads, { payload: OrchestrationSearchThreadsInput, success: OrchestrationRpcSchemas.searchThreads.output, @@ -1513,6 +1521,7 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationGetTurnDiffRpc, WsOrchestrationGetFullThreadDiffRpc, WsOrchestrationSearchThreadsRpc, + WsOrchestrationSearchThreadRpc, WsOrchestrationGetArchivedShellSnapshotRpc, WsOrchestrationSubscribeShellRpc, WsOrchestrationSubscribeThreadRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 8073b3f4ed0a..f68d822d25ee 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -581,6 +581,7 @@ export const ServerConfig = Schema.Struct({ threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), /** Whether thread reads accept the reasoningMessages opt-in. */ reasoningMessages: Schema.optionalKey(Schema.Boolean), + threadFind: Schema.optionalKey(Schema.Boolean), /** * Palettes published by this environment's machine. Never sent in a config * snapshot: the theme stream emits the current set before any change, so a diff --git a/packages/shared/package.json b/packages/shared/package.json index 97452f4d4a9f..3f0b5dde886d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -342,6 +342,50 @@ "./gitPatchPath": { "types": "./src/gitPatchPath.ts", "import": "./src/gitPatchPath.ts" + }, + "./codexArtifactTemplates": { + "types": "./src/codexArtifactTemplates.ts", + "import": "./src/codexArtifactTemplates.ts" + }, + "./codexFileCitations": { + "types": "./src/codexFileCitations.ts", + "import": "./src/codexFileCitations.ts" + }, + "./codexMarkdownDirectives": { + "types": "./src/codexMarkdownDirectives.ts", + "import": "./src/codexMarkdownDirectives.ts" + }, + "./markdownGithubAlerts": { + "types": "./src/markdownGithubAlerts.ts", + "import": "./src/markdownGithubAlerts.ts" + }, + "./markdownListIndentation": { + "types": "./src/markdownListIndentation.ts", + "import": "./src/markdownListIndentation.ts" + }, + "./proposedPlanText": { + "types": "./src/proposedPlanText.ts", + "import": "./src/proposedPlanText.ts" + }, + "./markdownPipeline": { + "types": "./src/markdownPipeline.ts", + "import": "./src/markdownPipeline.ts" + }, + "./threadFindText": { + "types": "./src/threadFindText.ts", + "import": "./src/threadFindText.ts" + }, + "./threadSearch": { + "types": "./src/threadSearch.ts", + "import": "./src/threadSearch.ts" + }, + "./markdownLinks": { + "types": "./src/markdownLinks.ts", + "import": "./src/markdownLinks.ts" + }, + "./fileLinks": { + "types": "./src/fileLinks.ts", + "import": "./src/fileLinks.ts" } }, "scripts": { @@ -354,12 +398,23 @@ "@t3tools/contracts": "workspace:*", "effect": "catalog:", "jose": "catalog:", + "mdast-util-directive": "^3.1.0", + "micromark-extension-directive": "^4.0.0", + "micromark-util-character": "^2.1.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5", "yaml": "catalog:" }, "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", + "micromark-util-types": "^2.0.2", "vite-plus": "catalog:" } } diff --git a/packages/client-runtime/src/codexArtifactTemplates.test.ts b/packages/shared/src/codexArtifactTemplates.test.ts similarity index 99% rename from packages/client-runtime/src/codexArtifactTemplates.test.ts rename to packages/shared/src/codexArtifactTemplates.test.ts index d92236104a56..65c1d489039f 100644 --- a/packages/client-runtime/src/codexArtifactTemplates.test.ts +++ b/packages/shared/src/codexArtifactTemplates.test.ts @@ -6,7 +6,7 @@ import { codexArtifactTemplateUsePrompt, resolveCodexArtifactTemplate, type CodexArtifactTemplate, -} from "./codexArtifactTemplates.js"; +} from "./codexArtifactTemplates.ts"; const HELLO_WORLD_TEMPLATE: CodexArtifactTemplate = { artifactKind: "document", diff --git a/packages/client-runtime/src/codexArtifactTemplates.ts b/packages/shared/src/codexArtifactTemplates.ts similarity index 100% rename from packages/client-runtime/src/codexArtifactTemplates.ts rename to packages/shared/src/codexArtifactTemplates.ts diff --git a/packages/client-runtime/src/codexFileCitations.test.ts b/packages/shared/src/codexFileCitations.test.ts similarity index 98% rename from packages/client-runtime/src/codexFileCitations.test.ts rename to packages/shared/src/codexFileCitations.test.ts index d132a5480ca4..e993d777cd2d 100644 --- a/packages/client-runtime/src/codexFileCitations.test.ts +++ b/packages/shared/src/codexFileCitations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.js"; +import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts"; describe("resolveCodexFileCitationLink", () => { it("resolves the attributes emitted by Codex", () => { diff --git a/packages/client-runtime/src/codexFileCitations.ts b/packages/shared/src/codexFileCitations.ts similarity index 100% rename from packages/client-runtime/src/codexFileCitations.ts rename to packages/shared/src/codexFileCitations.ts diff --git a/packages/client-runtime/src/codexMarkdownDirectives.test.ts b/packages/shared/src/codexMarkdownDirectives.test.ts similarity index 99% rename from packages/client-runtime/src/codexMarkdownDirectives.test.ts rename to packages/shared/src/codexMarkdownDirectives.test.ts index b2a22901eac2..86ab08fd5e8c 100644 --- a/packages/client-runtime/src/codexMarkdownDirectives.test.ts +++ b/packages/shared/src/codexMarkdownDirectives.test.ts @@ -7,7 +7,7 @@ import { renderCodexDirectivesForCopy, renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown, -} from "./codexMarkdownDirectives.js"; +} from "./codexMarkdownDirectives.ts"; interface TestNode { readonly type: string; diff --git a/packages/client-runtime/src/codexMarkdownDirectives.ts b/packages/shared/src/codexMarkdownDirectives.ts similarity index 100% rename from packages/client-runtime/src/codexMarkdownDirectives.ts rename to packages/shared/src/codexMarkdownDirectives.ts diff --git a/packages/shared/src/composerContextLegacy.ts b/packages/shared/src/composerContextLegacy.ts index 4f3fd51f1a70..b13caa51e3e9 100644 --- a/packages/shared/src/composerContextLegacy.ts +++ b/packages/shared/src/composerContextLegacy.ts @@ -24,8 +24,10 @@ export interface UpgradedLegacyContext { } const PLACEHOLDER = ""; -const TRAILING_TERMINAL = /\n*\n([\s\S]*?)\n<\/terminal_context>\s*$/; -const TRAILING_ELEMENT = /\n*\n([\s\S]*?)\n<\/element_context>\s*$/; +const TRAILING_TERMINAL = + /\n*\n((?:(?!\n<\/terminal_context>)[\s\S])*)\n<\/terminal_context>\s*$/; +const TRAILING_ELEMENT = + /\n*\n((?:(?!\n<\/element_context>)[\s\S])*)\n<\/element_context>\s*$/; const TRAILING_PREVIEW = /\n*\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/; const REVIEW_OR_CONTEXT_BLOCK = diff --git a/packages/shared/src/fileLinks.test.ts b/packages/shared/src/fileLinks.test.ts new file mode 100644 index 000000000000..7eee43b5998e --- /dev/null +++ b/packages/shared/src/fileLinks.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseFileUrlHref, splitFilePathPosition, resolvePathLinkTarget } from "./fileLinks.ts"; + +describe("parseFileUrlHref", () => { + it.each([ + ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], + [ + "file:///D:/Programme/t3code/OpenInPicker.tsx#L69", + "D:/Programme/t3code/OpenInPicker.tsx", + "#L69", + ], + ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""], + ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""], + ])("parses %s", (href, path, hash) => { + expect(parseFileUrlHref(href)).toEqual({ path, hash }); + }); + + it("keeps percent-encoding so the caller decodes once", () => { + expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe( + "/Users/julius/project/file%2520name.md", + ); + expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png"); + }); + + it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => { + expect(parseFileUrlHref(href)).toBeNull(); + }); +}); + +describe("splitFilePathPosition", () => { + it.each([ + ["src/main.ts", "", { path: "src/main.ts" }], + ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }], + ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }], + ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }], + ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }], + ["src/main.ts:0", "", { path: "src/main.ts" }], + ["src/main.ts", "#section", { path: "src/main.ts" }], + ])("splits %s%s", (path, hash, expected) => { + expect(splitFilePathPosition(path, hash)).toEqual(expected); + }); +}); + +describe("resolvePathLinkTarget", () => { + it("resolves relative paths against cwd", () => { + expect( + resolvePathLinkTarget( + "src/components/ThreadTerminalDrawer.tsx:42:7", + "/Users/julius/project", + ), + ).toBe("/Users/julius/project/src/components/ThreadTerminalDrawer.tsx:42:7"); + }); + + it("keeps absolute paths unchanged", () => { + expect( + resolvePathLinkTarget("/Users/julius/project/src/main.ts:12", "/Users/julius/project"), + ).toBe("/Users/julius/project/src/main.ts:12"); + }); + + it("keeps Windows absolute paths with forward slashes unchanged", () => { + expect( + resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"), + ).toBe("C:/Users/julius/project/src/main.ts:12"); + }); +}); diff --git a/packages/shared/src/fileLinks.ts b/packages/shared/src/fileLinks.ts new file mode 100644 index 000000000000..2f85669797a7 --- /dev/null +++ b/packages/shared/src/fileLinks.ts @@ -0,0 +1,183 @@ +import { + fileBasename, + isAbsolutePath, + isWindowsAbsolutePath, + stripSlashPrefixedWindowsDrive, +} from "./path.ts"; + +const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; + +const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; + +/** + * Turns a `file:` URL into a host path, still percent-encoded so callers that + * decode every destination in one place do not decode file URLs twice. A + * non-localhost authority becomes a UNC share. + */ +export function parseFileUrlHref( + href: string, +): { readonly path: string; readonly hash: string } | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") return null; + + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const path = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` + : parsed.pathname; + if (path.length === 0) return null; + return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; + } catch { + return null; + } +} + +export interface FilePathPosition { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { + const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); + const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); + if (!match?.[1]) return { path }; + + const line = Number.parseInt(match[1], 10); + const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); + return { + path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, + ...(line > 0 ? { line } : {}), + ...(column !== undefined && column > 0 ? { column } : {}), + }; +} + +export function formatFilePathPosition(position: FilePathPosition): string { + if (!position.line) return position.path; + return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; +} + +function isWindowsPathStyle(value: string): boolean { + return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); +} + +function joinPath(base: string, next: string, separator: "/" | "\\"): string { + const cleanBase = base.replace(/[\\/]+$/, ""); + if (separator === "\\") { + return `${cleanBase}\\${next.replaceAll("/", "\\")}`; + } + return `${cleanBase}/${next.replace(/^\/+/, "")}`; +} + +function inferHomeFromCwd(cwd: string): string | undefined { + const posixUser = cwd.match(/^\/Users\/([^/]+)/); + if (posixUser?.[1]) { + return `/Users/${posixUser[1]}`; + } + + const posixHome = cwd.match(/^\/home\/([^/]+)/); + if (posixHome?.[1]) { + return `/home/${posixHome[1]}`; + } + + const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/); + if (windowsUser?.[1]) { + return windowsUser[1]; + } + + return undefined; +} + +export function resolvePathLinkTarget(rawPath: string, cwd: string): string { + const position = splitFilePathPosition(rawPath); + const { path } = position; + + let resolvedPath = path; + if (path.startsWith("~/")) { + const home = inferHomeFromCwd(cwd); + if (home) { + const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/"; + resolvedPath = joinPath(home, path.slice(2), separator); + } + } else if (!isAbsolutePath(path)) { + const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/"; + resolvedPath = joinPath(cwd, path, separator); + } + + return formatFilePathPosition({ ...position, path: resolvedPath }); +} + +function pathParentSegments(path: string): string[] { + const normalized = path.replaceAll("\\", "/"); + const segments = normalized.split("/").filter((segment) => segment.length > 0); + return segments.slice(0, -1); +} + +export function buildFileLinkParentSuffixByPath( + filePaths: ReadonlyArray, +): Map { + const groups = new Map>(); + for (const filePath of filePaths) { + const normalizedPath = filePath.replaceAll("\\", "/"); + const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); + const basename = pathSegments[pathSegments.length - 1]; + if (!basename) continue; + const group = groups.get(basename) ?? new Set(); + group.add(normalizedPath); + groups.set(basename, group); + } + + const suffixByPath = new Map(); + for (const group of groups.values()) { + const uniquePaths = [...group]; + if (uniquePaths.length < 2) continue; + + const parentSegmentsByPath = new Map( + uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), + ); + const minUniqueDepthByPath = new Map(); + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + let resolvedDepth = segments.length; + for (let depth = 1; depth <= segments.length; depth += 1) { + const candidate = segments.slice(-depth).join("/"); + const collision = uniquePaths.some((otherPath) => { + if (otherPath === filePath) return false; + const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; + return otherSegments.slice(-depth).join("/") === candidate; + }); + if (!collision) { + resolvedDepth = depth; + break; + } + } + minUniqueDepthByPath.set(filePath, resolvedDepth); + } + + for (const filePath of uniquePaths) { + const segments = parentSegmentsByPath.get(filePath) ?? []; + if (segments.length === 0) continue; + const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; + const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); + } + } + + return suffixByPath; +} + +/** Visible text of a file chip, shared by Markdown rendering and thread search. */ +export function fileLinkLabel( + file: FilePathPosition, + parentSuffixByPath: ReadonlyMap, +): string { + const suffix = parentSuffixByPath.get(file.path.replaceAll("\\", "/")); + return [ + fileBasename(file.path), + suffix, + file.line ? `L${file.line}${file.column ? `:C${file.column}` : ""}` : undefined, + ] + .filter(Boolean) + .join(" · "); +} diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 948212a9aac6..12cd8002d150 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+f", command: "chat.find", when: "!terminalFocus && !previewFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, diff --git a/apps/web/src/markdown-github-alerts.ts b/packages/shared/src/markdownGithubAlerts.ts similarity index 100% rename from apps/web/src/markdown-github-alerts.ts rename to packages/shared/src/markdownGithubAlerts.ts diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts new file mode 100644 index 000000000000..613884ffb033 --- /dev/null +++ b/packages/shared/src/markdownLinks.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + inlineCodeFilePathCandidate, + parseMarkdownFileLink, + isWindowsDrivePathHref, + extractMarkdownLinkHrefs, + resolveMarkdownFileLinkTarget, +} from "./markdownLinks.ts"; + +describe("inlineCodeFilePathCandidate", () => { + it.each([ + ["src\\main.ts", "src/main.ts"], + ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"], + ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], + ["conf.d/nginx.conf", "conf.d/nginx.conf"], + ["script.pl:10", "script.pl:10"], + ["node.meta", null], + ["Recorded evidence here: /tmp/image.png", null], + ["origin/main", null], + ["127.0.0.1:3000", null], + ["example.com/index.html", null], + ["example.pl/index.html", null], + ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { + expect(inlineCodeFilePathCandidate(source)).toBe(candidate); + }); +}); + +describe("parseMarkdownFileLink", () => { + // Both clients consume this table, so a path the web app recognizes is one + // the mobile app recognizes too. + it.each([ + ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"], + ["/home/me/notes.md", "/home/me/notes.md"], + ["/usr/local/bin/tool", "/usr/local/bin/tool"], + ["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/workspace/Makefile", "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/workspace/Makefile"], + ["/tmp/favicons/", "/tmp/favicons/"], + ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"], + ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"], + ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"], + ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"], + ["", "D:/Programme/t3code/ChatMarkdown.tsx"], + ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"], + ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"], + ["file://localhost/home/me/notes.md", "/home/me/notes.md"], + ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"], + ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"], + ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"], + ["./scripts/deploy", "./scripts/deploy"], + ["~/notes/today.md", "~/notes/today.md"], + ["AGENTS.md", "AGENTS.md"], + ["script.ts:10", "script.ts"], + ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"], + ])("recognizes %s as a file", (href, path) => { + expect(parseMarkdownFileLink(href)?.path).toBe(path); + }); + + it.each([ + "", + "#anchor", + "//cdn.example.com/clip.mp4", + "https://example.com/docs", + "mailto:someone@example.com", + "javascript:alert(1)", + "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/chat/settings", + "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/chat/settings#L3", + "/app#L1", + "readme", + "TODO:12", + ])("does not treat %s as a file", (href) => { + expect(parseMarkdownFileLink(href)).toBeNull(); + }); + + it("accepts conventional extensionless names with or without a position", () => { + expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" }); + expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 }); + expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" }); + }); + + it("reads positions from suffixes and line anchors", () => { + expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({ + path: "/Users/julius/project/src/main.ts", + line: 42, + column: 7, + }); + expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({ + path: "\\\\server\\share\\src\\main.ts", + line: 42, + column: 7, + }); + }); +}); + +describe("isWindowsDrivePathHref", () => { + it.each([ + ["C:\\repo\\image.png", true], + ["C:%5Crepo%5Cimage.png", true], + ["https://example.com/image.png", false], + ])("classifies %s as %s", (href, expected) => { + expect(isWindowsDrivePathHref(href)).toBe(expected); + }); +}); + +describe("extractMarkdownLinkHrefs", () => { + it("extracts angle-bracketed paths containing spaces", () => { + expect( + extractMarkdownLinkHrefs( + "[Open the Bike Receipts folder]()", + ), + ).toEqual(["/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts"]); + }); + + it("preserves ordinary destinations and ignores link titles", () => { + expect( + extractMarkdownLinkHrefs( + '[source](apps/web/src/markdown-links.ts "implementation") and [docs](https://example.com)', + ), + ).toEqual(["apps/web/src/markdown-links.ts", "https://example.com"]); + }); +}); + +describe("resolveMarkdownFileLinkTarget", () => { + it("resolves absolute posix file paths", () => { + expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe( + "/Users/julius/project/AGENTS.md", + ); + }); + + it("resolves relative file paths against cwd", () => { + expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe( + "/Users/julius/project/src/processRunner.ts:71", + ); + }); + + it("does not treat filename line references as external schemes", () => { + expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe( + "/Users/julius/project/script.ts:10", + ); + }); + + it("resolves bare file names against cwd", () => { + expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe( + "/Users/julius/project/AGENTS.md", + ); + }); + + it("maps #L line anchors to editor line suffixes", () => { + expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe( + "/Users/julius/project/src/main.ts:42:7", + ); + }); + + it("ignores external urls", () => { + expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull(); + expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull(); + }); + + it("does not double-decode file URLs", () => { + expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe( + "/Users/julius/project/file%20name.md", + ); + }); + + it("resolves file uri authorities as windows UNC paths", () => { + expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe( + "\\\\server\\share\\workspace-image.svg", + ); + }); + + it("resolves a localhost file uri as a local path", () => { + expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe( + "/home/me/notes.md", + ); + }); + + it("keeps an encoded final space in the absolute target", () => { + expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe( + "/tmp/repo/file.ts ", + ); + }); + + it("normalizes slash-prefixed windows drive paths before resolving", () => { + expect( + resolveMarkdownFileLinkTarget( + "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69", + ), + ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69"); + }); + + it("resolves angle-bracketed windows drive paths", () => { + expect( + resolveMarkdownFileLinkTarget( + "", + ), + ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1"); + }); + + it("does not treat app routes as file links, even with a line anchor", () => { + expect(resolveMarkdownFileLinkTarget("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/chat/settings")).toBeNull(); + expect(resolveMarkdownFileLinkTarget("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/chat/settings#L3", "/repo")).toBeNull(); + }); + + it("decodes an encoded drive colon in a file uri before dropping its slash", () => { + expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe( + "c:/Users/x/shot.png", + ); + }); +}); diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts similarity index 68% rename from packages/client-runtime/src/markdownLinks.ts rename to packages/shared/src/markdownLinks.ts index 29a337e49c42..e770f1b38338 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -1,20 +1,34 @@ -import { isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "./path.ts"; +import { + type FilePathPosition, + formatFilePathPosition, + parseFileUrlHref, + resolvePathLinkTarget, + splitFilePathPosition, +} from "./fileLinks.ts"; -const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; + const RELATIVE_FILE_PATH_PATTERN = /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; + const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; + const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; + const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; -const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; -const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; + const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; + const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; + const PATH_SEPARATOR_PATTERN = /[\\/]/; + const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; + const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; + // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -43,6 +57,7 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/workspace/", "/workspaces/", ] as const; + // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. const EXTENSIONLESS_FILE_NAMES = new Set([ "Makefile", @@ -73,7 +88,9 @@ const EXTENSIONLESS_FILE_NAMES = new Set([ "README", "CODEOWNERS", ]); + const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); + // These allowlists avoid classifying dotted directories such as `conf.d/` // or filenames such as `Makefile.in:12` as hosts. const GENERIC_HOSTNAME_TLDS = new Set([ @@ -103,6 +120,7 @@ const GENERIC_HOSTNAME_TLDS = new Set([ "store", "link", ]); + // Country codes also name file extensions. A :line suffix makes `.pl` // and `.pt` files more likely than hostnames. const COUNTRY_HOSTNAME_TLDS = new Set([ @@ -194,11 +212,6 @@ export function normalizeMarkdownLinkDestination(value: string): string { return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; } -/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ -export function stripSlashPrefixedWindowsDrive(path: string): string { - return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; -} - export function splitMarkdownLinkSearchAndHash(value: string): { readonly path: string; readonly hash: string; @@ -213,55 +226,7 @@ export function splitMarkdownLinkSearchAndHash(value: string): { }; } -/** - * Turns a `file:` URL into a host path, still percent-encoded so callers that - * decode every destination in one place do not decode file URLs twice. A - * non-localhost authority becomes a UNC share. - */ -export function parseFileUrlHref( - href: string, -): { readonly path: string; readonly hash: string } | null { - try { - const parsed = new URL(href); - if (parsed.protocol.toLowerCase() !== "file:") return null; - - const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; - const path = uncHostname - ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` - : parsed.pathname; - if (path.length === 0) return null; - return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash }; - } catch { - return null; - } -} - -export interface FilePathPosition { - readonly path: string; - readonly line?: number; - readonly column?: number; -} - -export function splitFilePathPosition(path: string, hash = ""): FilePathPosition { - const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN); - const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN); - if (!match?.[1]) return { path }; - - const line = Number.parseInt(match[1], 10); - const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10); - return { - path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path, - ...(line > 0 ? { line } : {}), - ...(column !== undefined && column > 0 ? { column } : {}), - }; -} - -export function formatFilePathPosition(position: FilePathPosition): string { - if (!position.line) return position.path; - return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; -} - -export function isRelativeFilePath(path: string): boolean { +function isRelativeFilePath(path: string): boolean { return ( RELATIVE_PATH_PREFIX_PATTERN.test(path) || (!path.startsWith("/") && !isWindowsAbsolutePath(path)) @@ -315,27 +280,54 @@ export function parseMarkdownFileLink(href: string): FilePathPosition | null { return looksLikeFilePath(position.path, path) ? position : null; } -export function fileBasename(path: string): string { - // A trailing separator is a valid way to write a directory. Trim it before - // taking the final segment so the label is never empty. - const trimmed = path.replace(/[/\\]+$/, ""); - if (trimmed.length === 0) return path; - const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; +const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; + +const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; + +export function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); + for (let index = 0; index < segments.length; index += 2) { + for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { + const span = match[1]?.trim(); + if (span) spans.push(span); + } + } + return spans; +} + +const MARKDOWN_LINK_HREF_PATTERN = + /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; + +export function extractMarkdownLinkHrefs(markdown: string): string[] { + const hrefs: string[] = []; + for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { + const href = (match[1] ?? match[2])?.trim(); + if (href) hrefs.push(href); + } + return hrefs; } -export function workspaceRelativeFilePath( - path: string, - workspaceRoot: string | null | undefined, +/** + * `baseDir` anchors relative links; it defaults to the workspace root and is the + * file's own directory when rendering a markdown file. `cwd` stays the workspace + * root so the result still knows whether the target is inside it. + */ +export function resolveMarkdownFileLinkTarget( + href: string | undefined, + cwd?: string, + baseDir: string | undefined = cwd, ): string | null { - if (!workspaceRoot) return null; - const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); - const normalizedRoot = stripSlashPrefixedWindowsDrive( - workspaceRoot.replaceAll("\\", "/"), - ).replace(/\/+$/, ""); - const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); - const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; - const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; - if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; - return normalizedPath.slice(normalizedRoot.length + 1); + if (!href) return null; + const target = parseMarkdownFileLink(href); + if (!target) return null; + + const pathWithPosition = formatFilePathPosition(target); + if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; + if (!baseDir) return null; + return resolvePathLinkTarget(pathWithPosition, baseDir); +} + +export function isWindowsDrivePathHref(href: string): boolean { + return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); } diff --git a/apps/web/src/markdown-list-indentation.ts b/packages/shared/src/markdownListIndentation.ts similarity index 100% rename from apps/web/src/markdown-list-indentation.ts rename to packages/shared/src/markdownListIndentation.ts diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts new file mode 100644 index 000000000000..dc1642043041 --- /dev/null +++ b/packages/shared/src/markdownPipeline.ts @@ -0,0 +1,197 @@ +import type { PluggableList } from "unified"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import remarkBreaks from "remark-breaks"; +import remarkGfm from "remark-gfm"; +import { remarkGithubAlerts } from "./markdownGithubAlerts.ts"; +import { remarkNormalizeListItemIndentation } from "./markdownListIndentation.ts"; +import { + CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, + remarkCodexDirectives, +} from "./codexMarkdownDirectives.ts"; +import { isWindowsDrivePathHref } from "./markdownLinks.ts"; + +type MarkdownImageHastNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: MarkdownImageHastNode[]; +}; + +function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { + return (node.children ?? []).filter( + (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), + ); +} + +/** + * An image that is the only content of its block (optionally wrapped in a + * link) is almost always a screenshot or figure, so it gets a reserved slot + * while it loads. Images mixed with text or other images — badge rows, icons + * in a sentence — stay inline at their natural size, since a placeholder taller + * than the image would move the page more than the image itself does. + */ +const STANDALONE_IMAGE_BLOCKS = new Set([ + "p", + "div", + "li", + "td", + "th", + "figure", + "center", + "blockquote", +]); + +function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { + const children = meaningfulHastChildren(node); + if (children.length !== 1) return undefined; + const only = children[0]; + if (only?.type !== "element") return undefined; + if (only.tagName === "img") return only; + // A link, emphasis, or similar inline wrapper around the image still counts + // as long as nothing else shares the block. + return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" + ? soleImageDescendant(only) + : undefined; +} + +function markStandaloneImages(node: MarkdownImageHastNode) { + // A raw `` on its own line reaches the root without a paragraph. + if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { + const image = soleImageDescendant(node); + if (image) image.properties = { ...image.properties, dataStandalone: true }; + } + node.children?.forEach((child) => { + if (child.type === "element") markStandaloneImages(child); + }); +} + +/** Carries authored image source metadata through the sanitizer to the image renderer. */ +function rehypePreserveImageSourceMeta() { + return (tree: MarkdownImageHastNode) => { + const visit = (node: MarkdownImageHastNode) => { + const src = node.properties?.src; + const title = node.properties?.title; + if (node.type === "element" && node.tagName === "img") { + node.properties = { + ...node.properties, + ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), + ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + markStandaloneImages(tree); + }; +} + +const CHAT_MARKDOWN_SANITIZE_SCHEMA = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), + code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], + blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], + div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], + a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], + img: [ + ...(defaultSchema.attributes?.img ?? []), + "dataLocalSrc", + "dataMarkdownTitle", + "dataStandalone", + ], + }, + protocols: { + ...defaultSchema.protocols, + href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "t3-context"], + src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"], + }, +} satisfies Parameters[0]; + +export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [ + remarkGfm, + remarkGithubAlerts, + remarkNormalizeListItemIndentation, + remarkCodexDirectives, + remarkPreserveCodeMeta, + remarkNormalizeLinksAndTagInlineCode, +]; + +export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ + remarkGfm, + remarkGithubAlerts, + remarkNormalizeListItemIndentation, + remarkCodexDirectives, + remarkBreaks, + remarkPreserveCodeMeta, + remarkNormalizeLinksAndTagInlineCode, +]; + +export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [ + rehypeRaw, + rehypePreserveImageSourceMeta, + [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], +]; + +type MarkdownAstNode = { + type?: string; + meta?: unknown; + url?: string; + data?: { + hProperties?: Record; + }; + children?: MarkdownAstNode[]; +}; + +function remarkPreserveCodeMeta() { + return (tree: MarkdownAstNode) => { + const visit = (node: MarkdownAstNode) => { + if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataCodeMeta: node.meta.trim(), + }, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + +/** + * Preserve Windows drive links as allowed `file:` URLs before sanitization. + * The same traversal tags inline code while it can still be distinguished + * from fenced code. Code inside links stays untagged to avoid nested anchors. + */ +function remarkNormalizeLinksAndTagInlineCode() { + return (tree: MarkdownAstNode) => { + const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if ( + (node.type === "link" || node.type === "definition") && + typeof node.url === "string" && + /^[A-Za-z]:[\\/]/.test(node.url) + ) { + node.url = `file:///${node.url.replaceAll("\\", "/")}`; + } + if (node.type === "inlineCode" && !insideLink) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataInlineCode: "", + }, + }; + } + const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; + node.children?.forEach((child) => visit(child, childInsideLink)); + }; + + visit(tree, false); + }; +} diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 799f225fb6e2..d6ae76e848c1 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + fileBasename, + workspaceRelativeFilePath, isExplicitRelativePath, isUncPath, isWindowsAbsolutePath, @@ -44,3 +46,40 @@ describe("path helpers", () => { expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); }); }); + +describe("fileBasename", () => { + it.each([ + ["/tmp/favicons/", "favicons"], + ["C:\\Users\\kelchm\\.claude\\", ".claude"], + ["/tmp/", "tmp"], + ["AGENTS.md", "AGENTS.md"], + ["/", "/"], + ])("labels %s as %s", (path, basename) => { + expect(fileBasename(path)).toBe(basename); + }); +}); + +describe("workspaceRelativeFilePath", () => { + it.each([ + ["/repo/project/src/main.ts", "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/repo/project", "src/main.ts"], + ["/repo/project/src/main.ts", "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/repo/project/", "src/main.ts"], + ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], + ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], + ["/Repo/Project/src/main.ts", "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/repo/project", null], + ["/tmp/case/project/probe.txt", "/tmp/case/Project", null], + ["//tmp/case/project/probe.txt", "//tmp/case/Project", null], + ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"], + ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"], + ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"], + ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"], + ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], + ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"], + ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], + ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "], + ["/tmp/report.ts", "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/repo/project", null], + ["/repo/project-two/a.ts", "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/repo/project", null], + ["/repo/project/a.ts", undefined, null], + ])("relates %s to %s", (path, workspaceRoot, relativePath) => { + expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath); + }); +}); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index b6758d814aa8..2eabb5995057 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -53,3 +53,39 @@ export function normalizeProjectPathForComparison(value: string): string { } return normalized; } + +const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; + +/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ +export function stripSlashPrefixedWindowsDrive(path: string): string { + return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; +} + +export function fileBasename(path: string): string { + // A trailing separator is a valid way to write a directory. Trim it before + // taking the final segment so the label is never empty. + const trimmed = path.replace(/[/\\]+$/, ""); + if (trimmed.length === 0) return path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; +} + +export function workspaceRelativeFilePath( + path: string, + workspaceRoot: string | null | undefined, +): string | null { + if (!workspaceRoot) return null; + const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/")); + const normalizedRoot = stripSlashPrefixedWindowsDrive( + workspaceRoot.replaceAll("\\", "/"), + ).replace(/\/+$/, ""); + const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; + return normalizedPath.slice(normalizedRoot.length + 1); +} + +export function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || isWindowsAbsolutePath(value); +} diff --git a/packages/shared/src/proposedPlanText.test.ts b/packages/shared/src/proposedPlanText.test.ts new file mode 100644 index 000000000000..f0bcbe35a46a --- /dev/null +++ b/packages/shared/src/proposedPlanText.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; +import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; + +describe("proposedPlanTitle", () => { + it("reads the first markdown heading as the plan title", () => { + expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC"); + }); + + it("returns null when the plan has no heading", () => { + expect(proposedPlanTitle("- step 1")).toBeNull(); + }); +}); + +describe("stripDisplayedPlanMarkdown", () => { + it("drops the leading title heading from displayed plan markdown", () => { + expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe( + "- step 1", + ); + }); + + it("preserves non-summary headings after dropping the title heading", () => { + expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe( + "## Scope\n\n- step 1", + ); + }); +}); diff --git a/packages/shared/src/proposedPlanText.ts b/packages/shared/src/proposedPlanText.ts new file mode 100644 index 000000000000..52d568898ed6 --- /dev/null +++ b/packages/shared/src/proposedPlanText.ts @@ -0,0 +1,20 @@ +export function proposedPlanTitle(planMarkdown: string): string | null { + const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim(); + return heading && heading.length > 0 ? heading : null; +} + +export function stripDisplayedPlanMarkdown(planMarkdown: string): string { + const lines = planMarkdown.trimEnd().split(/\r?\n/); + const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines]; + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/); + if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") { + sourceLines.shift(); + while (sourceLines[0]?.trim().length === 0) { + sourceLines.shift(); + } + } + return sourceLines.join("\n"); +} diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index 73b73a4de91e..11f05d8ad608 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -88,6 +88,10 @@ export const THEME_COLOR_ROLES = [ "messageActionHover", "codeBackground", "codeForeground", + "searchMatchBackground", + "searchMatchForeground", + "searchMatchActiveBackground", + "searchMatchActiveForeground", "sidebar", "sidebarForeground", "sidebarMutedForeground", @@ -169,6 +173,10 @@ export const T3_CHAT_THEME: ThemeDefinition = { messageActionHover: "oklch(0.539042 0.197866 0.305)", codeBackground: "oklch(0.953855 0.019695 315.668)", codeForeground: "oklch(0.445128 0.13005 307.026)", + searchMatchBackground: "oklch(0.869588 0.06751 334.899)", + searchMatchForeground: "oklch(0.325698 0.116116 325.037)", + searchMatchActiveBackground: "oklch(0.591646 0.217985 0.584)", + searchMatchActiveForeground: "oklch(1 0 0)", sidebar: "oklch(0.928886 0.031178 322.592)", sidebarForeground: "oklch(0.396296 0.025134 285.196)", sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)", @@ -229,6 +237,10 @@ export const T3_CHAT_THEME: ThemeDefinition = { messageActionHover: "oklch(0.458754 0.184639 3.857)", codeBackground: "oklch(0.22813 0.020366 307.469)", codeForeground: "oklch(0.848703 0.064239 306.645)", + searchMatchBackground: "oklch(0.313674 0.030572 310.061)", + searchMatchForeground: "oklch(0.980735 0.004092 301.426)", + searchMatchActiveBackground: "oklch(0.460685 0.185347 4.099)", + searchMatchActiveForeground: "oklch(0.901233 0.057189 343.694)", sidebar: "oklch(0.185778 0.019368 322.159)", sidebarForeground: "oklch(0.967434 0.001326 286.375)", sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)", @@ -296,6 +308,10 @@ export const GROVE_THEME: ThemeDefinition = { messageActionHover: "oklch(0.488753 0.096536 77.829)", codeBackground: "oklch(0.955888 0.004783 158.391)", codeForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.891377 0.026164 164.929)", + searchMatchForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchActiveBackground: "oklch(0.523295 0.112292 158.089)", + searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", sidebar: "oklch(0.936464 0.014601 163.554)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)", @@ -356,6 +372,10 @@ export const GROVE_THEME: ThemeDefinition = { messageActionHover: "oklch(0.815227 0.117902 84.21)", codeBackground: "oklch(0.312979 0.018942 164.082)", codeForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.464636 0.066083 158.72)", + searchMatchForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchActiveBackground: "oklch(0.796228 0.133058 157.319)", + searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", sidebar: "oklch(0.309925 0.032827 160.944)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)", @@ -423,6 +443,10 @@ export const OCEAN_THEME: ThemeDefinition = { messageActionHover: "oklch(0.45151 0.074407 201.516)", codeBackground: "oklch(0.957684 0.002906 253.68)", codeForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.895373 0.023469 241.913)", + searchMatchForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchActiveBackground: "oklch(0.536684 0.120219 247.01)", + searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", sidebar: "oklch(0.939254 0.01193 241.729)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)", @@ -483,6 +507,10 @@ export const OCEAN_THEME: ThemeDefinition = { messageActionHover: "oklch(0.815308 0.096174 199.862)", codeBackground: "oklch(0.29661 0.021883 251.968)", codeForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.439946 0.0561 243.479)", + searchMatchForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchActiveBackground: "oklch(0.758933 0.105833 241.548)", + searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", sidebar: "oklch(0.290387 0.032043 247.274)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)", @@ -550,6 +578,10 @@ export const EMBER_THEME: ThemeDefinition = { messageActionHover: "oklch(0.471223 0.145843 24.688)", codeBackground: "oklch(0.959965 0.002668 47.512)", codeForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.899296 0.022939 49.163)", + searchMatchForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchActiveBackground: "oklch(0.552831 0.129438 44.656)", + searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", sidebar: "oklch(0.942267 0.01151 50.785)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)", @@ -610,6 +642,10 @@ export const EMBER_THEME: ThemeDefinition = { messageActionHover: "oklch(0.775116 0.117953 29.014)", codeBackground: "oklch(0.299662 0.017229 39.973)", codeForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.442681 0.0608 50.795)", + searchMatchForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchActiveBackground: "oklch(0.762174 0.124117 52.082)", + searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", sidebar: "oklch(0.293349 0.029554 46.882)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)", @@ -677,6 +713,10 @@ export const IRIS_THEME: ThemeDefinition = { messageActionHover: "oklch(0.471003 0.16748 340.687)", codeBackground: "oklch(0.95997 0.004338 306.542)", codeForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchBackground: "oklch(0.897143 0.028558 299.758)", + searchMatchForeground: "oklch(0.222003 0.03479 328.979)", + searchMatchActiveBackground: "oklch(0.525348 0.15373 294.176)", + searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)", sidebar: "oklch(0.941387 0.014687 300.474)", sidebarForeground: "oklch(0.222003 0.03479 328.979)", sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)", @@ -737,6 +777,10 @@ export const IRIS_THEME: ThemeDefinition = { messageActionHover: "oklch(0.813537 0.114101 337.23)", codeBackground: "oklch(0.281873 0.028308 295.193)", codeForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchBackground: "oklch(0.395417 0.085554 294.182)", + searchMatchForeground: "oklch(0.990339 0.008411 325.64)", + searchMatchActiveBackground: "oklch(0.671712 0.169136 293.929)", + searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)", sidebar: "oklch(0.266743 0.044689 294.138)", sidebarForeground: "oklch(0.990339 0.008411 325.64)", sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)", diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts new file mode 100644 index 000000000000..cf3ad2910b05 --- /dev/null +++ b/packages/shared/src/threadFindText.test.ts @@ -0,0 +1,114 @@ +import { expect, it } from "vite-plus/test"; +import { searchableMessageSegments } from "./threadFindText.ts"; + +it("excludes review attachments rendered as context chips", () => { + const text = [ + "Before **review**", + '', + "Keep **this literal** comment.", + "```diff", + "+ hidden patch content", + "```", + "", + "After review", + ].join("\n"); + expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([ + "Before review", + " After review", + ]); +}); + +it("keeps malformed review tags visible, matching the message renderer", () => { + const text = "not a valid attachment"; + expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([text]); +}); + +it("excludes structured context chips without joining text across them", () => { + expect( + searchableMessageSegments({ + role: "user", + streaming: false, + text: "before[hidden label](t3-context://v1/terminal/terminal_1)after", + context: { version: 1, records: [] }, + }), + ).toEqual(["before", "after"]); +}); + +it("does not upgrade literal legacy tags in messages with structured context", () => { + const text = + "\n- Terminal 1 line 12:\n 12 | visible output\n"; + expect( + searchableMessageSegments({ + role: "user", + streaming: false, + text, + context: { version: 1, records: [] }, + })?.join("\n"), + ).toContain("visible output"); + expect(searchableMessageSegments({ role: "user", streaming: false, text })).toEqual([]); +}); + +it("keeps context reference syntax inside code searchable", () => { + expect( + searchableMessageSegments({ + role: "user", + streaming: false, + text: "`[label](t3-context://v1/terminal/terminal_1)`", + }), + ).toEqual(["[label](t3-context://v1/terminal/terminal_1)"]); +}); + +it("excludes repeated legacy attachments containing literal context tags", () => { + const context = + "\n- Terminal 1 line 12:\n 12 | literal\n"; + expect( + searchableMessageSegments({ + role: "user", + streaming: false, + text: `Fix this\n\n${context}\n\n${context}`, + }), + ).toEqual(["Fix this"]); +}); + +const assistantSegments = (text: string, cwd?: string) => + searchableMessageSegments({ role: "assistant", text, streaming: false }, cwd); + +it("searches displayed file-chip labels rather than authored labels or paths", () => { + expect( + assistantSegments("[important description](/tmp/actual.ts). `/tmp/inline-example.ts:42`"), + ).toEqual(["actual.ts. inline-example.ts · L42"]); + expect(assistantSegments("[label](src/main.ts#L3C2)", "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/workspace/repo")).toEqual([ + "main.ts · L3:C2", + ]); + expect(assistantSegments("[label](src/main.ts#L3C2)")).toEqual(["label"]); +}); + +it("includes the same parent suffixes for duplicate filenames as the renderer", () => { + expect( + assistantSegments( + "[first](src/main.ts) and `/workspace/repo/tests/main.ts:2`", + "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/workspace/repo", + ), + ).toEqual(["main.ts · repo/src and main.ts · repo/tests · L2"]); + expect( + assistantSegments( + "[first](src/main.ts) and `/workspace/repo/src/main.ts:2`", + "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/workspace/repo", + ), + ).toEqual(["main.ts and main.ts · L2"]); +}); + +it("keeps literal file paths in fences and user messages", () => { + expect(assistantSegments("```text\n/tmp/file.ts:42\n```")).toEqual(["/tmp/file.ts:42\n"]); + expect( + searchableMessageSegments({ role: "user", text: "`/tmp/file.ts:42`", streaming: false }), + ).toEqual(["/tmp/file.ts:42"]); +}); + +it("indexes nested disclosure summaries and bodies in rendered order", () => { + expect( + assistantSegments( + "
Outer

first

Inner

second

", + ), + ).toEqual(["Outer", "first", "Inner", "second"]); +}); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts new file mode 100644 index 000000000000..6298f8348e1f --- /dev/null +++ b/packages/shared/src/threadFindText.ts @@ -0,0 +1,169 @@ +import { renderCodexFileCitationsAsMarkdown } from "./codexMarkdownDirectives.ts"; +import { + buildFileLinkParentSuffixByPath, + splitFilePathPosition, + fileLinkLabel, +} from "./fileLinks.ts"; +import { + extractMarkdownLinkHrefs, + extractInlineCodeSpans, + inlineCodeFilePathCandidate, + resolveMarkdownFileLinkTarget, +} from "./markdownLinks.ts"; +import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts"; +import { parseComposerContextHref } from "./composerContextReferences.ts"; +import type { OrchestrationMessage } from "@t3tools/contracts"; +import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { + CHAT_MARKDOWN_REHYPE_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, +} from "./markdownPipeline.ts"; + +// Inline wrappers (including Shiki token spans) must not split a search phrase. +export const THREAD_FIND_BLOCK_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "br", + "dd", + "details", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "summary", + "table", + "tbody", + "td", + "th", + "thead", + "tr", + "ul", +]); + +const assistantProcessor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); +const userProcessor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) + .use(remarkRehype, { allowDangerousHtml: true }); + +interface TextTree { + readonly type: string; + readonly tagName?: string; + readonly value?: string; + readonly properties?: { + readonly href?: unknown; + readonly src?: unknown; + readonly dataInlineCode?: unknown; + }; + readonly children?: ReadonlyArray; +} + +/** Uses the renderer's Markdown transforms, without mounting folded/virtualized rows. */ +function markdownThreadFindText(markdown: string, userMessage = false, cwd?: string): string[] { + const processor = userMessage ? userProcessor : assistantProcessor; + const tree = processor.runSync(processor.parse(markdown)); + const paths = userMessage + ? [] + : [ + ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)), + ...extractInlineCodeSpans(markdown).flatMap( + (span) => inlineCodeFilePathCandidate(span) ?? [], + ), + ].flatMap((href) => { + const target = resolveMarkdownFileLinkTarget(href, cwd); + return target ? [splitFilePathPosition(target).path] : []; + }); + const parentSuffixes = buildFileLinkParentSuffixByPath(paths); + const segments: string[] = []; + let text = ""; + const flush = () => { + if (text.trim()) segments.push(text); + text = ""; + }; + const visit = (node: TextTree, inPre = false) => { + const href = node.properties?.href ?? node.properties?.src; + if (userMessage && typeof href === "string" && parseComposerContextHref(href)) { + flush(); + return; + } + if (!userMessage) { + let candidate: string | null = null; + if (node.tagName === "a" && typeof href === "string") { + candidate = href; + } else if ( + node.tagName === "code" && + !inPre && + node.properties?.dataInlineCode !== undefined + ) { + candidate = inlineCodeFilePathCandidate( + (node.children ?? []).map((child) => child.value ?? "").join(""), + ); + } + const target = candidate ? resolveMarkdownFileLinkTarget(candidate, cwd) : null; + if (target) { + text += fileLinkLabel(splitFilePathPosition(target), parentSuffixes); + return; + } + } + const block = THREAD_FIND_BLOCK_TAGS.has(node.tagName ?? ""); + if (block) flush(); + if (node.type === "text" || (userMessage && node.type === "raw")) { + text += inPre ? (node.value ?? "") : (node.value ?? "").replace(/\r?\n/g, " "); + } + for (const child of node.children ?? []) visit(child, inPre || node.tagName === "pre"); + if (block) flush(); + }; + visit(tree); + flush(); + return segments; +} + +export function searchablePlanSegments(markdown: string, cwd?: string): readonly string[] { + return [ + proposedPlanTitle(markdown) ?? "Proposed plan", + ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd), + ]; +} + +export function searchableMessageSegments( + message: Pick, + cwd?: string, +): readonly string[] | null { + if (message.role === "user") { + const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text; + return markdownThreadFindText(text, true); + } + if (message.role !== "assistant") return null; + return markdownThreadFindText( + message.text || (message.streaming ? "" : "(empty response)"), + false, + cwd, + ); +} diff --git a/packages/shared/src/threadSearch.test.ts b/packages/shared/src/threadSearch.test.ts new file mode 100644 index 000000000000..d25bc91fa043 --- /dev/null +++ b/packages/shared/src/threadSearch.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "vite-plus/test"; +import { findThreadSearchOccurrences, splitThreadSearchText } from "./threadSearch.ts"; + +it("finds non-overlapping occurrences without invalidating Unicode offsets", () => { + expect(findThreadSearchOccurrences("Deploy the Deployment", "deploy")).toEqual([0, 11]); + expect(findThreadSearchOccurrences("aaaa", "aa")).toEqual([0, 2]); + expect(findThreadSearchOccurrences("İİİ needle", "needle")).toEqual([4]); +}); + +it("splits search text into highlighted and unhighlighted parts", () => { + expect(splitThreadSearchText("one TWO three two", " two ")).toEqual([ + { text: "one ", highlighted: false, start: 0 }, + { text: "TWO", highlighted: true, start: 4 }, + { text: " three ", highlighted: false, start: 7 }, + { text: "two", highlighted: true, start: 14 }, + ]); +}); + +it("matches both Greek sigma forms without changing source offsets", () => { + expect(findThreadSearchOccurrences("ΟΣ ος οσ", "Σ")).toEqual([1, 4, 7]); + expect(findThreadSearchOccurrences("ΟΣ ος οσ", "ς")).toEqual([1, 4, 7]); + expect(findThreadSearchOccurrences("İ ΟΣ", "Σ")).toEqual([3]); +}); diff --git a/packages/shared/src/threadSearch.ts b/packages/shared/src/threadSearch.ts new file mode 100644 index 000000000000..df86c3b94b2c --- /dev/null +++ b/packages/shared/src/threadSearch.ts @@ -0,0 +1,70 @@ +interface ThreadSearchTextPart { + readonly text: string; + readonly highlighted: boolean; + readonly start: number; +} + +function foldThreadSearchCase(text: string): string { + const lowered = text.toLowerCase().replace(/ς/g, "σ"); + if (lowered.length === text.length) return lowered; + + let folded = ""; + for (const character of text) { + const loweredCharacter = character.toLowerCase().replace(/ς/g, "σ"); + folded += loweredCharacter.length === character.length ? loweredCharacter : character; + } + return folded; +} + +function scanThreadSearchOccurrences(text: string, query: string, offsets?: number[]): number { + if (text.length === 0 || query.length === 0) return 0; + + const foldedText = foldThreadSearchCase(text); + const foldedQuery = foldThreadSearchCase(query); + let count = 0; + let cursor = 0; + + while (cursor <= foldedText.length - foldedQuery.length) { + const offset = foldedText.indexOf(foldedQuery, cursor); + if (offset === -1) break; + offsets?.push(offset); + count += 1; + cursor = offset + foldedQuery.length; + } + return count; +} + +export function countThreadSearchOccurrences(text: string, query: string): number { + return scanThreadSearchOccurrences(text, query); +} + +export function findThreadSearchOccurrences(text: string, query: string): number[] { + const offsets: number[] = []; + scanThreadSearchOccurrences(text, query, offsets); + return offsets; +} + +export function splitThreadSearchText(text: string, query: string): ThreadSearchTextPart[] { + const normalizedQuery = query.trim(); + if (normalizedQuery.length === 0) { + return [{ text, highlighted: false, start: 0 }]; + } + + const parts: ThreadSearchTextPart[] = []; + let cursor = 0; + for (const offset of findThreadSearchOccurrences(text, normalizedQuery)) { + if (offset > cursor) { + parts.push({ text: text.slice(cursor, offset), highlighted: false, start: cursor }); + } + parts.push({ + text: text.slice(offset, offset + normalizedQuery.length), + highlighted: true, + start: offset, + }); + cursor = offset + normalizedQuery.length; + } + if (cursor < text.length) { + parts.push({ text: text.slice(cursor), highlighted: false, start: cursor }); + } + return parts; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6feae2edd57c..8278d9919f49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,15 +687,6 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.16)(react@19.2.6) - rehype-raw: - specifier: ^7.0.0 - version: 7.0.0 - rehype-sanitize: - specifier: ^6.0.0 - version: 6.0.0 - remark-breaks: - specifier: ^4.0.0 - version: 4.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -855,28 +846,10 @@ importers: effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) - mdast-util-directive: - specifier: ^3.1.0 - version: 3.1.0 - micromark-extension-directive: - specifier: ^4.0.0 - version: 4.0.0 - micromark-util-character: - specifier: ^2.1.1 - version: 2.1.1 - remark-parse: - specifier: ^11.0.0 - version: 11.0.0 - unified: - specifier: ^11.0.5 - version: 11.0.5 devDependencies: '@effect/vitest': specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2)) - micromark-util-types: - specifier: ^2.0.2 - version: 2.0.2 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -955,6 +928,36 @@ importers: jose: specifier: 'catalog:' version: 6.2.2 + mdast-util-directive: + specifier: ^3.1.0 + version: 3.1.0 + micromark-extension-directive: + specifier: ^4.0.0 + version: 4.0.0 + micromark-util-character: + specifier: ^2.1.1 + version: 2.1.1 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-rehype: + specifier: ^11.1.2 + version: 11.1.2 + unified: + specifier: ^11.0.5 + version: 11.0.5 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -968,6 +971,9 @@ importers: '@types/node': specifier: 24.12.4 version: 24.12.4 + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)