authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-10-09 02:24:50+13:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-08 08:24:50-05:00
log12069eefd707f78eafc27812027c994eea0613cf
tree7fe8591a6067391ce75cd079ed9f74d7698a47a7
parenta4c9494b0e3606775cc5fc929fc138399288bd43
signature Signed by PGP key B5690EEEBB952194

feat(web): find messages and plans in the current thread (#10439)

Co-authored-by: Yash Singh <saiansh2525@gmail.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

151 files changed, 9801 insertions(+), 3263 deletions(-)

apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts+3-3
......@@ -1,10 +1,10 @@
1import { fileBasename } from "@t3tools/shared/path";
2import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
13import {
2 fileBasename,
3 formatFilePathPosition,
44 inlineCodeFilePathCandidate,
55 normalizeMarkdownLinkDestination,
66 parseMarkdownFileLink,
7} from "@t3tools/client-runtime/markdown-links";
7} from "@t3tools/shared/markdownLinks";
88import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
99import { videoMimeType } from "@t3tools/shared/video";
1010
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+1-1
......@@ -1,5 +1,5 @@
11import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
2import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
33import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
44import { imageMimeType } from "@t3tools/shared/image";
55import { isWindowsAbsolutePath } from "@t3tools/shared/path";
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+1-1
......@@ -29,7 +29,7 @@ import type {
2929import {
3030 appendCodexArtifactTemplateUsePrompt,
3131 type CodexArtifactTemplate,
32} from "@t3tools/client-runtime/codex-artifact-templates";
32} from "@t3tools/shared/codexArtifactTemplates";
3333import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
3434import {
3535 presentPendingBackgroundWork,
apps/mobile/src/features/threads/ThreadFeed.tsx+3-3
......@@ -36,9 +36,9 @@ import { writeComposerContextClipboard } from "../../lib/composerContextClipboar
3636import {
3737 codexArtifactTemplatePresentationLabel,
3838 type CodexArtifactTemplate,
39} from "@t3tools/client-runtime/codex-artifact-templates";
39} from "@t3tools/shared/codexArtifactTemplates";
4040import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
41import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
41import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
4242import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";
4343import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
4444import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
......@@ -50,7 +50,7 @@ import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presen
5050import {
5151 renderCodexFileCitationsAsMarkdown,
5252 splitCodexArtifactTemplateMarkdown,
53} from "@t3tools/client-runtime/codex-markdown-directives";
53} from "@t3tools/shared/codexMarkdownDirectives";
5454import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
5555import { imageMimeType } from "@t3tools/shared/image";
5656import { videoMimeType } from "@t3tools/shared/video";
apps/mobile/src/features/threads/fileChipMenu.ts+1-1
......@@ -1,4 +1,4 @@
1import { fileBasename } from "@t3tools/client-runtime/markdown-links";
1import { fileBasename } from "@t3tools/shared/path";
22import type { ThreadId } from "@t3tools/contracts";
33import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
44import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";
apps/mobile/src/features/threads/thread-search-match.tsx+2-41
......@@ -1,48 +1,9 @@
1import { splitThreadSearchText } from "@t3tools/shared/threadSearch";
12import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
23
34import { AppText as Text } from "../../components/AppText";
45import { cn } from "../../lib/cn";
56
6function foldAsciiCase(value: string): string {
7 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
8}
9
10function splitHighlightParts(text: string, query: string) {
11 const normalizedText = foldAsciiCase(text);
12 const normalizedQuery = foldAsciiCase(query.trim());
13 if (normalizedQuery.length === 0) {
14 return [{ text, highlighted: false, start: 0 }];
15 }
16
17 const parts: Array<{
18 readonly text: string;
19 readonly highlighted: boolean;
20 readonly start: number;
21 }> = [];
22 let cursor = 0;
23 while (cursor < text.length) {
24 const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
25 if (matchIndex === -1) {
26 parts.push({ text: text.slice(cursor), highlighted: false, start: cursor });
27 break;
28 }
29 if (matchIndex > cursor) {
30 parts.push({
31 text: text.slice(cursor, matchIndex),
32 highlighted: false,
33 start: cursor,
34 });
35 }
36 parts.push({
37 text: text.slice(matchIndex, matchIndex + normalizedQuery.length),
38 highlighted: true,
39 start: matchIndex,
40 });
41 cursor = matchIndex + normalizedQuery.length;
42 }
43 return parts;
44}
45
467export function ThreadSearchMatchExcerpt(props: {
478 readonly match: EnvironmentThreadSearchMatch;
489 readonly query: string;
......@@ -51,7 +12,7 @@ export function ThreadSearchMatchExcerpt(props: {
5112 readonly sidebar?: boolean;
5213}) {
5314 const isUser = props.match.source === "user";
54 const parts = splitHighlightParts(props.match.snippet, props.query);
15 const parts = splitThreadSearchText(props.match.snippet, props.query);
5516 return (
5617 <Text
5718 className={cn(
apps/server/src/auth/RpcAuthorization.ts+2
......@@ -47,6 +47,8 @@ export const RPC_REQUIRED_SCOPES = {
4747 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
4848 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
4949 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
50 [ORCHESTRATION_V2_WS_METHODS.searchThread]: AuthOrchestrationReadScope,
51 [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: AuthOrchestrationReadScope,
5052 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
5153 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,
5254 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope,
apps/server/src/observability/RpcInstrumentation.ts+2
......@@ -21,6 +21,8 @@ const RPC_AGGREGATES = {
2121 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",
2222 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",
2323 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: "orchestration",
24 [ORCHESTRATION_V2_WS_METHODS.searchThread]: "orchestration",
25 [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: "orchestration",
2426 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration",
2527 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2",
2628 [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration",
apps/server/src/orchestration-v2/Orchestrator.ts+42
......@@ -1,3 +1,8 @@
1import type {
2 OrchestrationV2SearchThreadInput,
3 OrchestrationV2SearchThreadResult,
4 OrchestrationV2ThreadHistoryPage,
5} from "@t3tools/contracts";
16import {
27 latestExecutedRun,
38 latestRootProviderFailure,
......@@ -281,6 +286,18 @@ export interface OrchestratorV2Shape {
281286 readonly dispatch: (
282287 command: OrchestrationV2ServerCommand,
283288 ) => Effect.Effect<OrchestratorV2DispatchResult, OrchestratorV2Error>;
289 readonly searchThreadStream: (
290 input: OrchestrationV2SearchThreadInput,
291 ) => Stream.Stream<OrchestrationV2SearchThreadResult, OrchestratorV2Error>;
292 readonly searchThread: (
293 input: OrchestrationV2SearchThreadInput,
294 ) => Effect.Effect<OrchestrationV2SearchThreadResult, OrchestratorV2Error>;
295 readonly getThreadHistoryPage: (
296 threadId: ThreadId,
297 cursor: string,
298 throughEntryId?: string,
299 conversationOnly?: boolean,
300 ) => Effect.Effect<OrchestrationV2ThreadHistoryPage, OrchestratorV2Error>;
284301 readonly getTimelinePage: (
285302 threadId: ThreadId,
286303 options: ProjectionTimelinePageOptions,
......@@ -10817,6 +10834,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1081710834 recoverDelegatedTask,
1081810835 delegatedTaskResultPending,
1081910836 dispatch: dispatchWithReceipt,
10837 searchThreadStream: (input) =>
10838 projectionStore
10839 .searchThreadStream(input)
10840 .pipe(
10841 Stream.mapError(
10842 (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }),
10843 ),
10844 ),
10845 searchThread: (input) =>
10846 projectionStore
10847 .searchThread(input)
10848 .pipe(
10849 Effect.mapError(
10850 (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }),
10851 ),
10852 ),
10853 getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) =>
10854 projectionStore
10855 .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly)
10856 .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))),
1082010857 getTimelinePage: (threadId, options) =>
1082110858 projectionStore
1082210859 .getTimelinePage(threadId, options)
......@@ -10951,6 +10988,11 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
1095110988 cause: "Orchestration V2 live runtime is not configured.",
1095210989 }),
1095310990 ),
10991 searchThreadStream: (input) =>
10992 Stream.fail(new OrchestratorProjectionError({ threadId: input.threadId })),
10993 searchThread: (input) =>
10994 Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })),
10995 getThreadHistoryPage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
1095410996 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
1095510997 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
1095610998 getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })),
apps/server/src/orchestration-v2/ProjectionStore.ts+207-6
......@@ -1,3 +1,11 @@
1import { projectTurnItemForWire } from "./WireProjection.ts";
2import * as Stream from "effect/Stream";
3import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts";
4import type {
5 OrchestrationV2SearchThreadInput,
6 OrchestrationV2SearchThreadResult,
7 OrchestrationV2ThreadHistoryPage,
8} from "@t3tools/contracts";
19import {
210 latestRootProviderFailure,
311 latestUnheldRun,
......@@ -28,7 +36,6 @@ import type {
2836 ProviderThreadId,
2937 ProviderTurnId,
3038 RunAttemptId,
31 MessageId,
3239} from "@t3tools/contracts";
3340import {
3441 OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema,
......@@ -49,6 +56,7 @@ import {
4956 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,
5057 orchestrationV2RunWorkStartedAt,
5158 RunId,
59 MessageId,
5260 RuntimeRequestId,
5361 CheckpointScopeId,
5462 ThreadId,
......@@ -74,8 +82,13 @@ import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp";
7482import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts";
7583import {
7684 isThreadHistoryUserTurn,
85 isConversationHistoryItem,
7786 isThreadHistoryTurnStart,
7887 THREAD_HISTORY_MAX_RAW_TURNS,
88 selectHistoryPageFromCursor,
89 selectRecentTimelineWindow,
90 THREAD_HISTORY_PAGE_POLICY,
91 OLDER_THREAD_USER_TURN_LIMIT,
7992} from "./threadHistoryPaging.ts";
8093
8194export class ProjectionStoreApplyEventError extends Schema.TaggedError<ProjectionStoreApplyEventError>()(
......@@ -318,9 +331,21 @@ export interface ShellSnapshotOptions {
318331}
319332
320333export interface ProjectionStoreV2Shape {
334 readonly searchThreadStream: (
335 input: OrchestrationV2SearchThreadInput,
336 ) => Stream.Stream<OrchestrationV2SearchThreadResult, ProjectionStoreV2Error>;
337 readonly searchThread: (
338 input: OrchestrationV2SearchThreadInput,
339 ) => Effect.Effect<OrchestrationV2SearchThreadResult, ProjectionStoreV2Error>;
321340 readonly getThreadAttachmentIds: (
322341 threadId: ThreadId,
323342 ) => Effect.Effect<ReadonlyArray<string>, ProjectionStoreV2Error>;
343 readonly getThreadHistoryPage: (
344 threadId: ThreadId,
345 cursor: string,
346 throughEntryId?: string,
347 conversationOnly?: boolean,
348 ) => Effect.Effect<OrchestrationV2ThreadHistoryPage, ProjectionStoreV2Error>;
324349 readonly getTimelinePage: (
325350 threadId: ThreadId,
326351 options: ProjectionTimelinePageOptions,
......@@ -969,6 +994,11 @@ type ShellRunItemCountRow = {
969994 readonly item_count: number;
970995};
971996
997const encodeTimelineSources = Schema.encodeEffect(
998 Schema.fromJsonString(
999 Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.String })),
1000 ),
1001);
9721002const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
9731003
9741004const encodeThreadPayload = Schema.encodeEffect(
......@@ -4706,12 +4736,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
47064736 );
47074737
47084738 // Only the timeline index is assembled across fork ancestry. Payloads are read
4709 // after page selection, so a small MCP page never hydrates all tool output.
4739 // after page selection, so history and MCP pages hydrate only the requested rows.
47104740 type TimelineIndexItem = Pick<
47114741 OrchestrationV2TurnItem,
47124742 "id" | "threadId" | "runId" | "nodeId" | "type"
47134743 > & {
4714 readonly inputIntent?: "queued_turn";
4744 readonly inputIntent?: Extract<
4745 OrchestrationV2TurnItem,
4746 { type: "user_message" }
4747 >["inputIntent"];
4748 readonly createdBy?: "user" | "agent" | "system";
4749 readonly messageId?: MessageId;
47154750 };
47164751 type TimelineIndexRow = {
47174752 readonly sourceThreadId: ThreadId;
......@@ -4738,9 +4773,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
47384773 run_id: string | null;
47394774 node_id: string | null;
47404775 type: OrchestrationV2TurnItem["type"];
4741 input_intent: string | null;
4776 input_intent: NonNullable<TimelineIndexItem["inputIntent"]> | null;
4777 created_by: "user" | "agent" | "system" | null;
4778 message_id: string | null;
47424779 }>`SELECT turn_item_id, run_id, node_id, type,
4743 CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent
4780 CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent,
4781 CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by,
4782 CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id
47444783 FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}
47454784 ORDER BY ordinal ASC, turn_item_id ASC`;
47464785 const items: Array<TimelineIndexItem> = rows.map((row) => ({
......@@ -4749,7 +4788,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
47494788 runId: row.run_id === null ? null : RunId.make(row.run_id),
47504789 nodeId: row.node_id === null ? null : NodeId.make(row.node_id),
47514790 type: row.type,
4752 ...(row.input_intent === "queued_turn" ? { inputIntent: "queued_turn" as const } : {}),
4791 ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }),
4792 ...(row.created_by === null ? {} : { createdBy: row.created_by }),
4793 ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }),
47534794 }));
47544795 const local: Array<TimelineIndexRow> = items.map((item) => ({
47554796 sourceThreadId: threadId,
......@@ -4819,6 +4860,116 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
48194860 ),
48204861 );
48214862
4863 const { searchThread, searchThreadStream } = yield* makeThreadFind((threadId) =>
4864 readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)),
4865 );
4866
4867 const getThreadHistoryPage: ProjectionStoreV2Shape["getThreadHistoryPage"] = (
4868 threadId,
4869 cursor,
4870 throughEntryId,
4871 conversationOnly,
4872 ) =>
4873 sql
4874 .withTransaction(
4875 Effect.gen(function* () {
4876 const index = yield* readTimelineIndex(threadId, new Set());
4877 if (index.records.thread.deletedAt !== null)
4878 return yield* new ProjectionStoreThreadNotFoundError({ threadId });
4879 const revisions = yield* sql<{
4880 sequence: number;
4881 }>`SELECT COALESCE(MAX(sequence), 0) AS sequence
4882 FROM orchestration_events WHERE application_event_version = 2
4883 AND aggregate_kind = 'thread' AND stream_id = ${threadId}`;
4884 const snapshotSequence = revisions[0]?.sequence ?? 0;
4885 // Choose complete turns from the light index before loading any bodies.
4886 // A history page does not need nodes, provider turns, or other control state.
4887 const page = yield* Effect.try({
4888 try: () =>
4889 selectHistoryPageFromCursor({
4890 items: index.visible.map((row, position) => ({
4891 ...row,
4892 position,
4893 canonicalPosition: position,
4894 })),
4895 cursor,
4896 snapshotSequence,
4897 rowEncodedBytes: () => 0,
4898 throughEntryId,
4899 }),
4900 catch: (cause) => new ProjectionStoreReadError({ threadId, cause }),
4901 });
4902 const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1;
4903 const hasUserTurns = index.visible
4904 .slice(0, end)
4905 .some((row) => isThreadHistoryUserTurn(row.item));
4906 const materialized =
4907 conversationOnly && hasUserTurns
4908 ? page.items.filter((row) => isConversationHistoryItem(row.item))
4909 : page.items;
4910 const sources = yield* encodeTimelineSources(
4911 materialized
4912 .filter((row) => !row.synthetic)
4913 .map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })),
4914 );
4915 const payloads = yield* sql<{
4916 thread_id: string;
4917 turn_item_id: string;
4918 payload_json: string;
4919 }>`
4920 SELECT item.thread_id, item.turn_item_id, item.payload_json
4921 FROM orchestration_v2_projection_turn_items item JOIN json_each(${sources}) wanted
4922 ON item.thread_id = json_extract(wanted.value, '$.threadId')
4923 AND item.turn_item_id = json_extract(wanted.value, '$.id')`;
4924 const byIdentity = new Map(
4925 payloads.map((row) => [`${row.thread_id}:${row.turn_item_id}`, row.payload_json]),
4926 );
4927 const items = yield* Effect.forEach(materialized, (row) =>
4928 Effect.gen(function* () {
4929 const payload = byIdentity.get(`${row.sourceThreadId}:${row.sourceItemId}`);
4930 const item =
4931 row.synthetic ??
4932 (payload === undefined
4933 ? yield* new ProjectionStoreReadError({ threadId })
4934 : yield* decodeTurnItemPayload(payload));
4935 return {
4936 position: row.canonicalPosition,
4937 visibility: row.visibility,
4938 sourceThreadId: row.sourceThreadId,
4939 sourceItemId: row.sourceItemId,
4940 item: projectTurnItemForWire(item),
4941 };
4942 }),
4943 );
4944 if (hasUserTurns)
4945 return {
4946 ...page,
4947 snapshotSequence,
4948 items: items.map((row, position) => ({ ...row, position })),
4949 };
4950 // The no-turn fallback also respects the encoded byte budget after projection.
4951 const bounded = selectRecentTimelineWindow({
4952 items,
4953 snapshotSequence,
4954 policy: {
4955 ...THREAD_HISTORY_PAGE_POLICY,
4956 maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT,
4957 maxItems:
4958 THREAD_HISTORY_PAGE_POLICY.maxItems * (throughEntryId === undefined ? 1 : 2),
4959 },
4960 });
4961 return {
4962 snapshotSequence,
4963 items: conversationOnly
4964 ? bounded.items.filter((row) => isConversationHistoryItem(row.item))
4965 : bounded.items,
4966 nextCursor: bounded.hasMoreHistory ? bounded.nextCursor : page.nextCursor,
4967 hasMoreHistory: bounded.hasMoreHistory || page.hasMoreHistory,
4968 };
4969 }),
4970 )
4971 .pipe(Effect.mapError(controlReadError(threadId)));
4972
48224973 const getTimelinePage: ProjectionStoreV2Shape["getTimelinePage"] = (threadId, options) =>
48234974 sql
48244975 .withTransaction(
......@@ -5756,6 +5907,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
57565907 getThreadSnapshot,
57575908 getThreadSnapshotWindow,
57585909 getTimelinePage,
5910 getThreadHistoryPage,
5911 searchThread,
5912 searchThreadStream,
57595913 getThreadAttachmentIds,
57605914 } satisfies ProjectionStoreV2Shape;
57615915 }),
......@@ -6314,6 +6468,53 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
63146468 ),
63156469 ),
63166470 ),
6471 searchThreadStream: (input) => Stream.fromEffect(service.searchThread(input)),
6472 searchThread: (input) =>
6473 service.getThreadSnapshot(input.threadId).pipe(
6474 Effect.flatMap(({ projection, snapshotSequence }) =>
6475 projection.thread.deletedAt !== null
6476 ? Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: input.threadId }))
6477 : Effect.succeed(
6478 // This store has no project records, so only worktree paths resolve labels.
6479 findProjectedThreadItems(
6480 projection.visibleTurnItems,
6481 input,
6482 snapshotSequence,
6483 projection.thread.worktreePath ?? undefined,
6484 ),
6485 ),
6486 ),
6487 ),
6488 getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) =>
6489 service.getThreadSnapshot(threadId).pipe(
6490 Effect.flatMap(({ projection, snapshotSequence }) =>
6491 Effect.gen(function* () {
6492 if (projection.thread.deletedAt !== null)
6493 return yield* new ProjectionStoreThreadNotFoundError({ threadId });
6494 return yield* Effect.try({
6495 try: () => {
6496 const page = selectHistoryPageFromCursor({
6497 items: projection.visibleTurnItems.map((row) => ({
6498 ...row,
6499 item: projectTurnItemForWire(row.item),
6500 })),
6501 cursor,
6502 throughEntryId,
6503 snapshotSequence,
6504 });
6505 return {
6506 ...page,
6507 snapshotSequence,
6508 items: conversationOnly
6509 ? page.items.filter((row) => isConversationHistoryItem(row.item))
6510 : page.items,
6511 };
6512 },
6513 catch: (cause) => new ProjectionStoreReadError({ threadId, cause }),
6514 });
6515 }),
6516 ),
6517 ),
63176518 getTimelinePage: (threadId, options) =>
63186519 service.getThreadProjection(threadId).pipe(
63196520 Effect.map((projection) => {
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+3
......@@ -225,6 +225,9 @@ it.effect(
225225 getPlan: () => Effect.die("unused"),
226226 hasUnpairedRunInterruptRequest: () => Effect.die("unused interrupt read"),
227227 getThreadAttachmentIds: () => Effect.die("Unused attachment lookup"),
228 searchThread: () => Effect.die("unused"),
229 searchThreadStream: () => Stream.empty,
230 getThreadHistoryPage: () => Effect.die("unused"),
228231 getTimelinePage: () => Effect.die("Unused timeline read"),
229232 getMessageCount: () => Effect.die("unused message count"),
230233 getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"),
apps/server/src/orchestration-v2/ThreadFind.test.ts created+1012
......@@ -0,0 +1,1012 @@
1import { assert, it, describe } from "@effect/vitest";
2import {
3 EventId,
4 MessageId,
5 PlanId,
6 RunId,
7 RuntimeRequestId,
8 TurnItemId,
9 type OrchestrationV2TurnItem,
10 type OrchestrationV2Run,
11 type OrchestrationV2SearchThreadResult,
12 type OrchestrationV2ThreadHistoryPage,
13 ProjectId,
14 ProviderInstanceId,
15 ThreadId,
16 type OrchestrationV2DomainEvent,
17} from "@t3tools/contracts";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as SqlClient from "effect/sql/SqlClient";
22import * as Stream from "effect/Stream";
23import { vi } from "vite-plus/test";
24import * as ThreadFindText from "@t3tools/shared/threadFindText";
25
26import * as SqlitePersistence from "../persistence/Sqlite.ts";
27import * as ProjectionStore from "./ProjectionStore.ts";
28import * as ProjectStore from "./ProjectStore.ts";
29import * as EventStore from "./EventStore.ts";
30import { encodeThreadHistoryCursor, selectHistoryPageFromCursor } from "./threadHistoryPaging.ts";
31import { projectTurnItemForWire } from "./WireProjection.ts";
32
33const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe(
34 Layer.provideMerge(SqlitePersistence.layerMemory),
35);
36
37const providerInstanceId = ProviderInstanceId.make("codex");
38const at = (minute: number) => DateTime.makeUnsafe(Date.UTC(2026, 8, 27, 0, minute));
39
40const createProject = (projectId: ProjectId) =>
41 Effect.flatMap(ProjectStore.ProjectStoreV2, (projects) =>
42 projects.apply({
43 sequence: 0,
44 eventId: EventId.make(`created:${projectId}`),
45 aggregateKind: "project",
46 aggregateId: projectId,
47 occurredAt: DateTime.formatIso(at(0)),
48 commandId: null,
49 causationEventId: null,
50 correlationId: null,
51 metadata: {},
52 type: "project.created",
53 payload: {
54 projectId,
55 title: projectId,
56 workspaceRoot: `/work/${projectId}`,
57 defaultModelSelection: null,
58 scripts: [],
59 createdAt: DateTime.formatIso(at(0)),
60 updatedAt: DateTime.formatIso(at(0)),
61 },
62 }),
63 );
64
65const thread = (
66 threadId: ThreadId,
67 projectId: ProjectId,
68 overrides: { readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc } = {},
69): OrchestrationV2DomainEvent => ({
70 id: EventId.make(`created:${threadId}`),
71 type: "thread.created",
72 threadId,
73 providerInstanceId,
74 occurredAt: at(0),
75 payload: {
76 createdBy: "user",
77 creationSource: "web",
78 id: threadId,
79 projectId,
80 title: threadId,
81 providerInstanceId,
82 modelSelection: { instanceId: providerInstanceId, model: "gpt-5" },
83 runtimeMode: "full-access",
84 interactionMode: "default",
85 branch: null,
86 worktreePath: null,
87 activeProviderThreadId: null,
88 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
89 forkedFrom: null,
90 createdAt: at(0),
91 updatedAt: at(0),
92 archivedAt: overrides.archivedAt ?? null,
93 settledOverride: null,
94 settledAt: null,
95 lastVisitedAt: null,
96 deletedAt: overrides.deletedAt ?? null,
97 },
98});
99
100const threadId = ThreadId.make("thread:find");
101const projectId = ProjectId.make("project:find");
102const runId = RunId.make("run:find");
103
104function item(
105 id: string,
106 ordinal: number,
107 text: string,
108 kind: "user_message" | "assistant_message" | "proposed_plan" = "assistant_message",
109 owner = threadId,
110): Extract<
111 OrchestrationV2TurnItem,
112 { type: "user_message" | "assistant_message" | "proposed_plan" }
113> {
114 const base = {
115 id: TurnItemId.make(id),
116 threadId: owner,
117 runId,
118 nodeId: null,
119 providerThreadId: null,
120 providerTurnId: null,
121 nativeItemRef: null,
122 parentItemId: null,
123 ordinal,
124 status: "completed" as const,
125 title: null,
126 // Creation time deliberately opposes the canonical item order.
127 startedAt: at(100 - ordinal),
128 completedAt: at(100 - ordinal),
129 updatedAt: at(1),
130 };
131 if (kind === "proposed_plan")
132 return { ...base, type: kind, planId: PlanId.make(id), markdown: text, streaming: false };
133 if (kind === "user_message")
134 return {
135 ...base,
136 type: kind,
137 messageId: MessageId.make(id),
138 text,
139 inputIntent: "turn_start",
140 attachments: [],
141 createdBy: "user",
142 creationSource: "web",
143 };
144 return { ...base, type: kind, messageId: MessageId.make(id), text, streaming: false };
145}
146
147function run(
148 owner = threadId,
149 id = runId,
150 ordinal = 1,
151 status: OrchestrationV2Run["status"] = "completed",
152): Extract<OrchestrationV2DomainEvent, { type: "run.created" }> {
153 return {
154 id: EventId.make(`run:${owner}:${id}:${status}`),
155 threadId: owner,
156 occurredAt: at(1),
157 type: "run.created",
158 payload: {
159 id,
160 threadId: owner,
161 ordinal,
162 providerInstanceId,
163 modelSelection: { instanceId: providerInstanceId, model: "gpt-6" },
164 providerThreadId: null,
165 userMessageId: MessageId.make("prompt"),
166 rootNodeId: null,
167 activeAttemptId: null,
168 status,
169 requestedAt: at(1),
170 startedAt: at(1),
171 completedAt: at(2),
172 checkpointId: null,
173 contextHandoffId: null,
174 },
175 };
176}
177
178const commit = Effect.fn("ThreadFindTest.commit")(function* (
179 events: readonly OrchestrationV2DomainEvent[],
180) {
181 const store = yield* EventStore.EventStoreV2;
182 const projection = yield* ProjectionStore.ProjectionStoreV2;
183 yield* store.append({ events });
184 yield* Effect.forEach(events, projection.apply, { discard: true });
185});
186const putItems = (items: readonly OrchestrationV2TurnItem[], version = "initial") =>
187 commit(
188 items.map((payload) => ({
189 id: EventId.make(`${payload.threadId}:${payload.id}:${version}`),
190 threadId: payload.threadId,
191 occurredAt: at(1),
192 type: "turn-item.updated" as const,
193 payload,
194 })),
195 );
196const setup = Effect.gen(function* () {
197 yield* createProject(projectId);
198 yield* commit([thread(threadId, projectId), run()]);
199 return yield* ProjectionStore.ProjectionStoreV2;
200});
201
202describe("V2 thread find", () => {
203 it.effect("delivers the reading-position match before parsing the remaining history", () =>
204 Effect.gen(function* () {
205 const projection = yield* setup;
206 yield* putItems([
207 item("before", 0, "before progressive needle needle"),
208 item("reading", 1, "reading progressive needle needle"),
209 item("after", 2, "after progressive needle"),
210 ]);
211 const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments");
212 const frames: OrchestrationV2SearchThreadResult[] = [];
213 try {
214 yield* projection
215 .searchThreadStream({
216 threadId,
217 query: "needle",
218 start: { entryId: "reading", occurrence: 1 },
219 })
220 .pipe(
221 Stream.runForEach((frame) =>
222 Effect.sync(() => {
223 frames.push(frame);
224 if (frame.complete === false) {
225 assert.deepEqual(frame.match, { entryId: "reading", runId, occurrence: 1 });
226 assert.equal(parse.mock.calls.length, 1);
227 assert.equal(parse.mock.calls[0]![0].text, "reading progressive needle needle");
228 }
229 }),
230 ),
231 );
232 assert.equal(frames.length, 2);
233 assert.notEqual(frames[1]!.complete, false);
234 assert.deepEqual(frames[1]!.match, frames[0]!.match);
235 assert.equal(frames[1]!.totalMatches, 5);
236 assert.equal(frames[1]!.activeIndex, 3);
237 const cached = yield* projection
238 .searchThreadStream({ threadId, query: "needle" })
239 .pipe(Stream.runCollect);
240 assert.equal(cached.length, 1);
241 assert.notEqual(cached[0]!.complete, false);
242 } finally {
243 parse.mockRestore();
244 }
245 }).pipe(Effect.provide(layerTest)),
246 );
247
248 it.effect("wraps from an exhausted bottom anchor and cancels without counting the rest", () =>
249 Effect.gen(function* () {
250 const projection = yield* setup;
251 yield* putItems([
252 item("first", 0, "cancel progressive needle"),
253 item("later", 1, "cancel later needle needle"),
254 item("bottom", 2, "cancel bottom needle"),
255 ]);
256 const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments");
257 try {
258 const frames = yield* projection
259 .searchThreadStream({
260 threadId,
261 query: "needle",
262 start: { entryId: "bottom", occurrence: 1 },
263 })
264 .pipe(Stream.take(1), Stream.runCollect);
265 assert.equal(frames[0]!.complete, false);
266 assert.equal(frames[0]!.match?.entryId, "first");
267 assert.deepEqual(
268 parse.mock.calls.map(([message]) => message.text),
269 ["cancel bottom needle", "cancel progressive needle"],
270 );
271 const complete = yield* projection.searchThread({ threadId, query: "needle" });
272 assert.equal(complete.totalMatches, 4);
273 assert.equal(parse.mock.calls.length, 3);
274 } finally {
275 parse.mockRestore();
276 }
277 }).pipe(Effect.provide(layerTest)),
278 );
279
280 it.effect("finishes one snapshot while later searches include newly inserted matches", () =>
281 Effect.gen(function* () {
282 const projection = yield* setup;
283 yield* putItems([
284 item("selected", 1, "snapshot needle"),
285 item("last", 2, "snapshot last needle"),
286 ]);
287 const frames = yield* projection.searchThreadStream({ threadId, query: "needle" }).pipe(
288 Stream.tap((frame) =>
289 frame.complete === false
290 ? putItems([item("inserted", 0, "snapshot inserted needle")], "during-search")
291 : Effect.void,
292 ),
293 Stream.runCollect,
294 );
295 assert.equal(frames.length, 2);
296 assert.equal(frames[1]!.totalMatches, 2);
297 assert.deepEqual(frames[1]!.match, frames[0]!.match);
298 const updated = yield* projection.searchThread({
299 threadId,
300 query: "needle",
301 start: { entryId: "selected", occurrence: 0 },
302 });
303 assert.equal(updated.totalMatches, 3);
304 assert.equal(updated.activeIndex, 1);
305 assert.equal(updated.match?.entryId, "selected");
306 }).pipe(Effect.provide(layerTest)),
307 );
308
309 it.effect("returns only a final frame for empty searches and relative navigation", () =>
310 Effect.gen(function* () {
311 const projection = yield* setup;
312 yield* putItems([item("only", 0, "needle needle")]);
313 const empty = yield* projection
314 .searchThreadStream({ threadId, query: "missing" })
315 .pipe(Stream.runCollect);
316 assert.equal(empty.length, 1);
317 assert.equal(empty[0]!.totalMatches, 0);
318 assert.notEqual(empty[0]!.complete, false);
319 const relative = yield* projection
320 .searchThreadStream({ threadId, query: "needle", offset: 1 })
321 .pipe(Stream.runCollect);
322 assert.equal(relative.length, 1);
323 assert.equal(relative[0]!.match?.occurrence, 1);
324 assert.notEqual(relative[0]!.complete, false);
325 }).pipe(Effect.provide(layerTest)),
326 );
327
328 it.effect("returns bounded navigation counts around the selection, including wraparound", () =>
329 Effect.gen(function* () {
330 const projection = yield* setup;
331 yield* putItems(
332 Array.from({ length: 30 }, (_, i) =>
333 item(`message:${i}`, i, "needle ".repeat((i % 3) + 1)),
334 ),
335 );
336 const current = yield* projection.searchThread({
337 threadId,
338 query: "needle",
339 start: { entryId: "message:14", occurrence: 1 },
340 });
341 assert.equal(current.totalMatches, 60);
342 assert.equal(current.activeIndex, 28);
343 assert.equal(current.navigation?.length, 17);
344 assert.deepEqual(
345 current.navigation?.map((entry) => entry.entryId),
346 Array.from({ length: 17 }, (_, i) => `message:${i + 6}`),
347 );
348 assert.deepEqual(
349 current.navigation?.find((entry) => entry.entryId === "message:14"),
350 {
351 entryId: "message:14",
352 runId,
353 startIndex: 27,
354 count: 3,
355 },
356 );
357 const first = yield* projection.searchThread({ threadId, query: "needle" });
358 assert.equal(first.navigation?.length, 17);
359 assert.include(first.navigation?.map((entry) => entry.entryId) ?? [], "message:29");
360 const empty = yield* projection.searchThread({ threadId, query: "absent" });
361 assert.deepEqual(empty.navigation, []);
362 }).pipe(Effect.provide(layerTest)),
363 );
364
365 it.effect("keeps navigation compact for a message with thousands of occurrences", () =>
366 Effect.gen(function* () {
367 const projection = yield* setup;
368 yield* putItems([item("many", 1, "needle ".repeat(5000))]);
369 const result = yield* projection.searchThread({ threadId, query: "needle", index: 2000 });
370 assert.equal(result.totalMatches, 5000);
371 assert.equal(result.match?.occurrence, 2000);
372 assert.deepEqual(result.navigation, [{ entryId: "many", runId, startIndex: 0, count: 5000 }]);
373 assert.isBelow(JSON.stringify(result).length, 500);
374 }).pipe(Effect.provide(layerTest)),
375 );
376
377 it.effect("cycles relative to the current match when new matches are inserted", () =>
378 Effect.gen(function* () {
379 const projection = yield* setup;
380 yield* putItems([
381 item("first", 10, "needle"),
382 item("current", 20, "needle"),
383 item("last", 30, "needle"),
384 ]);
385 const initial = yield* projection.searchThread({ threadId, query: "needle", index: 1 });
386 assert.equal(initial.match?.entryId, "current");
387 yield* putItems([
388 item("new-before", 15, "needle"),
389 item("new-after", 25, "needle"),
390 item("new-last", 40, "needle"),
391 ]);
392 const start = { entryId: "current", occurrence: 0 };
393 const refreshed = yield* projection.searchThread({ threadId, query: "needle", start });
394 assert.equal(refreshed.totalMatches, 6);
395 assert.equal(refreshed.activeIndex, 2);
396 assert.equal(refreshed.match?.entryId, "current");
397 const next = yield* projection.searchThread({ threadId, query: "needle", start, offset: 1 });
398 assert.equal(next.match?.entryId, "new-after");
399 const previous = yield* projection.searchThread({
400 threadId,
401 query: "needle",
402 start,
403 offset: -1,
404 });
405 assert.equal(previous.match?.entryId, "new-before");
406 const end = yield* projection.searchThread({
407 threadId,
408 query: "needle",
409 start: { entryId: "last", occurrence: 0 },
410 offset: 1,
411 });
412 assert.equal(end.match?.entryId, "new-last");
413 const wrapped = yield* projection.searchThread({
414 threadId,
415 query: "needle",
416 start: { entryId: "new-last", occurrence: 0 },
417 offset: 1,
418 });
419 assert.equal(wrapped.match?.entryId, "first");
420 const backwards = yield* projection.searchThread({
421 threadId,
422 query: "needle",
423 start: { entryId: "first", occurrence: 0 },
424 offset: -1,
425 });
426 assert.equal(backwards.match?.entryId, "new-last");
427 }).pipe(Effect.provide(layerTest)),
428 );
429
430 it.effect("reuses parses of large messages across navigation, edits, and new queries", () =>
431 Effect.gen(function* () {
432 const projection = yield* setup;
433 const text = `${"large message ".repeat(2600)}needle`;
434 yield* putItems(Array.from({ length: 8 }, (_, i) => item(`large:${i}`, i + 1, text)));
435 const parse = vi.spyOn(ThreadFindText, "searchableMessageSegments");
436 try {
437 const first = yield* projection.searchThread({ threadId, query: "needle" });
438 assert.equal(first.totalMatches, 8);
439 // Identical bodies share one parse.
440 assert.equal(parse.mock.calls.length, 1);
441 parse.mockClear();
442 const next = yield* projection.searchThread({ threadId, query: "needle", index: 4 });
443 assert.equal(next.match?.entryId, "large:4");
444 assert.equal(next.snapshotSequence, first.snapshotSequence);
445 assert.equal(parse.mock.calls.length, 0);
446 yield* putItems([item("large:4", 5, `${text} needle`)], "changed");
447 const changed = yield* projection.searchThread({ threadId, query: "needle", index: 5 });
448 assert.equal(changed.totalMatches, 9);
449 assert.deepEqual(changed.match, { entryId: "large:4", runId, occurrence: 1 });
450 // Only the edited message is parsed again.
451 assert.equal(parse.mock.calls.length, 1);
452 parse.mockClear();
453 const retyped = yield* projection.searchThread({ threadId, query: "large message" });
454 assert.equal(retyped.totalMatches, 8 * 2600);
455 assert.equal(parse.mock.calls.length, 0);
456 } finally {
457 parse.mockRestore();
458 }
459 }).pipe(Effect.provide(layerTest)),
460 );
461
462 it.effect("searches visible rendered messages and plans in canonical item order", () =>
463 Effect.gen(function* () {
464 const projection = yield* setup;
465 yield* putItems([
466 item("user", 1, "Check **the build**", "user_message"),
467 item("assistant", 2, "check the build twice: check the build"),
468 item("plan-item", 3, "# Check the build\nShip it", "proposed_plan"),
469 item("url", 4, "[documentation](https://hidden.example/needle)"),
470 ]);
471 const matches = [];
472 for (let index = 0; index < 4; index++) {
473 const result = yield* projection.searchThread({
474 threadId,
475 query: "CHECK THE BUILD",
476 index,
477 });
478 assert.equal(result.totalMatches, 4);
479 matches.push(result.match);
480 }
481 assert.deepEqual(matches, [
482 { entryId: "user", runId, occurrence: 0 },
483 { entryId: "assistant", runId, occurrence: 0 },
484 { entryId: "assistant", runId, occurrence: 1 },
485 { entryId: "plan-item", runId, occurrence: 0 },
486 ]);
487 assert.equal(
488 (yield* projection.searchThread({ threadId, query: "hidden.example" })).totalMatches,
489 0,
490 );
491 assert.equal(
492 (yield* projection.searchThread({ threadId, query: "check", index: 99 })).activeIndex,
493 3,
494 );
495 }).pipe(Effect.provide(layerTest)),
496 );
497
498 it.effect("keeps a search snapshot consistent while another thread commits", () =>
499 Effect.gen(function* () {
500 const projection = yield* setup;
501 const other = ThreadId.make("thread:busy");
502 yield* commit([thread(other, projectId), run(other, RunId.make("run:busy"))]);
503 yield* putItems(Array.from({ length: 260 }, (_, i) => item(`message:${i}`, i, "needle")));
504 const before = yield* projection.searchThread({ threadId, query: "needle" });
505 const [during] = yield* Effect.all(
506 [
507 projection.searchThread({ threadId, query: "needle", index: 259 }),
508 putItems([item("busy", 1, "busy", "assistant_message", other)]),
509 ],
510 { concurrency: "unbounded" },
511 );
512 assert.equal(during.totalMatches, 260);
513 assert.equal(during.match?.entryId, "message:259");
514 assert.equal(during.snapshotSequence, before.snapshotSequence);
515 assert.equal(
516 (yield* projection.searchThread({ threadId, query: "needle" })).snapshotSequence,
517 before.snapshotSequence,
518 );
519 }).pipe(Effect.provide(layerTest)),
520 );
521
522 it.effect("searches displayed skill labels and invalidates parsed text when labels change", () =>
523 Effect.gen(function* () {
524 const projection = yield* setup;
525 yield* putItems([item("skill", 1, "Use $test-t3-app now")]);
526 const input = {
527 threadId,
528 query: "T3 App Testing",
529 skills: [{ name: "test-t3-app", displayName: "T3 App Testing" }],
530 };
531 assert.equal((yield* projection.searchThread(input)).totalMatches, 1);
532 assert.equal((yield* projection.searchThread({ ...input, skills: [] })).totalMatches, 0);
533 // Plans render `$skill` tokens literally, so they match as written, not by label.
534 yield* putItems(
535 [item("skill-plan", 2, "# Verify\nUse $test-t3-app now", "proposed_plan")],
536 "plan",
537 );
538 assert.equal((yield* projection.searchThread(input)).totalMatches, 1);
539 assert.equal(
540 (yield* projection.searchThread({ ...input, query: "$test-t3-app" })).totalMatches,
541 1,
542 );
543 // The message label "T3 App Testing" plus the plan's literal "$test-t3-app".
544 assert.equal((yield* projection.searchThread({ ...input, query: "App" })).totalMatches, 2);
545 }).pipe(Effect.provide(layerTest)),
546 );
547
548 it.effect("never mixes message versions when the searched thread changes during a scan", () =>
549 Effect.gen(function* () {
550 const projection = yield* setup;
551 const sql = yield* SqlClient.SqlClient;
552 const items = Array.from({ length: 260 }, (_, i) => item(`message:${i}`, i, "needle"));
553 yield* putItems(items);
554 const updated = items.map((item) =>
555 item.type === "assistant_message" ? { ...item, text: "needle needle" } : item,
556 );
557 const [during] = yield* Effect.all(
558 [
559 projection.searchThread({ threadId, query: "needle" }),
560 sql.withTransaction(putItems(updated, "concurrent")),
561 ],
562 { concurrency: "unbounded" },
563 );
564 assert.include([260, 520], during.totalMatches);
565 assert.equal(
566 (yield* projection.searchThread({ threadId, query: "needle" })).totalMatches,
567 520,
568 );
569 }).pipe(Effect.provide(layerTest)),
570 );
571
572 it.effect("starts at the reading position and wraps when no later match exists", () =>
573 Effect.gen(function* () {
574 const projection = yield* setup;
575 yield* putItems([
576 item("first", 1, "needle"),
577 item("reading", 2, "needle above, needle below"),
578 item("between", 3, "nothing"),
579 item("last", 4, "needle"),
580 item("end", 5, "nothing"),
581 ]);
582 const from = (entryId: string, occurrence = 0) =>
583 projection.searchThread({ threadId, query: "needle", start: { entryId, occurrence } });
584 assert.equal((yield* from("reading")).activeIndex, 1);
585 const below = yield* from("reading", 1);
586 assert.equal(below.activeIndex, 2);
587 assert.equal(below.match?.occurrence, 1);
588 assert.equal((yield* from("between")).match?.entryId, "last");
589 assert.equal((yield* from("reading", 2)).activeIndex, 3);
590 assert.equal((yield* from("end")).match?.entryId, "first");
591 assert.equal((yield* from("missing")).activeIndex, 0);
592 const explicit = yield* projection.searchThread({
593 threadId,
594 query: "needle",
595 start: { entryId: "last", occurrence: 0 },
596 index: 1,
597 });
598 assert.equal(explicit.match?.entryId, "reading");
599 }).pipe(Effect.provide(layerTest)),
600 );
601
602 it.effect("finds matches beyond the recent history window", () =>
603 Effect.gen(function* () {
604 const projection = yield* setup;
605 const items = Array.from({ length: 520 }, (_, i) =>
606 item(`message:${i}`, i + 1, i === 8 || i === 510 ? "sentinel" : "other message"),
607 );
608 yield* putItems(items);
609 const first = yield* projection.searchThread({ threadId, query: "sentinel" });
610 const last = yield* projection.searchThread({ threadId, query: "sentinel", index: 1 });
611 assert.equal(first.totalMatches, 2);
612 assert.equal(first.match?.entryId, "message:8");
613 assert.equal(last.match?.entryId, "message:510");
614 }).pipe(Effect.provide(layerTest)),
615 );
616
617 it.effect("invalidates cached counts when streamed text changes or a run rolls back", () =>
618 Effect.gen(function* () {
619 const projection = yield* setup;
620 const original = item("streaming", 1, "needle");
621 yield* putItems([original]);
622 assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 1);
623 if (original.type !== "assistant_message") return;
624 yield* putItems([{ ...original, text: "needle needle", streaming: true }], "streaming");
625 assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 2);
626 const updated = run(threadId, runId, 1, "rolled_back");
627 yield* commit([{ ...updated, type: "run.updated" }]);
628 assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 0);
629 }).pipe(Effect.provide(layerTest)),
630 );
631
632 it.effect(
633 "does not search undispatched inputs, cancelled queue rows, or folded question answers",
634 () =>
635 Effect.gen(function* () {
636 const projection = yield* setup;
637 const queuedRunId = RunId.make("queued:cancelled");
638 yield* commit([run(threadId, queuedRunId, 2, "cancelled")]);
639 const queued = item("cancelled", 1, "needle", "user_message");
640 if (queued.type !== "user_message") return;
641 const answer = item("async-answer:question", 2, "needle", "user_message");
642 const question: OrchestrationV2TurnItem = {
643 ...item("question", 3, ""),
644 type: "user_input_request",
645 requestId: RuntimeRequestId.make("question"),
646 questions: [],
647 questionAnswer: { requestId: "question", answers: {}, attachmentsByQuestionId: {} },
648 };
649 yield* putItems([
650 { ...queued, runId: queuedRunId, inputIntent: "queued_turn" },
651 answer,
652 question,
653 ]);
654 const event: OrchestrationV2DomainEvent = {
655 id: EventId.make("undispatched"),
656 threadId,
657 occurredAt: at(2),
658 type: "message.updated",
659 payload: {
660 id: MessageId.make("undispatched"),
661 threadId,
662 runId: null,
663 nodeId: null,
664 role: "user",
665 text: "needle",
666 attachments: [],
667 streaming: false,
668 createdBy: "user",
669 creationSource: "web",
670 createdAt: at(2),
671 updatedAt: at(2),
672 },
673 };
674 yield* commit([event]);
675 const result = yield* projection.searchThread({ threadId, query: "needle" });
676 assert.equal(result.totalMatches, 0);
677 assert.isNull(result.match);
678 }).pipe(Effect.provide(layerTest)),
679 );
680
681 it.effect(
682 "searches inherited fork history through the fork boundary, using source item identities",
683 () =>
684 Effect.gen(function* () {
685 const projection = yield* setup;
686 const parent = ThreadId.make("thread:parent");
687 const laterRun = RunId.make("run:later");
688 const parentRun = RunId.make("run:parent");
689 yield* commit([
690 thread(parent, projectId),
691 run(parent, parentRun),
692 run(parent, laterRun, 2),
693 ]);
694 yield* putItems([
695 { ...item("inherited", 1, "needle", "assistant_message", parent), runId: parentRun },
696 { ...item("after-fork", 2, "needle", "assistant_message", parent), runId: laterRun },
697 ]);
698 const child = thread(threadId, projectId);
699 if (child.type !== "thread.created") return;
700 yield* commit([
701 {
702 ...child,
703 id: EventId.make("child:fork"),
704 type: "thread.metadata-updated",
705 payload: {
706 ...child.payload,
707 forkedFrom: { type: "run", threadId: parent, runId: parentRun },
708 },
709 },
710 ]);
711 yield* putItems([item("local", 1, "needle")]);
712 const inherited = yield* projection.searchThread({ threadId, query: "needle" });
713 assert.equal(inherited.totalMatches, 2);
714 assert.equal(inherited.match?.entryId, "inherited");
715 assert.equal(
716 (yield* projection.searchThread({ threadId, query: "needle", index: 1 })).match?.entryId,
717 "local",
718 );
719 const snapshot = yield* projection.getThreadSnapshot(threadId);
720 const history = yield* projection.getThreadHistoryPage(
721 threadId,
722 encodeThreadHistoryCursor({
723 snapshotSequence: snapshot.snapshotSequence,
724 sourceThreadId: threadId,
725 sourceItemId: "local",
726 position: 2,
727 }),
728 );
729 assert.deepEqual(
730 history.items,
731 snapshot.projection.visibleTurnItems
732 .slice(0, -1)
733 .map((row) => ({ ...row, item: projectTurnItemForWire(row.item) })),
734 );
735 assert.equal(history.items[0]?.sourceThreadId, parent);
736 assert.equal(history.items.at(-1)?.item.type, "fork");
737 // Parent updates invalidate a child's cached inherited result too.
738 yield* putItems(
739 [{ ...item("inherited", 1, "gone", "assistant_message", parent), runId: parentRun }],
740 "parent-update",
741 );
742 assert.equal(
743 (yield* projection.searchThread({ threadId, query: "needle" })).totalMatches,
744 1,
745 );
746 }).pipe(Effect.provide(layerTest)),
747 );
748
749 it.effect("rejects deleted threads and keeps results scoped to their thread", () =>
750 Effect.gen(function* () {
751 const projection = yield* setup;
752 const other = ThreadId.make("thread:other");
753 yield* commit([thread(other, projectId), run(other, RunId.make("run:other"))]);
754 yield* putItems([
755 item("shared-id", 1, "needle"),
756 item("other-id", 1, "other text", "assistant_message", other),
757 ]);
758 assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 1);
759 assert.equal(
760 (yield* projection.searchThread({ threadId: other, query: "needle" })).totalMatches,
761 0,
762 );
763 const deleted = thread(threadId, projectId, { deletedAt: at(3) });
764 if (deleted.type !== "thread.created") return;
765 yield* commit([{ ...deleted, id: EventId.make("deleted"), type: "thread.deleted" }]);
766 const error = yield* Effect.flip(projection.searchThread({ threadId, query: "needle" }));
767 assert.equal(error._tag, "ProjectionStoreThreadNotFoundError");
768 const historyError = yield* Effect.flip(
769 projection.getThreadHistoryPage(
770 threadId,
771 encodeThreadHistoryCursor({
772 snapshotSequence: 0,
773 sourceThreadId: threadId,
774 sourceItemId: "shared-id",
775 position: 0,
776 }),
777 ),
778 );
779 assert.equal(historyError._tag, "ProjectionStoreThreadNotFoundError");
780 }).pipe(Effect.provide(layerTest)),
781 );
782
783 it.effect("invalidates a cached fork search when its empty source gains its first message", () =>
784 Effect.gen(function* () {
785 const projection = yield* setup;
786 const parent = ThreadId.make("thread:empty-parent");
787 const parentRun = RunId.make("run:empty-parent");
788 yield* commit([thread(parent, projectId), run(parent, parentRun)]);
789 const child = thread(threadId, projectId);
790 if (child.type !== "thread.created") return;
791 yield* commit([
792 {
793 ...child,
794 id: EventId.make("child:empty-fork"),
795 type: "thread.metadata-updated",
796 payload: {
797 ...child.payload,
798 forkedFrom: { type: "run", threadId: parent, runId: parentRun },
799 },
800 },
801 ]);
802 yield* putItems([item("local", 1, "needle")]);
803 assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 1);
804 yield* putItems([
805 { ...item("inherited", 1, "needle", "assistant_message", parent), runId: parentRun },
806 ]);
807 const updated = yield* projection.searchThread({ threadId, query: "needle" });
808 assert.equal(updated.totalMatches, 2);
809 assert.equal(updated.match?.entryId, "inherited");
810 }).pipe(Effect.provide(layerTest)),
811 );
812
813 it.effect("memory store resolves worktree file labels and rejects deleted threads", () =>
814 Effect.gen(function* () {
815 const projection = yield* ProjectionStore.ProjectionStoreV2;
816 const created = thread(threadId, projectId);
817 if (created.type !== "thread.created") return;
818 const withWorktree = {
819 ...created,
820 payload: { ...created.payload, worktreePath: "/work/tree" },
821 };
822 const message = item("file", 1, "See `docs/notes.md:12`.");
823 yield* Effect.forEach(
824 [
825 withWorktree,
826 run(),
827 {
828 id: EventId.make("file"),
829 threadId,
830 occurredAt: at(1),
831 type: "turn-item.updated",
832 payload: message,
833 },
834 ] satisfies OrchestrationV2DomainEvent[],
835 projection.apply,
836 { discard: true },
837 );
838 assert.equal(
839 (yield* projection.searchThread({ threadId, query: "notes.md · L12" })).totalMatches,
840 1,
841 );
842 const deleted = thread(threadId, projectId, { deletedAt: at(3) });
843 if (deleted.type !== "thread.created") return;
844 yield* projection.apply({ ...deleted, id: EventId.make("deleted"), type: "thread.deleted" });
845 const error = yield* Effect.flip(projection.searchThread({ threadId, query: "notes" }));
846 assert.equal(error._tag, "ProjectionStoreThreadNotFoundError");
847 }).pipe(Effect.provide(ProjectionStore.layerMemory)),
848 );
849});
850
851describe("V2 history materialization", () => {
852 it.effect(
853 "combines history pages through a match's message identity without skipping intervening turns",
854 () =>
855 Effect.gen(function* () {
856 const projection = yield* setup;
857 const items = Array.from({ length: 45 }, (_, turn) => [
858 item(`user:${turn}`, turn * 3, `turn ${turn}`, "user_message"),
859 {
860 ...item(`answer:${turn}`, turn * 3 + 1, `answer ${turn}`),
861 messageId: MessageId.make(`message:${turn}`),
862 },
863 {
864 ...item(`tool:${turn}`, turn * 3 + 2, ""),
865 type: "command_execution" as const,
866 input: "pwd",
867 output: "huge output".repeat(1000),
868 exitCode: 0,
869 },
870 ]).flat();
871 yield* putItems(items);
872 const snapshot = yield* projection.getThreadSnapshot(threadId);
873 const cursor = encodeThreadHistoryCursor({
874 snapshotSequence: snapshot.snapshotSequence,
875 sourceThreadId: threadId,
876 sourceItemId: "absent",
877 position: items.length,
878 });
879 const ordinary = yield* projection.getThreadHistoryPage(threadId, cursor);
880 assert.equal(ordinary.items[0]?.sourceItemId, "user:25");
881 const targeted = yield* projection.getThreadHistoryPage(threadId, cursor, "message:10");
882 assert.equal(targeted.items[0]?.sourceItemId, "user:10");
883 assert.deepEqual(
884 targeted.items.map((row) => row.sourceItemId),
885 items.slice(30).map((entry) => entry.id),
886 );
887 const conversation = yield* projection.getThreadHistoryPage(
888 threadId,
889 cursor,
890 "message:10",
891 true,
892 );
893 assert.equal(conversation.nextCursor, targeted.nextCursor);
894 assert.equal(conversation.hasMoreHistory, targeted.hasMoreHistory);
895 assert.deepEqual(
896 conversation.items.map((row) => row.sourceItemId),
897 targeted.items
898 .filter((row) => row.item.type !== "command_execution")
899 .map((row) => row.sourceItemId),
900 );
901 assert.equal(conversation.items.length, 70);
902 assert.equal(targeted.items.length, 105);
903 assert.isTrue(targeted.hasMoreHistory);
904 const earlier = yield* projection.getThreadHistoryPage(threadId, targeted.nextCursor!);
905 assert.deepEqual(
906 earlier.items.map((row) => row.sourceItemId),
907 items.slice(0, 30).map((entry) => entry.id),
908 );
909 assert.isFalse(earlier.hasMoreHistory);
910 const capped = yield* projection.getThreadHistoryPage(threadId, cursor, "message:0");
911 assert.equal(capped.items[0]?.sourceItemId, "user:5");
912 const absent = yield* projection.getThreadHistoryPage(
913 threadId,
914 cursor,
915 "another-thread-message",
916 );
917 assert.deepEqual(absent, ordinary);
918 }).pipe(Effect.provide(layerTest)),
919 );
920
921 it.effect("pages complete turns without duplicating or skipping messages", () =>
922 Effect.gen(function* () {
923 const projection = yield* setup;
924 const items = Array.from({ length: 45 }, (_, turn) => [
925 item(`user:${turn}`, turn * 3, `turn ${turn}`, "user_message"),
926 {
927 ...item(`steer:${turn}`, turn * 3 + 1, "steer", "user_message"),
928 inputIntent: "steer" as const,
929 },
930 item(
931 `answer:${turn}`,
932 turn * 3 + 2,
933 turn === 40 ? "large ".repeat(200_000) : `answer ${turn}`,
934 ),
935 ]).flat();
936 yield* putItems(items);
937 const snapshot = yield* projection.getThreadSnapshot(threadId);
938 const rows = snapshot.projection.visibleTurnItems.map((row) => ({
939 ...row,
940 item: projectTurnItemForWire(row.item),
941 }));
942 let cursor: string | null = encodeThreadHistoryCursor({
943 snapshotSequence: snapshot.snapshotSequence,
944 sourceThreadId: threadId,
945 sourceItemId: "absent",
946 position: rows.length,
947 });
948 const collected: string[] = [];
949 while (cursor !== null) {
950 const page: OrchestrationV2ThreadHistoryPage = yield* projection.getThreadHistoryPage(
951 threadId,
952 cursor,
953 );
954 assert.deepEqual(page, {
955 snapshotSequence: snapshot.snapshotSequence,
956 ...selectHistoryPageFromCursor({
957 items: rows,
958 cursor,
959 snapshotSequence: snapshot.snapshotSequence,
960 }),
961 });
962 assert.equal(page.items[0]?.item.type, "user_message");
963 collected.unshift(...page.items.map((row) => String(row.sourceItemId)));
964 cursor = page.nextCursor;
965 }
966 assert.deepEqual(
967 collected,
968 items.map((entry) => String(entry.id)),
969 );
970 }).pipe(Effect.provide(layerTest)),
971 );
972
973 it.effect("enforces fallback item and byte limits and always admits an oversized row", () =>
974 Effect.gen(function* () {
975 const projection = yield* setup;
976 yield* putItems(
977 Array.from({ length: 80 }, (_, ordinal) =>
978 item(`row:${ordinal}`, ordinal, ordinal < 78 ? "small" : "large ".repeat(200_000)),
979 ),
980 );
981 const snapshot = yield* projection.getThreadSnapshot(threadId);
982 const rows = snapshot.projection.visibleTurnItems.map((row) => ({
983 ...row,
984 item: projectTurnItemForWire(row.item),
985 }));
986 let cursor: string | null = encodeThreadHistoryCursor({
987 snapshotSequence: snapshot.snapshotSequence,
988 sourceThreadId: threadId,
989 sourceItemId: "absent",
990 position: rows.length,
991 });
992 const sizes: number[] = [];
993 while (cursor !== null) {
994 const page: OrchestrationV2ThreadHistoryPage = yield* projection.getThreadHistoryPage(
995 threadId,
996 cursor,
997 );
998 assert.deepEqual(page, {
999 snapshotSequence: snapshot.snapshotSequence,
1000 ...selectHistoryPageFromCursor({
1001 items: rows,
1002 cursor,
1003 snapshotSequence: snapshot.snapshotSequence,
1004 }),
1005 });
1006 sizes.push(page.items.length);
1007 cursor = page.nextCursor;
1008 }
1009 assert.deepEqual(sizes, [1, 1, 75, 3]);
1010 }).pipe(Effect.provide(layerTest)),
1011 );
1012});
apps/server/src/orchestration-v2/ThreadFind.ts created+449
......@@ -0,0 +1,449 @@
1import {
2 OrchestrationV2TurnItemJson,
3 type OrchestrationV2ProjectedTurnItem,
4 OrchestrationV2SearchThreadInput,
5 type OrchestrationV2SearchThreadResult,
6 type OrchestrationV2TurnItem,
7 ThreadId,
8 TurnItemId,
9} from "@t3tools/contracts";
10import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText";
11import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch";
12import * as Cache from "effect/Cache";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";
17import * as SqlClient from "effect/sql/SqlClient";
18import {
19 ProjectionStoreReadError,
20 ProjectionStoreThreadNotFoundError,
21 type ProjectionStoreV2Error,
22} from "./ProjectionStore.ts";
23
24interface FindRow {
25 readonly position: number;
26 readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
27 readonly sourceThreadId: ThreadId;
28 readonly sourceItemId: TurnItemId;
29}
30interface FindPayload extends FindRow {
31 readonly payload: string;
32 readonly entryId: string;
33 readonly type: OrchestrationV2TurnItem["type"];
34}
35interface FindDocument extends FindRow {
36 readonly entryId: string;
37 readonly runId: OrchestrationV2TurnItem["runId"];
38 readonly count: number;
39}
40class TextKey extends Data.Class<{
41 text: string;
42 role: "user" | "assistant" | "plan";
43 streaming: boolean;
44 hasContext: boolean;
45 cwd: string | undefined;
46 skills: NonNullable<OrchestrationV2SearchThreadInput["skills"]>;
47}> {}
48
49function textKey(
50 item: OrchestrationV2TurnItem,
51 cwd: string | undefined,
52 skills: TextKey["skills"],
53) {
54 return new TextKey({
55 text:
56 item.type === "proposed_plan"
57 ? item.markdown
58 : item.type === "user_message" || item.type === "assistant_message"
59 ? item.text
60 : "",
61 role:
62 item.type === "proposed_plan" ? "plan" : item.type === "user_message" ? "user" : "assistant",
63 streaming: item.type === "assistant_message" && item.streaming,
64 hasContext: item.type === "user_message" && item.context !== undefined,
65 cwd,
66 skills,
67 });
68}
69
70function parseText(key: TextKey): readonly string[] {
71 return key.role === "plan"
72 ? searchablePlanSegments(key.text, key.cwd)
73 : (searchableMessageSegments(
74 {
75 role: key.role,
76 text: key.text,
77 streaming: key.streaming,
78 context: key.hasContext ? { version: 1, records: [] } : undefined,
79 },
80 key.cwd,
81 key.skills,
82 ) ?? []);
83}
84
85function searchableRows(items: readonly OrchestrationV2ProjectedTurnItem[]) {
86 const foldedAnswers = new Set(
87 items.flatMap(({ item }) =>
88 item.type === "user_input_request" && item.questionAnswer
89 ? [`async-answer:${item.questionAnswer.requestId}`]
90 : [],
91 ),
92 );
93 return items.filter(
94 ({ item }) =>
95 ["user_message", "assistant_message", "proposed_plan"].includes(item.type) &&
96 !(item.type === "user_message" && foldedAnswers.has(item.messageId)),
97 );
98}
99
100function documentFor(row: OrchestrationV2ProjectedTurnItem, count: number): FindDocument {
101 return {
102 position: row.position,
103 visibility: row.visibility,
104 sourceThreadId: row.sourceThreadId,
105 sourceItemId: row.sourceItemId,
106 entryId:
107 row.item.type === "user_message" || row.item.type === "assistant_message"
108 ? row.item.messageId
109 : row.item.id,
110 runId: row.item.runId,
111 count,
112 };
113}
114
115function countSegments(segments: readonly string[], query: string) {
116 return segments.reduce((sum, text) => sum + countThreadSearchOccurrences(text, query), 0);
117}
118
119function selectMatch(
120 documents: readonly FindDocument[],
121 input: Pick<OrchestrationV2SearchThreadInput, "index" | "start" | "offset">,
122) {
123 const totalMatches = documents.reduce((sum, doc) => sum + doc.count, 0);
124 let requestedIndex = input.index ?? 0;
125 if (input.index === undefined && input.start) {
126 const startIndex = documents.findIndex((doc) => doc.entryId === input.start?.entryId);
127 if (startIndex >= 0) {
128 requestedIndex =
129 documents.slice(0, startIndex).reduce((sum, doc) => sum + doc.count, 0) +
130 Math.min(input.start.occurrence, documents[startIndex]!.count);
131 if (requestedIndex >= totalMatches) requestedIndex = 0;
132 }
133 }
134 const activeIndex =
135 input.index === undefined && totalMatches > 0
136 ? (((requestedIndex + (input.offset ?? 0)) % totalMatches) + totalMatches) % totalMatches
137 : Math.min(requestedIndex, Math.max(0, totalMatches - 1));
138 let occurrence = activeIndex;
139 for (const document of documents) {
140 if (occurrence < document.count) return { totalMatches, activeIndex, document, occurrence };
141 occurrence -= document.count;
142 }
143 return { totalMatches, activeIndex, document: null, occurrence: 0 };
144}
145
146/** The memory projection uses the same match ordering as SQLite. */
147export function findProjectedThreadItems(
148 items: readonly OrchestrationV2ProjectedTurnItem[],
149 input: OrchestrationV2SearchThreadInput,
150 snapshotSequence: number,
151 cwd?: string,
152): OrchestrationV2SearchThreadResult {
153 const rows = searchableRows(items);
154 const docs = rows.map((row) =>
155 documentFor(
156 row,
157 countSegments(parseText(textKey(row.item, cwd, input.skills ?? [])), input.query),
158 ),
159 );
160 return resultForSelection(selectMatch(docs, input), docs, snapshotSequence);
161}
162
163function resultForSelection(
164 selected: ReturnType<typeof selectMatch>,
165 documents: readonly FindDocument[],
166 snapshotSequence: number,
167): OrchestrationV2SearchThreadResult {
168 let startIndex = 0;
169 const entries = documents.flatMap(({ entryId, runId, count }) => {
170 const entry = { entryId, runId, count, startIndex };
171 startIndex += count;
172 return count > 0 ? [entry] : [];
173 });
174 const selectedEntry = entries.findIndex((entry) => entry.entryId === selected.document?.entryId);
175 const navigation =
176 entries.length <= 17
177 ? entries
178 : Array.from(
179 { length: 17 },
180 (_, i) => entries[(selectedEntry + i - 8 + entries.length) % entries.length]!,
181 );
182 return {
183 snapshotSequence,
184 totalMatches: selected.totalMatches,
185 activeIndex: selected.activeIndex,
186 navigation,
187 match:
188 selected.document === null
189 ? null
190 : {
191 entryId: selected.document.entryId,
192 runId: selected.document.runId,
193 occurrence: selected.occurrence,
194 },
195 };
196}
197
198/** Read the canonical index once per scan, including inherited fork rows, without tool bodies. */
199export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
200 readIndex: (threadId: ThreadId) => Effect.Effect<
201 readonly (Omit<FindRow, "position"> & {
202 readonly item: Pick<OrchestrationV2TurnItem, "type">;
203 })[],
204 ProjectionStoreV2Error
205 >,
206) {
207 const sql = yield* SqlClient.SqlClient;
208 const encodeCacheKey = Schema.encodeEffect(
209 Schema.fromJsonString(
210 Schema.Struct({
211 threadId: ThreadId,
212 cwd: Schema.optional(Schema.String),
213 query: Schema.String,
214 skills: OrchestrationV2SearchThreadInput.fields.skills,
215 }),
216 ),
217 );
218 const encodeThreadIds = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(ThreadId)));
219 const encodeSources = Schema.encodeEffect(
220 Schema.fromJsonString(Schema.Array(Schema.Struct({ threadId: ThreadId, id: TurnItemId }))),
221 );
222 const decodeItem = Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2TurnItemJson));
223 const isSnapshotError = Schema.is(
224 Schema.Union([ProjectionStoreReadError, ProjectionStoreThreadNotFoundError]),
225 );
226 const load = Effect.fn("ThreadFind.load")(function* (
227 threadId: ThreadId,
228 rows: readonly FindRow[],
229 ) {
230 if (rows.length === 0) return [];
231 const sources = yield* encodeSources(
232 rows.map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })),
233 ).pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })));
234 const payloads = yield* sql<{
235 thread_id: string;
236 turn_item_id: string;
237 payload_json: string;
238 entry_id: string;
239 item_type: OrchestrationV2TurnItem["type"];
240 }>`
241 SELECT item.thread_id, item.turn_item_id, item.payload_json,
242 COALESCE(json_extract(item.payload_json, '$.messageId'), item.turn_item_id) AS entry_id,
243 json_extract(item.payload_json, '$.type') AS item_type
244 FROM orchestration_v2_projection_turn_items AS item
245 JOIN json_each(${sources}) AS wanted
246 ON item.thread_id = json_extract(wanted.value, '$.threadId')
247 AND item.turn_item_id = json_extract(wanted.value, '$.id')
248 `.pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })));
249 const byThread = new Map<string, Map<string, (typeof payloads)[number]>>();
250 for (const row of payloads) {
251 let items = byThread.get(row.thread_id);
252 if (!items) byThread.set(row.thread_id, (items = new Map()));
253 items.set(row.turn_item_id, row);
254 }
255 return yield* Effect.forEach(rows, (row) => {
256 const payload = byThread.get(row.sourceThreadId)?.get(row.sourceItemId);
257 return payload === undefined
258 ? Effect.fail(new ProjectionStoreReadError({ threadId }))
259 : Effect.succeed({
260 ...row,
261 payload: payload.payload_json,
262 entryId: payload.entry_id,
263 type: payload.item_type,
264 });
265 });
266 });
267 // Cache rendered segments, not queries; typing and navigation reuse Markdown parsing.
268 const textCache = yield* Cache.make({
269 capacity: 512,
270 timeToLive: "1 minute",
271 lookup: (key: TextKey) => Effect.sync(() => parseText(key)),
272 });
273 // Bounded separately because each entry can hold a large body; typing reuses the parse.
274 const largeTextCache = yield* Cache.make({
275 capacity: 32,
276 timeToLive: "1 minute",
277 lookup: (key: TextKey) => Effect.sync(() => parseText(key)),
278 });
279 // Scans retain counts and item references, never message bodies. Navigation checks
280 // the current revision in its transaction without reloading message payloads.
281 const scans = new Map<
282 string,
283 {
284 sequence: number;
285 documents: readonly FindDocument[];
286 sourceThreadIds: readonly ThreadId[];
287 }
288 >();
289 const readRevision = Effect.fn("ThreadFind.readRevision")(function* (
290 threadIds: readonly ThreadId[],
291 ) {
292 const sources = yield* encodeThreadIds(threadIds);
293 const revision = yield* sql<{ sequence: number }>`
294 WITH RECURSIVE source_threads(thread_id) AS (
295 SELECT value FROM json_each(${sources})
296 UNION
297 SELECT json_extract(t.payload_json, '$.forkedFrom.threadId')
298 FROM orchestration_v2_projection_threads t
299 JOIN source_threads s ON s.thread_id = t.thread_id
300 WHERE json_extract(t.payload_json, '$.forkedFrom.type') = 'run'
301 )
302 SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events
303 WHERE application_event_version = 2 AND aggregate_kind = 'thread'
304 AND stream_id IN (SELECT thread_id FROM source_threads)
305 `;
306 return revision[0]?.sequence ?? 0;
307 });
308 const snapshot = Effect.fn("ThreadFind.snapshot")(
309 function* (input: OrchestrationV2SearchThreadInput) {
310 const { threadId } = input;
311 const active = yield* sql<{ cwd: string | null }>`
312 SELECT COALESCE(json_extract(t.payload_json, '$.worktreePath'), p.workspace_root) AS cwd
313 FROM orchestration_v2_projection_threads t JOIN projection_projects p ON p.project_id = t.project_id
314 WHERE t.thread_id = ${threadId} AND t.deleted_at IS NULL AND p.deleted_at IS NULL
315 `;
316 if (!active[0]) return yield* new ProjectionStoreThreadNotFoundError({ threadId });
317 const cwd = active[0].cwd ?? undefined;
318 const cacheKey = yield* encodeCacheKey({
319 threadId,
320 cwd,
321 query: input.query,
322 skills: input.skills ?? [],
323 });
324 const cached = scans.get(cacheKey);
325 if (cached && cached.sequence === (yield* readRevision(cached.sourceThreadIds))) {
326 const selected = selectMatch(cached.documents, input);
327 scans.delete(cacheKey);
328 scans.set(cacheKey, cached);
329 return { result: resultForSelection(selected, cached.documents, cached.sequence) };
330 }
331 const index = yield* readIndex(threadId);
332 const sourceThreadIds = [...new Set([threadId, ...index.map((row) => row.sourceThreadId)])];
333 const sequence = yield* readRevision(sourceThreadIds);
334 const candidates = index
335 .map((row, position) => ({ ...row, position }))
336 .filter((row) =>
337 ["user_message", "assistant_message", "proposed_plan", "user_input_request"].includes(
338 row.item.type,
339 ),
340 );
341 const payloads: FindPayload[] = [];
342 for (let start = 0; start < candidates.length; start += 128) {
343 payloads.push(...(yield* load(threadId, candidates.slice(start, start + 128))));
344 }
345 return { payloads, cwd, sequence, cacheKey, sourceThreadIds };
346 },
347 sql.withTransaction,
348 (effect, input) =>
349 effect.pipe(
350 Effect.mapError((cause) =>
351 isSnapshotError(cause)
352 ? cause
353 : new ProjectionStoreReadError({ threadId: input.threadId, cause }),
354 ),
355 ),
356 );
357 const searchThreadStream = (input: OrchestrationV2SearchThreadInput) =>
358 Stream.unwrap(
359 Effect.gen(function* () {
360 // Release SQLite before decoding and parsing. Both frames describe one snapshot.
361 const data = yield* snapshot(input);
362 if (data.result) return Stream.succeed(data.result);
363 const { payloads, cwd, sequence, cacheKey, sourceThreadIds } = data;
364 const decode = (row: FindPayload) =>
365 decodeItem(row.payload).pipe(
366 Effect.map((item) => ({ ...row, item })),
367 Effect.mapError(
368 (cause) => new ProjectionStoreReadError({ threadId: input.threadId, cause }),
369 ),
370 );
371 // Answers rendered inside question cards must not appear as separate matches.
372 const foldedAnswers = new Set<string>();
373 for (const payload of payloads) {
374 if (payload.type !== "user_input_request") continue;
375 const { item } = yield* decode(payload);
376 if (item.type === "user_input_request" && item.questionAnswer)
377 foldedAnswers.add(`async-answer:${item.questionAnswer.requestId}`);
378 }
379 const rows = payloads.filter(
380 (row) =>
381 row.type !== "user_input_request" &&
382 !(row.type === "user_message" && foldedAnswers.has(row.entryId)),
383 );
384 const documents = new Map<number, FindDocument>();
385 const count = Effect.fnUntraced(function* (row: FindPayload) {
386 const cached = documents.get(row.position);
387 if (cached) return cached;
388 const decoded = yield* decode(row);
389 const key = textKey(decoded.item, cwd, input.skills ?? []);
390 const segments = yield* Cache.get(
391 key.text.length <= 32_768 ? textCache : largeTextCache,
392 key,
393 );
394 const document = documentFor(decoded, countSegments(segments, input.query));
395 documents.set(row.position, document);
396 yield* Effect.yieldNow;
397 return document;
398 });
399 const finish = Effect.gen(function* () {
400 for (const row of rows) yield* count(row);
401 const ordered = rows.map((row) => documents.get(row.position)!);
402 scans.delete(cacheKey);
403 scans.set(cacheKey, { sequence, documents: ordered, sourceThreadIds });
404 if (scans.size > 8) {
405 const oldest = scans.keys().next().value;
406 if (oldest !== undefined) scans.delete(oldest);
407 }
408 return resultForSelection(selectMatch(ordered, input), ordered, sequence);
409 });
410 // Relative navigation needs the final counts. Initial searches can reveal an
411 // identity immediately, scanning at/below the reading anchor before wrapping.
412 if (input.index !== undefined || (input.offset ?? 0) !== 0)
413 return Stream.fromEffect(finish);
414 const anchor = Math.max(
415 0,
416 rows.findIndex((row) => row.entryId === input.start?.entryId),
417 );
418 for (let step = 0; step <= rows.length; step++) {
419 const index = (anchor + step) % rows.length;
420 const row = rows[index];
421 if (!row) break;
422 const document = yield* count(row);
423 const occurrence =
424 step === 0 && row.entryId === input.start?.entryId
425 ? Math.min(input.start.occurrence, document.count)
426 : 0;
427 if (occurrence >= document.count) continue;
428 const first: OrchestrationV2SearchThreadResult = {
429 complete: false,
430 snapshotSequence: sequence,
431 totalMatches: 1,
432 activeIndex: 0,
433 match: { entryId: document.entryId, runId: document.runId, occurrence },
434 };
435 // The remainder is pulled only after the first frame has been delivered.
436 return Stream.concat(Stream.succeed(first), Stream.fromEffect(finish));
437 }
438 return Stream.fromEffect(finish);
439 }),
440 );
441 return {
442 searchThreadStream,
443 searchThread: (input: OrchestrationV2SearchThreadInput) =>
444 searchThreadStream(input).pipe(
445 Stream.runCollect,
446 Effect.map((results) => results[results.length - 1]!),
447 ),
448 };
449});
apps/server/src/orchestration-v2/ThreadManagementService.ts+27
......@@ -1,3 +1,7 @@
1import type {
2 OrchestrationV2SearchThreadInput,
3 OrchestrationV2SearchThreadResult,
4} from "@t3tools/contracts";
15import type {
26 ProjectionRecordField,
37 ProjectionRecordFilter,
......@@ -271,12 +275,19 @@ export type ThreadManagementError = typeof ThreadManagementError.Type;
271275type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error;
272276
273277export interface ThreadManagementServiceShape {
278 readonly searchThreadStream: (
279 input: OrchestrationV2SearchThreadInput,
280 ) => Stream.Stream<OrchestrationV2SearchThreadResult, Orchestrator.OrchestratorV2Error>;
281 readonly searchThread: (
282 input: OrchestrationV2SearchThreadInput,
283 ) => Effect.Effect<OrchestrationV2SearchThreadResult, Orchestrator.OrchestratorV2Error>;
274284 readonly ensureLegacyTranscript: (
275285 threadId: ThreadId,
276286 ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>;
277287 readonly dispatch: (
278288 command: OrchestrationV2ServerCommand,
279289 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
290 readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"];
280291 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
281292 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];
282293 /**
......@@ -859,7 +870,23 @@ const make = Effect.gen(function* () {
859870
860871 return ThreadManagementService.of({
861872 ensureLegacyTranscript,
873 searchThreadStream: (input) =>
874 Stream.unwrap(
875 ensureProjectionTranscript(input.threadId).pipe(
876 Effect.as(orchestrator.searchThreadStream(input)),
877 ),
878 ),
879 searchThread: (input) =>
880 ensureProjectionTranscript(input.threadId).pipe(
881 Effect.andThen(orchestrator.searchThread(input)),
882 ),
862883 dispatch,
884 getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) =>
885 ensureProjectionTranscript(threadId).pipe(
886 Effect.andThen(
887 orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly),
888 ),
889 ),
863890 getTimelinePage: (threadId, options) =>
864891 ensureProjectionTranscript(threadId).pipe(
865892 Effect.andThen(orchestrator.getTimelinePage(threadId, options)),
apps/server/src/orchestration-v2/http.ts+21-48
......@@ -1,8 +1,6 @@
11import {
22 AuthOrchestrationReadScope,
33 EnvironmentHttpApi,
4 ThreadId,
5 TurnItemId,
64 type OrchestrationProjectShell,
75} from "@t3tools/contracts";
86import * as Effect from "effect/Effect";
......@@ -24,7 +22,6 @@ import {
2422 buildBoundedThreadProjection,
2523 decodeThreadHistoryCursor,
2624 InvalidThreadHistoryCursorError,
27 selectHistoryPageFromCursor,
2825 THREAD_HISTORY_SNAPSHOT_ROW_LIMIT,
2926 THREAD_HISTORY_PAGE_POLICY,
3027 OLDER_THREAD_USER_TURN_LIMIT,
......@@ -43,22 +40,6 @@ function isThreadNotFound(error: unknown): boolean {
4340 );
4441}
4542
46function selectHistoryPageFromCursorOrError(
47 input: Parameters<typeof selectHistoryPageFromCursor>[0],
48):
49 | { readonly _tag: "ok"; readonly page: ReturnType<typeof selectHistoryPageFromCursor> }
50 | { readonly _tag: "invalid_cursor" }
51 | { readonly _tag: "error"; readonly cause: unknown } {
52 try {
53 return { _tag: "ok", page: selectHistoryPageFromCursor(input) };
54 } catch (cause) {
55 if (cause instanceof InvalidThreadHistoryCursorError) {
56 return { _tag: "invalid_cursor" };
57 }
58 return { _tag: "error", cause };
59 }
60}
61
6243/**
6344 * Serves orchestration V2 snapshots over HTTP so clients can load the
6445 * (potentially large) shell and thread projections off the socket; gzip
......@@ -222,41 +203,33 @@ export const layer = HttpApiBuilder.group(
222203 Effect.fn("environment.orchestration.threadHistoryPage")(function* (args) {
223204 yield* annotateEnvironmentRequest(args.endpoint.name);
224205 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
225 let anchorItemId;
226206 try {
227 anchorItemId = TurnItemId.make(decodeThreadHistoryCursor(args.query.cursor).si);
207 decodeThreadHistoryCursor(args.query.cursor);
228208 } catch (cause) {
229 if (cause instanceof InvalidThreadHistoryCursorError) {
209 if (cause instanceof InvalidThreadHistoryCursorError)
230210 return yield* failEnvironmentInvalidRequest("invalid_history_cursor");
231 }
232211 return yield* failEnvironmentInternal("orchestration_thread_history_failed", cause);
233212 }
234 const decodedCursor = decodeThreadHistoryCursor(args.query.cursor);
235 const snapshot = yield* loadThreadSnapshotWindow(
236 args.params.threadId,
237 anchorItemId,
238 ThreadId.make(decodedCursor.st),
239 ).pipe(traceLocalHandlerWork);
240 const pageOrError = selectHistoryPageFromCursorOrError({
241 items: snapshot.projection.visibleTurnItems,
242 cursor: args.query.cursor,
243 snapshotSequence: snapshot.snapshotSequence,
244 });
245 if (pageOrError._tag === "invalid_cursor") {
246 return yield* failEnvironmentInvalidRequest("invalid_history_cursor");
247 }
248 if (pageOrError._tag === "error") {
249 return yield* failEnvironmentInternal(
250 "orchestration_thread_history_failed",
251 pageOrError.cause,
213 return yield* threadManagement
214 .getThreadHistoryPage(
215 args.params.threadId,
216 args.query.cursor,
217 args.query.throughEntryId,
218 args.query.view === "conversation",
219 )
220 .pipe(
221 traceLocalHandlerWork,
222 Effect.catch(
223 Effect.fnUntraced(function* (cause) {
224 if (isThreadNotFound(cause))
225 return yield* failEnvironmentNotFound("thread_not_found");
226 return yield* failEnvironmentInternal(
227 "orchestration_thread_history_failed",
228 cause,
229 );
230 }),
231 ),
252232 );
253 }
254 return {
255 snapshotSequence: snapshot.snapshotSequence,
256 items: pageOrError.page.items,
257 nextCursor: pageOrError.page.nextCursor,
258 hasMoreHistory: pageOrError.page.hasMoreHistory,
259 };
260233 }),
261234 );
262235 }),
apps/server/src/orchestration-v2/threadHistoryPaging.ts+61-21
......@@ -17,6 +17,11 @@ export const THREAD_HISTORY_PAGE_POLICY = {
1717} as const;
1818
1919export const OLDER_THREAD_USER_TURN_LIMIT = 20;
20
21/** Load conversation rows before the bulk of a turn's folded tool activity. */
22export function isConversationHistoryItem(item: Pick<OrchestrationV2TurnItem, "type">): boolean {
23 return !["command_execution", "reasoning", "file_change", "dynamic_tool"].includes(item.type);
24}
2025export const THREAD_HISTORY_MAX_RAW_TURNS = 150;
2126
2227/** Extra rows let the projection query retain an inclusive cursor and prove another page exists. */
......@@ -39,8 +44,19 @@ export type ThreadHistoryCursorPayload = {
3944 readonly p: number;
4045};
4146
42export type SelectTimelinePageResult = {
43 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
47type HistoryRow = Pick<
48 OrchestrationV2ProjectedTurnItem,
49 "position" | "visibility" | "sourceThreadId" | "sourceItemId"
50> & {
51 readonly item: Pick<OrchestrationV2TurnItem, "type"> & {
52 readonly id?: string;
53 readonly messageId?: string;
54 readonly inputIntent?: string;
55 readonly createdBy?: string;
56 };
57};
58export type SelectTimelinePageResult<Row extends HistoryRow = OrchestrationV2ProjectedTurnItem> = {
59 readonly items: ReadonlyArray<Row>;
4460 readonly nextCursor: string | null;
4561 readonly hasMoreHistory: boolean;
4662};
......@@ -67,7 +83,7 @@ function bytesOfJson(value: unknown): number {
6783}
6884
6985/** Ordinary history-page cost: one projected row (item is nested once). */
70export function projectedRowEncodedBytes(row: OrchestrationV2ProjectedTurnItem): number {
86export function projectedRowEncodedBytes(row: HistoryRow): number {
7187 return bytesOfJson(row);
7288}
7389
......@@ -87,9 +103,7 @@ export function projectedRowBoundedSnapshotEncodedBytes(
87103 return rowBytes;
88104}
89105
90function renumberPositions(
91 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
92): OrchestrationV2ProjectedTurnItem[] {
106function renumberPositions<Row extends HistoryRow>(items: ReadonlyArray<Row>): Row[] {
93107 return items.map((row, position) => (row.position === position ? row : { ...row, position }));
94108}
95109
......@@ -148,7 +162,7 @@ export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPa
148162 return parsed as ThreadHistoryCursorPayload;
149163}
150164
151export function isThreadHistoryTurnStart(item: OrchestrationV2TurnItem): boolean {
165export function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean {
152166 return (
153167 item.type === "user_message" &&
154168 (item.inputIntent === "turn_start" || item.inputIntent === "queued_turn")
......@@ -156,7 +170,7 @@ export function isThreadHistoryTurnStart(item: OrchestrationV2TurnItem): boolean
156170}
157171
158172/** Steering belongs to its existing turn and must not consume another page slot. */
159export function isThreadHistoryUserTurn(item: OrchestrationV2TurnItem): boolean {
173export function isThreadHistoryUserTurn(item: HistoryRow["item"]): boolean {
160174 return (
161175 isThreadHistoryTurnStart(item) && item.type === "user_message" && item.createdBy === "user"
162176 );
......@@ -167,13 +181,13 @@ export function isThreadHistoryUserTurn(item: OrchestrationV2TurnItem): boolean
167181 * through complete user turns. Histories without turn starts use row budgets
168182 * and always admit at least one row so oversized items cannot deadlock paging.
169183 */
170function selectOlderTimelinePage(input: {
171 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
184function selectOlderTimelinePage<Row extends HistoryRow>(input: {
185 readonly items: ReadonlyArray<Row>;
172186 readonly exclusiveEndIndex: number;
173187 readonly snapshotSequence: number;
174188 readonly policy?: ThreadHistoryPagePolicy | undefined;
175 readonly rowEncodedBytes?: ((row: OrchestrationV2ProjectedTurnItem) => number) | undefined;
176}): SelectTimelinePageResult {
189 readonly rowEncodedBytes?: ((row: Row) => number) | undefined;
190}): SelectTimelinePageResult<Row> {
177191 const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY;
178192 const rowCost = input.rowEncodedBytes ?? projectedRowEncodedBytes;
179193 const end = Math.min(Math.max(input.exclusiveEndIndex, 0), input.items.length);
......@@ -181,7 +195,7 @@ function selectOlderTimelinePage(input: {
181195 return { items: [], nextCursor: null, hasMoreHistory: false };
182196 }
183197
184 const selected: OrchestrationV2ProjectedTurnItem[] = [];
198 const selected: Row[] = [];
185199 let encodedBytes = 0;
186200 let userTurns = 0;
187201 let rawTurns = 0;
......@@ -247,7 +261,7 @@ export function selectRecentTimelineWindow(input: {
247261}
248262
249263function findCursorIndex(
250 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
264 items: ReadonlyArray<Pick<HistoryRow, "sourceThreadId" | "sourceItemId">>,
251265 cursor: ThreadHistoryCursorPayload,
252266): number {
253267 const byIdentity = items.findIndex(
......@@ -264,22 +278,48 @@ function findCursorIndex(
264278 return Math.min(cursor.p, items.length);
265279}
266280
267export function selectHistoryPageFromCursor(input: {
268 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
281export function selectHistoryPageFromCursor<Row extends HistoryRow>(input: {
282 readonly items: ReadonlyArray<Row>;
269283 readonly cursor: string;
270284 readonly snapshotSequence: number;
271285 readonly policy?: ThreadHistoryPagePolicy | undefined;
272}): SelectTimelinePageResult {
286 readonly rowEncodedBytes?: ((row: Row) => number) | undefined;
287 readonly throughEntryId?: string | undefined;
288}): SelectTimelinePageResult<Row> {
273289 const decoded = decodeThreadHistoryCursor(input.cursor);
274290 const anchorIndex = findCursorIndex(input.items, decoded);
291 let policy = input.policy ?? {
292 ...THREAD_HISTORY_PAGE_POLICY,
293 maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT,
294 };
295 if (input.throughEntryId !== undefined) {
296 const targetIndex = input.items.findIndex(
297 (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId,
298 );
299 if (targetIndex >= 0 && targetIndex < anchorIndex) {
300 // A search jump can combine two pages, but never grow into an unbounded read.
301 const turns = input.items
302 .slice(targetIndex, anchorIndex)
303 .filter((row) => isThreadHistoryUserTurn(row.item)).length;
304 policy = {
305 ...policy,
306 maxUserTurns: Math.min(
307 OLDER_THREAD_USER_TURN_LIMIT * 2,
308 Math.max(
309 OLDER_THREAD_USER_TURN_LIMIT,
310 turns + (isThreadHistoryUserTurn(input.items[targetIndex]!.item) ? 0 : 1),
311 ),
312 ),
313 maxItems: policy.maxItems * 2,
314 };
315 }
316 }
275317 return selectOlderTimelinePage({
276318 items: input.items,
277319 exclusiveEndIndex: anchorIndex,
278320 snapshotSequence: input.snapshotSequence,
279 policy: input.policy ?? {
280 ...THREAD_HISTORY_PAGE_POLICY,
281 maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT,
282 },
321 ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }),
322 policy,
283323 });
284324}
285325
apps/server/src/relay/AgentAwarenessRelay.test.ts+3
......@@ -193,6 +193,9 @@ const makeTestRelay = Effect.fnUntraced(function* (
193193 readShellSnapshot: unused,
194194 ensureLegacyTranscript: unused,
195195 dispatch: unused,
196 searchThread: () => Effect.die("unused"),
197 searchThreadStream: () => Stream.empty,
198 getThreadHistoryPage: () => Effect.die("unused"),
196199 getTimelinePage: () => Effect.die("Unused timeline read"),
197200 getMessageCount: () => Effect.die("unused message count"),
198201 getTurnItem: () => Effect.die("unused turn item read"),
apps/server/src/ws.ts+11
......@@ -52,6 +52,7 @@ import {
5252 type AcpRegistrySetProviderInput,
5353 OrchestrationGetFullThreadDiffError,
5454 OrchestrationSearchThreadsError,
55 OrchestrationV2SearchThreadError,
5556 OrchestrationGetTurnDiffError,
5657 ORCHESTRATION_V2_WS_METHODS,
5758 ORCHESTRATION_PROTOCOL_QUERY_PARAM,
......@@ -1727,6 +1728,8 @@ const layerWsRpc = (
17271728 shellResumeCompletionMarker: true,
17281729 threadResumeCompletionMarker: true,
17291730 threadSnapshotPagination: true,
1731 threadFind: true,
1732 threadFindProgressive: true,
17301733 ...Option.match(scratchWorkspaceRoot, {
17311734 onNone: () => ({}),
17321735 onSome: (root) => ({ scratchWorkspaceRoot: root }),
......@@ -1894,6 +1897,14 @@ const layerWsRpc = (
18941897 }),
18951898 ),
18961899 ),
1900 [ORCHESTRATION_V2_WS_METHODS.searchThread]: (input) =>
1901 threadManagement
1902 .searchThread(input)
1903 .pipe(Effect.mapError((cause) => new OrchestrationV2SearchThreadError({ cause }))),
1904 [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: (input) =>
1905 threadManagement
1906 .searchThreadStream(input)
1907 .pipe(Stream.mapError((cause) => new OrchestrationV2SearchThreadError({ cause }))),
18971908 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) =>
18981909 threadSearch.search(input).pipe(
18991910 Effect.mapError(
apps/web/package.json+2-5
......@@ -60,10 +60,7 @@
6060 "react": "19.2.6",
6161 "react-dom": "19.2.6",
6262 "react-markdown": "^10.1.0",
63 "rehype-raw": "^7.0.0",
64 "rehype-sanitize": "^6.0.0",
65 "remark-breaks": "^4.0.0",
66 "remark-gfm": "^4.0.1",
63 "remark-gfm": "catalog:",
6764 "tailwind-merge": "^3.4.0",
6865 "unbash": "^5.0.0",
6966 "zustand": "^5.0.11"
......@@ -88,7 +85,7 @@
8885 "jsdom": "^30.0.1",
8986 "react-test-renderer": "19.2.6",
9087 "tailwindcss": "^4.0.0",
91 "unified": "^11.0.5",
88 "unified": "catalog:",
9289 "vite": "catalog:",
9390 "vite-plus": "catalog:"
9491 }
apps/web/src/components/ChatMarkdown.test.tsx+238
......@@ -1,6 +1,12 @@
11// @vitest-environment jsdom
22
33import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";
4import { createRoot } from "react-dom/client";
5import { useThreadFindHighlights } from "./chat/threadFindHighlights";
6import { searchableMessageSegments } from "@t3tools/shared/threadFindText";
7import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch";
8
9import { MarkdownFindContext } from "./chat/markdownFindContext";
410import { act, type ComponentProps, type ReactNode } from "react";
511import { renderToStaticMarkup } from "react-dom/server";
612import { create, type ReactTestRenderer } from "react-test-renderer";
......@@ -12,6 +18,10 @@ import { Button } from "./ui/button";
1218import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1319
1420vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
21vi.mock("./chat/MermaidDiagram", () => ({
22 // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG.
23 MermaidDiagram: () => <svg aria-label="Diagram" />,
24}));
1525vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
1626vi.mock("../hooks/useSettings", async (importOriginal) => {
1727 const actual = await importOriginal<typeof import("../hooks/useSettings")>();
......@@ -1034,3 +1044,231 @@ describe("ChatMarkdown Windows file links", () => {
10341044 expect(html).not.toContain("chat-markdown-file-link");
10351045 });
10361046});
1047
1048it("opens a disclosure only when find selects a match inside it", async () => {
1049 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1050 vi.stubGlobal(
1051 "Highlight",
1052 class extends Set<Range> {
1053 constructor(...ranges: Range[]) {
1054 super(ranges);
1055 }
1056 },
1057 );
1058 const highlights = new Map<string, Set<Range>>();
1059 vi.stubGlobal("CSS", { highlights, escape: (value: string) => value });
1060 const container = document.createElement("div");
1061 document.body.append(container);
1062 const root = createRoot(container);
1063 const openStates = () =>
1064 [...container.querySelectorAll("[data-markdown-details-open]")].map((node) =>
1065 node.getAttribute("data-markdown-details-open"),
1066 );
1067 function Probe({
1068 activeOccurrence,
1069 searching = true,
1070 }: {
1071 activeOccurrence: number;
1072 searching?: boolean;
1073 }) {
1074 useThreadFindHighlights({
1075 container,
1076 query: searching ? "needle" : "",
1077 activeRowId: "row",
1078 activeOccurrence,
1079 onActiveRange: () => {},
1080 });
1081 return (
1082 <div data-timeline-row-id="row">
1083 <div data-thread-find-text>
1084 <MarkdownFindContext value={searching}>
1085 <ChatMarkdown
1086 cwd={undefined}
1087 text={[
1088 "Visible needle.",
1089 "<details><summary>Unrelated</summary><p>nothing here</p></details>",
1090 "<details><summary>Outer</summary><details><summary>Inner</summary><p>needle</p></details></details>",
1091 ].join("\n\n")}
1092 />
1093 </MarkdownFindContext>
1094 </div>
1095 </div>
1096 );
1097 }
1098 const frame = () =>
1099 act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
1100 try {
1101 // Closed panels are unmounted until find starts, as in the app.
1102 await act(() => root.render(<Probe activeOccurrence={0} searching={false} />));
1103 expect(container.textContent).not.toContain("nothing here");
1104 await act(() => root.render(<Probe activeOccurrence={0} />));
1105 await frame();
1106 // Selecting the visible match opens nothing; the folded one is counted but not painted.
1107 expect(openStates()).toEqual(["false", "false", "false"]);
1108 expect(container.textContent).toContain("nothing here");
1109 expect(
1110 [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()),
1111 ).toEqual(["needle"]);
1112 expect(highlights.get("t3-thread-find")?.size).toBe(0);
1113
1114 await act(() => root.render(<Probe activeOccurrence={1} />));
1115 await frame();
1116 await frame();
1117 // Stepping to the folded match opens its two ancestors, not the unrelated one.
1118 expect(openStates()).toEqual(["false", "true", "true"]);
1119 expect(highlights.get("t3-thread-find-active")?.size).toBe(1);
1120 } finally {
1121 await act(() => root.unmount());
1122 container.remove();
1123 vi.unstubAllGlobals();
1124 }
1125});
1126
1127it("keeps Mermaid diagrams rendered until find selects a match in their source", async () => {
1128 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1129 vi.stubGlobal(
1130 "Highlight",
1131 class extends Set<Range> {
1132 constructor(...ranges: Range[]) {
1133 super(ranges);
1134 }
1135 },
1136 );
1137 const highlights = new Map<string, Set<Range>>();
1138 vi.stubGlobal("CSS", { highlights, escape: (value: string) => value });
1139 const container = document.createElement("div");
1140 document.body.append(container);
1141 const root = createRoot(container);
1142 const diagram = "```mermaid\ngraph TD; Alpha-->Beta\n```";
1143 function Probe({ query }: { query: string }) {
1144 useThreadFindHighlights({
1145 container,
1146 query,
1147 activeRowId: "row",
1148 activeOccurrence: 0,
1149 onActiveRange: () => {},
1150 });
1151 return (
1152 <div data-timeline-row-id="row">
1153 <div data-thread-find-text>
1154 <MarkdownFindContext value={true}>
1155 <ChatMarkdown cwd={undefined} text={`Needle first.\n\n${diagram}\n\n${diagram}`} />
1156 </MarkdownFindContext>
1157 </div>
1158 </div>
1159 );
1160 }
1161 const frame = () =>
1162 act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
1163 const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length;
1164 try {
1165 await act(() => root.render(<Probe query="Needle" />));
1166 await frame();
1167 expect(diagrams()).toBe(2);
1168 await act(() => root.render(<Probe query="Alpha" />));
1169 await frame();
1170 await frame();
1171 // Only the diagram holding the selected match switches to source.
1172 expect(diagrams()).toBe(1);
1173 expect(
1174 [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()),
1175 ).toEqual(["Alpha"]);
1176 } finally {
1177 await act(() => root.unmount());
1178 container.remove();
1179 vi.unstubAllGlobals();
1180 }
1181});
1182
1183it.each([
1184 {
1185 text: "```mermaid\ngraph TD; SearchSourceAlpha-->B\n```",
1186 query: "SearchSourceAlpha",
1187 count: 1,
1188 },
1189 {
1190 text: "★ Insight ─────\nfirst line\nsecond line",
1191 query: "first line second",
1192 count: 0,
1193 lineBreaks: true,
1194 },
1195 { text: '```ts title="src/needle.ts"\nconst a = 1;\n```', query: "needle", count: 0 },
1196 { text: "```weirdlang\nconst a = 1;\n```", query: "weirdlang", count: 0 },
1197 { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 },
1198 { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true },
1199 { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 },
1200 {
1201 text: "<details><summary>Folded</summary><p>Hidden needle</p></details>",
1202 query: "Hidden needle",
1203 count: 1,
1204 },
1205 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, useTemplate: true },
1206 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Document template", count: 1, useTemplate: true },
1207 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, useTemplate: true },
1208 { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, useTemplate: true },
1209])(
1210 "highlights the indexed occurrences of $query in $text",
1211 async ({ text, query, count, lineBreaks, user, useTemplate }) => {
1212 const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }];
1213 const highlights = new Map<string, Set<Range>>();
1214 vi.stubGlobal(
1215 "Highlight",
1216 class extends Set<Range> {
1217 constructor(...ranges: Range[]) {
1218 super(ranges);
1219 }
1220 },
1221 );
1222 vi.stubGlobal("CSS", { highlights, escape: (value: string) => value });
1223 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1224 const container = document.createElement("div");
1225 document.body.append(container);
1226 const root = createRoot(container);
1227 function Probe() {
1228 useThreadFindHighlights({
1229 container,
1230 query,
1231 activeRowId: "row",
1232 activeOccurrence: 0,
1233 onActiveRange: () => {},
1234 });
1235 return (
1236 <div data-timeline-row-id="row">
1237 <div data-thread-find-text>
1238 <MarkdownFindContext value={true}>
1239 <ChatMarkdown
1240 text={text}
1241 cwd={undefined}
1242 skills={skills}
1243 lineBreaks={lineBreaks ?? false}
1244 parseRawHtml={!user}
1245 onUseArtifactTemplate={useTemplate ? () => undefined : undefined}
1246 />
1247 </MarkdownFindContext>
1248 </div>
1249 </div>
1250 );
1251 }
1252 try {
1253 await act(() => root.render(<Probe />));
1254 await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
1255 const ranges = [...highlights.values()].flatMap((value) => [...value]);
1256 expect(ranges.map((range) => range.toString())).toEqual(
1257 Array.from({ length: count }, () => query),
1258 );
1259 const segments =
1260 searchableMessageSegments(
1261 { role: user ? "user" : "assistant", text, streaming: false },
1262 undefined,
1263 skills,
1264 ) ?? [];
1265 expect(
1266 segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0),
1267 ).toBe(count);
1268 } finally {
1269 await act(() => root.unmount());
1270 container.remove();
1271 vi.unstubAllGlobals();
1272 }
1273 },
1274);
apps/web/src/components/ChatMarkdown.tsx+68-379
......@@ -1,5 +1,24 @@
1import { MarkdownFindContext, useFindRevealRef } from "./chat/markdownFindContext";
2import {
3 buildFileLinkParentSuffixByPath,
4 fileLinkLabel,
5 resolvePathLinkTarget,
6} from "@t3tools/shared/fileLinks";
7import {
8 isWindowsDrivePathHref,
9 normalizeMarkdownLinkDestination,
10 extractInlineCodeSpans,
11 extractMarkdownLinkHrefs,
12 inlineCodeFilePathCandidate,
13} from "@t3tools/shared/markdownLinks";
14import { isAbsolutePath } from "@t3tools/shared/path";
115import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
216import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";
17import {
18 CHAT_MARKDOWN_REMARK_PLUGINS,
19 CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
20 CHAT_MARKDOWN_REHYPE_PLUGINS,
21} from "@t3tools/shared/markdownPipeline";
322import { useAtomValue } from "@effect/atom-react";
423import {
524 COMPOSER_CONTEXT_CLIPBOARD_MIME,
......@@ -46,12 +65,11 @@ import {
4665 codexArtifactTemplatePresentationLabel,
4766 type CodexArtifactTemplate,
4867 type CodexArtifactTemplateKind,
49} from "@t3tools/client-runtime/codex-artifact-templates";
68} from "@t3tools/shared/codexArtifactTemplates";
5069import {
5170 classifyMarkdownImageSource,
5271 markdownImageSourceFragment,
5372} from "@t3tools/client-runtime/markdown-images";
54import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
5573import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
5674import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
5775import * as Cause from "effect/Cause";
......@@ -83,24 +101,16 @@ import ReactMarkdown from "react-markdown";
83101import { toHtml } from "hast-util-to-html";
84102import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
85103import { defaultUrlTransform } from "react-markdown";
86import rehypeRaw from "rehype-raw";
87import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
88import remarkBreaks from "remark-breaks";
89104import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
90105import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
91import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks";
106import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
92107import { AssistantCitationChip } from "./chat/AssistantCitationChip";
93108import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
94import remarkGfm from "remark-gfm";
95import type { Processor } from "unified";
96109import { isWindowsAbsolutePath } from "@t3tools/shared/path";
97import { remarkGithubAlerts } from "../markdown-github-alerts";
98110import {
99111 artifactTemplateFromHastProperties,
100 CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES,
101 remarkCodexDirectives,
102112 renderCodexFileCitationsAsMarkdown,
103} from "@t3tools/client-runtime/codex-markdown-directives";
113} from "@t3tools/shared/codexMarkdownDirectives";
104114import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
105115import {
106116 resolveMarkdownMediaPreview,
......@@ -153,11 +163,7 @@ import {
153163 serializeTableElementToCsv,
154164 serializeTableElementToMarkdown,
155165} from "../markdown-clipboard";
156import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
157166import {
158 extractMarkdownLinkHrefs,
159 isWindowsDrivePathHref,
160 normalizeMarkdownLinkDestination,
161167 resolveInlineCodeFileLinkMeta,
162168 resolveMarkdownFileLinkMeta,
163169 rewriteMarkdownFileUriHref,
......@@ -165,7 +171,7 @@ import {
165171 shouldOpenMarkdownFileLinkInEditor,
166172 type MarkdownFileLinkMeta,
167173} from "../markdown-links";
168import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
174import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
169175import { readLocalApi } from "../localApi";
170176import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
171177import { cn } from "../lib/utils";
......@@ -195,7 +201,6 @@ import {
195201import { useOpenLink } from "../browser/useOpenLink";
196202import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
197203import { isPreviewAvailableFor } from "../browser/previewRuntime";
198import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
199204import {
200205 isBrowserPreviewFile,
201206 openFileInPreview,
......@@ -322,15 +327,17 @@ function CodexArtifactTemplateCard(props: {
322327 <SparklesIcon aria-hidden className="size-2.5" />
323328 </span>
324329 </span>
325 <span className="min-w-0">
326 <span className="block truncate text-sm font-medium text-foreground">
330 {/* Block elements keep the name and label separate thread-find segments. */}
331 <div className="min-w-0">
332 <div className="truncate text-sm font-medium text-foreground">
327333 {props.template.displayName}
328 </span>
329 <span className="block text-xs text-muted-foreground">{presentationLabel}</span>
330 </span>
334 </div>
335 <div className="text-xs text-muted-foreground">{presentationLabel}</div>
336 </div>
331337 </div>
332338 {props.onUse ? (
333339 <Button
340 data-thread-find-ignore
334341 type="button"
335342 size="sm"
336343 variant="outline"
......@@ -396,190 +403,6 @@ function orderedListGutterStyle(
396403 return { "--list-gutter": `${markerWidth + 2}ch` };
397404}
398405
399type MarkdownImageHastNode = {
400 type?: string;
401 value?: string;
402 tagName?: string;
403 properties?: Record<string, unknown>;
404 children?: MarkdownImageHastNode[];
405};
406
407function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] {
408 return (node.children ?? []).filter(
409 (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""),
410 );
411}
412
413/**
414 * An image that is the only content of its block (optionally wrapped in a
415 * link) is almost always a screenshot or figure, so it gets a reserved slot
416 * while it loads. Images mixed with text or other images — badge rows, icons
417 * in a sentence — stay inline at their natural size, since a placeholder taller
418 * than the image would move the page more than the image itself does.
419 */
420/** Containers whose sole child image reads as a figure rather than part of a sentence. */
421const STANDALONE_IMAGE_BLOCKS = new Set([
422 "p",
423 "div",
424 "li",
425 "td",
426 "th",
427 "figure",
428 "center",
429 "blockquote",
430]);
431
432function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined {
433 const children = meaningfulHastChildren(node);
434 if (children.length !== 1) return undefined;
435 const only = children[0];
436 if (only?.type !== "element") return undefined;
437 if (only.tagName === "img") return only;
438 // A link, emphasis, or similar inline wrapper around the image still counts
439 // as long as nothing else shares the block.
440 return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em"
441 ? soleImageDescendant(only)
442 : undefined;
443}
444
445function markStandaloneImages(node: MarkdownImageHastNode) {
446 // A raw `<img>` on its own line reaches the root without a paragraph.
447 if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) {
448 const image = soleImageDescendant(node);
449 if (image) image.properties = { ...image.properties, dataStandalone: true };
450 }
451 node.children?.forEach((child) => {
452 if (child.type === "element") markStandaloneImages(child);
453 });
454}
455
456/** Keep unmatched inline `<A>` placeholders from opening an HTML link over later blocks. */
457function rehypePreserveBareAnchorPlaceholders() {
458 return (tree: MarkdownImageHastNode) => {
459 const anchors: Array<MarkdownImageHastNode | null> = [];
460 let rawTextTag: string | undefined;
461 const visit = (node: MarkdownImageHastNode) => {
462 if (node.type === "raw" && typeof node.value === "string") {
463 // Raw blocks can contain several tags. Consume whole tags, quoted attributes,
464 // and comments so text resembling a closing anchor cannot pair a placeholder.
465 const tags = /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g;
466 let offset = 0;
467 while (rawTextTag !== "plaintext") {
468 // Raw text ends at its closing tag even inside comment-looking text.
469 const matcher = rawTextTag ? new RegExp(`</${rawTextTag}\\s*>`, "gi") : tags;
470 matcher.lastIndex = offset;
471 const match = matcher.exec(node.value);
472 if (!match) break;
473 const [tag] = match;
474 offset = matcher.lastIndex;
475 if (rawTextTag) {
476 rawTextTag = undefined;
477 continue;
478 }
479 if (tag.startsWith("<!--")) continue;
480 const closing = /^<\/([a-z]+)\s*>$/i.exec(tag)?.[1]?.toLowerCase();
481 const opening = /^<([a-z]+)(?:\s|\/?>)/i.exec(tag)?.[1]?.toLowerCase();
482 if (
483 opening &&
484 /^(?:script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext)$/.test(opening)
485 ) {
486 rawTextTag = opening;
487 } else if (opening === "a") {
488 anchors.push(node.value === tag && /^<a\s*\/?>$/i.test(tag) ? node : null);
489 } else if (closing === "a") {
490 anchors.pop();
491 }
492 }
493 }
494 node.children?.forEach(visit);
495 };
496
497 visit(tree);
498 for (const anchor of anchors) {
499 if (anchor) anchor.type = "text";
500 }
501 };
502}
503
504/** Carries authored image source metadata through the sanitizer to the image renderer. */
505function rehypePreserveImageSourceMeta() {
506 return (tree: MarkdownImageHastNode) => {
507 const visit = (node: MarkdownImageHastNode) => {
508 const src = node.properties?.src;
509 const title = node.properties?.title;
510 if (node.type === "element" && node.tagName === "img") {
511 node.properties = {
512 ...node.properties,
513 ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}),
514 ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}),
515 };
516 }
517 node.children?.forEach(visit);
518 };
519
520 visit(tree);
521 markStandaloneImages(tree);
522 };
523}
524
525const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
526 ...defaultSchema,
527 attributes: {
528 ...defaultSchema.attributes,
529 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
530 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
531 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
532 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
533 a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
534 img: [
535 ...(defaultSchema.attributes?.img ?? []),
536 "dataLocalSrc",
537 "dataMarkdownTitle",
538 "dataStandalone",
539 ],
540 },
541 protocols: {
542 ...defaultSchema.protocols,
543 href: [
544 ...(defaultSchema.protocols?.href ?? []),
545 "file",
546 "t3-citation",
547 "t3-context",
548 THREAD_LINK_PROTOCOL,
549 ],
550 src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
551 },
552} satisfies Parameters<typeof rehypeSanitize>[0];
553
554const CHAT_MARKDOWN_REMARK_PLUGINS = [
555 remarkGfm,
556 remarkKeepWindowsPathDestinations,
557 remarkGithubAlerts,
558 remarkNormalizeListItemIndentation,
559 remarkCodexDirectives,
560 remarkPreserveCodeMeta,
561 remarkNormalizeLinksAndTagInlineCode,
562] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
563
564const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
565 remarkGfm,
566 remarkKeepWindowsPathDestinations,
567 remarkGithubAlerts,
568 remarkNormalizeListItemIndentation,
569 remarkCodexDirectives,
570 remarkBreaks,
571 remarkPreserveCodeMeta,
572 remarkNormalizeLinksAndTagInlineCode,
573] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
574
575const CHAT_MARKDOWN_REHYPE_PLUGINS = [
576 rehypePreserveBareAnchorPlaceholders,
577 rehypeRaw,
578 rehypePreserveImageSourceMeta,
579 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
580] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>;
581
582/** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */
583406const GITHUB_ALERT_PRESENTATIONS: Record<
584407 string,
585408 { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }
......@@ -680,87 +503,6 @@ type MarkdownAstNode = {
680503 children?: MarkdownAstNode[];
681504};
682505
683function remarkPreserveCodeMeta() {
684 return (tree: MarkdownAstNode) => {
685 const visit = (node: MarkdownAstNode) => {
686 if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) {
687 node.data = {
688 ...node.data,
689 hProperties: {
690 ...node.data?.hProperties,
691 dataCodeMeta: node.meta.trim(),
692 },
693 };
694 }
695 node.children?.forEach(visit);
696 };
697
698 visit(tree);
699 };
700}
701
702interface DestinationCompileContext {
703 readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>;
704 resume(): string;
705 sliceSerialize(token: unknown): string;
706}
707
708function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) {
709 const decoded = this.resume();
710 const authored = this.sliceSerialize(token);
711 const node = this.stack.at(-1);
712 // Character references still need decoding, so those destinations keep the parsed URL.
713 if (node)
714 node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded;
715}
716
717/**
718 * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link
719 * destination. Every backslash in a Windows path is a separator, so link, image, and
720 * definition destinations that are Windows paths keep the text as written.
721 */
722function remarkKeepWindowsPathDestinations(this: Processor) {
723 const data = this.data();
724 (data.fromMarkdownExtensions ??= []).push({
725 exit: {
726 resourceDestinationString: keepWindowsPathDestination,
727 definitionDestinationString: keepWindowsPathDestination,
728 },
729 });
730}
731
732/**
733 * Preserve Windows drive links as allowed `file:` URLs before sanitization.
734 * The same traversal tags inline code while it can still be distinguished
735 * from fenced code. Code inside links stays untagged to avoid nested anchors.
736 */
737function remarkNormalizeLinksAndTagInlineCode() {
738 return (tree: MarkdownAstNode) => {
739 const visit = (node: MarkdownAstNode, insideLink: boolean) => {
740 if (
741 (node.type === "link" || node.type === "definition") &&
742 typeof node.url === "string" &&
743 WINDOWS_DRIVE_PATH_REGEX.test(node.url)
744 ) {
745 node.url = `file:///${node.url.replaceAll("\\", "/")}`;
746 }
747 if (node.type === "inlineCode" && !insideLink) {
748 node.data = {
749 ...node.data,
750 hProperties: {
751 ...node.data?.hProperties,
752 dataInlineCode: "",
753 },
754 };
755 }
756 const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
757 node.children?.forEach((child) => visit(child, childInsideLink));
758 };
759
760 visit(tree, false);
761 };
762}
763
764506function nodeToPlainText(node: ReactNode): string {
765507 if (typeof node === "string" || typeof node === "number") {
766508 return String(node);
......@@ -947,6 +689,12 @@ function MarkdownDetails({
947689 open = false,
948690}: Pick<React.ComponentProps<"details">, "children" | "open">) {
949691 const [isOpen, setIsOpen] = useState(open);
692 const searching = use(MarkdownFindContext);
693 const expanded = isOpen;
694 const revealForFind = useCallback(() => setIsOpen(true), []);
695 // Base UI only listens for `beforematch` on a panel mounted at its first
696 // render; a closed panel mounts later, when find starts, so listen here.
697 const findRevealRef = useFindRevealRef(revealForFind);
950698 const childNodes = Children.toArray(children);
951699 const summaryIndex = childNodes.findIndex(
952700 (child) => isValidElement(child) && child.type === "summary",
......@@ -961,10 +709,10 @@ function MarkdownDetails({
961709 return (
962710 <div className="my-2 border-y border-border/60">
963711 <Collapsible
964 defaultOpen={open}
712 open={expanded}
965713 onOpenChange={setIsOpen}
966714 data-markdown-details=""
967 data-markdown-details-open={isOpen ? "true" : "false"}
715 data-markdown-details-open={expanded ? "true" : "false"}
968716 >
969717 <CollapsibleTrigger
970718 className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90"
......@@ -976,7 +724,7 @@ function MarkdownDetails({
976724 />
977725 <span>{summary}</span>
978726 </CollapsibleTrigger>
979 <CollapsiblePanel>
727 <CollapsiblePanel ref={findRevealRef} hiddenUntilFound={searching}>
980728 <div
981729 className="pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"
982730 data-markdown-details-content=""
......@@ -995,6 +743,10 @@ function MarkdownDetails({
995743 * language title, an optional actions slot, and the body as children. The
996744 * composer passes Tiptap's node view wrapper as the outer element.
997745 */
746const THREAD_FIND_IGNORE_PROPS = {
747 "data-thread-find-ignore": "",
748} as React.HTMLAttributes<HTMLDivElement>;
749
998750export function MarkdownCodeBlockFrame({
999751 as: Wrapper = "div",
1000752 language,
......@@ -1203,6 +955,8 @@ function MarkdownCodeBlock({
1203955 fenceTitle={fenceTitle}
1204956 theme={theme}
1205957 wrapped={wrapped}
958 // Find does not count the header, so it must not highlight it either.
959 headerProps={THREAD_FIND_IGNORE_PROPS}
1206960 actions={
1207961 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
1208962 {leadingActions}
......@@ -1272,6 +1026,9 @@ function MarkdownMermaidCodeBlock({
12721026 children: ReactNode;
12731027}) {
12741028 const [showCode, setShowCode] = useState(false);
1029 const searching = use(MarkdownFindContext);
1030 const revealSource = useCallback(() => setShowCode(true), []);
1031 const sourceRevealRef = useFindRevealRef(revealSource);
12751032 const showDiagram = !showCode && !isStreaming && code.trim().length > 0;
12761033 const toggleLabel = showCode ? "Show diagram" : "Show code";
12771034 return (
......@@ -1315,6 +1072,12 @@ function MarkdownMermaidCodeBlock({
13151072 >
13161073 <MermaidDiagram source={code} theme={theme} onExpand={onExpand} />
13171074 </Suspense>
1075 {/* Find counts the source; a selected match here switches to it. */}
1076 {searching ? (
1077 <pre ref={sourceRevealRef} hidden>
1078 {code}
1079 </pre>
1080 ) : null}
13181081 </RenderErrorBoundary>
13191082 ) : (
13201083 children
......@@ -1454,79 +1217,6 @@ interface MarkdownFileLinkProps {
14541217
14551218const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link";
14561219
1457function pathParentSegments(path: string): string[] {
1458 const normalized = path.replaceAll("\\", "/");
1459 const segments = normalized.split("/").filter((segment) => segment.length > 0);
1460 return segments.slice(0, -1);
1461}
1462
1463function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> {
1464 const groups = new Map<string, Set<string>>();
1465 for (const filePath of filePaths) {
1466 const normalizedPath = filePath.replaceAll("\\", "/");
1467 const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0);
1468 const basename = pathSegments[pathSegments.length - 1];
1469 if (!basename) continue;
1470 const group = groups.get(basename) ?? new Set<string>();
1471 group.add(normalizedPath);
1472 groups.set(basename, group);
1473 }
1474
1475 const suffixByPath = new Map<string, string>();
1476 for (const group of groups.values()) {
1477 const uniquePaths = [...group];
1478 if (uniquePaths.length < 2) continue;
1479
1480 const parentSegmentsByPath = new Map(
1481 uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]),
1482 );
1483 const minUniqueDepthByPath = new Map<string, number>();
1484
1485 for (const filePath of uniquePaths) {
1486 const segments = parentSegmentsByPath.get(filePath) ?? [];
1487 let resolvedDepth = segments.length;
1488 for (let depth = 1; depth <= segments.length; depth += 1) {
1489 const candidate = segments.slice(-depth).join("/");
1490 const collision = uniquePaths.some((otherPath) => {
1491 if (otherPath === filePath) return false;
1492 const otherSegments = parentSegmentsByPath.get(otherPath) ?? [];
1493 return otherSegments.slice(-depth).join("/") === candidate;
1494 });
1495 if (!collision) {
1496 resolvedDepth = depth;
1497 break;
1498 }
1499 }
1500 minUniqueDepthByPath.set(filePath, resolvedDepth);
1501 }
1502
1503 for (const filePath of uniquePaths) {
1504 const segments = parentSegmentsByPath.get(filePath) ?? [];
1505 if (segments.length === 0) continue;
1506 const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1;
1507 const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2));
1508 suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/"));
1509 }
1510 }
1511
1512 return suffixByPath;
1513}
1514
1515const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;
1516const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g;
1517
1518function extractInlineCodeSpans(text: string): string[] {
1519 const spans: string[] = [];
1520 const segments = text.split(FENCED_CODE_SEGMENT_PATTERN);
1521 for (let index = 0; index < segments.length; index += 2) {
1522 for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) {
1523 const span = match[1]?.trim();
1524 if (span) spans.push(span);
1525 }
1526 }
1527 return spans;
1528}
1529
15301220function normalizeMarkdownLinkHrefKey(href: string): string {
15311221 const normalizedHref = normalizeMarkdownLinkDestination(href);
15321222 const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
......@@ -1659,7 +1349,8 @@ function ChatMarkdownMediaUnavailableLabel(props: {
16591349}) {
16601350 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
16611351 return (
1662 <span className="inline-flex items-center gap-1.5">
1352 // Find indexes no image text, so this fallback must not highlight either.
1353 <span data-thread-find-ignore className="inline-flex items-center gap-1.5">
16631354 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
16641355 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
16651356 </span>
......@@ -2877,18 +2568,6 @@ function useChatMarkdownState({
28772568 );
28782569 const fileLinkChip = useCallback(
28792570 (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => {
2880 const parentSuffix = fileLinkParentSuffixByPath.get(
2881 fileLinkMeta.filePath.replaceAll("\\", "/"),
2882 );
2883 const labelParts = [fileLinkMeta.basename];
2884 if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
2885 labelParts.push(parentSuffix);
2886 }
2887 if (fileLinkMeta.line) {
2888 labelParts.push(
2889 `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
2890 );
2891 }
28922571 const mediaPath = mediaSource ?? fileLinkMeta.filePath;
28932572 const canPreviewMedia =
28942573 mediaMimeTypeFromExtension(
......@@ -2908,7 +2587,14 @@ function useChatMarkdownState({
29082587 displayPath={fileLinkMeta.displayPath}
29092588 panelPath={panelPath}
29102589 line={fileLinkMeta.line}
2911 label={labelParts.join(" · ")}
2590 label={fileLinkLabel(
2591 {
2592 path: fileLinkMeta.filePath,
2593 ...(fileLinkMeta.line !== undefined ? { line: fileLinkMeta.line } : {}),
2594 ...(fileLinkMeta.column !== undefined ? { column: fileLinkMeta.column } : {}),
2595 },
2596 fileLinkParentSuffixByPath,
2597 )}
29122598 copyMarkdown={copyMarkdown}
29132599 theme={resolvedTheme}
29142600 threadRef={threadRef}
......@@ -3086,7 +2772,10 @@ const CHAT_MARKDOWN_COMPONENTS = {
30862772 // text under a colored title — which is how the host renders it.
30872773 return (
30882774 <div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
3089 <p className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}>
2775 <p
2776 data-thread-find-ignore="true"
2777 className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}
2778 >
30902779 <alert.Icon aria-hidden className="size-3.5 shrink-0" />
30912780 {alert.label}
30922781 </p>
apps/web/src/components/ChatView.logic.test.ts+1-1
......@@ -24,7 +24,7 @@ import {
2424 type OrchestrationV2ProjectedTurnItem,
2525 type WorktreeSetupSnapshot,
2626} from "@t3tools/contracts";
27import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
27import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
2828import * as DateTime from "effect/DateTime";
2929import * as Option from "effect/Option";
3030import { afterEach, describe, expect, it, vi } from "vite-plus/test";
apps/web/src/components/ChatView.logic.ts+1-1
......@@ -38,7 +38,7 @@ import {
3838 appendCodexArtifactTemplateUsePrompt,
3939 codexArtifactTemplateUsePrompt,
4040 type CodexArtifactTemplate,
41} from "@t3tools/client-runtime/codex-artifact-templates";
41} from "@t3tools/shared/codexArtifactTemplates";
4242import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
4343import {
4444 type ChatMessage,
apps/web/src/components/ChatView.tsx+71-13
......@@ -1,4 +1,5 @@
1import { ChatCanvas } from "./chat/ChatCanvas";
1import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider";
2import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";
23import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
34import {
45 resolveBackgroundDraftWorkspaceOptions,
......@@ -608,7 +609,7 @@ import {
608609const EMPTY_PROVIDERS: ServerProvider[] = [];
609610const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = [];
610611const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
611import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
612import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
612613
613614const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
614615const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = [];
......@@ -1735,10 +1736,13 @@ export default function ChatView(props: ChatViewProps) {
17351736 hasMoreHistory: serverThreadHistory.hasMoreHistory,
17361737 loading: serverThreadHistory.loading,
17371738 error: serverThreadHistory.error,
1738 onLoadEarlier: () => {
1739 onLoadEarlier: (throughEntryId) => {
17391740 void loadEarlierThreadHistory({
17401741 environmentId: routeThreadDetailRef.environmentId,
1741 input: { threadId: routeThreadDetailRef.threadId },
1742 input: {
1743 threadId: routeThreadDetailRef.threadId,
1744 ...(throughEntryId === undefined ? {} : { throughEntryId }),
1745 },
17421746 });
17431747 },
17441748 };
......@@ -7931,6 +7935,18 @@ export default function ChatView(props: ChatViewProps) {
79317935 }),
79327936 [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase],
79337937 );
7938 const timelineSkills = activeProviderStatus
7939 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
7940 : EMPTY_PROVIDER_SKILLS;
7941 const threadFindControlsRef = useRef<ThreadFindControls | null>(null);
7942 const [isThreadFindActive, setIsThreadFindActive] = useState(false);
7943 const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []);
7944 const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []);
7945 // The details popover hangs off the header over the find bar; opening find dismisses it.
7946 useEffect(() => {
7947 if (!isThreadFindActive || threadPanelPresentation !== "popover" || !activeThreadRef) return;
7948 useRightPanelStore.getState().setThreadPanelOpen(activeThreadRef, "popover", false);
7949 }, [activeThreadRef, isThreadFindActive, threadPanelPresentation]);
79347950
79357951 useEffect(() => {
79367952 const handler = (event: globalThis.KeyboardEvent) => {
......@@ -7938,6 +7954,8 @@ export default function ChatView(props: ChatViewProps) {
79387954 event.stopPropagation();
79397955 return;
79407956 }
7957 // Let contextual controls claim Escape before the bubbling find handler.
7958 if (isThreadFindActive && event.key === "Escape") return;
79417959 if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {
79427960 event.stopPropagation();
79437961 return;
......@@ -8029,6 +8047,14 @@ export default function ChatView(props: ChatViewProps) {
80298047 return;
80308048 }
80318049
8050 // Drafts and servers without thread search leave Mod+F to the browser.
8051 if (command === "chat.find" && isServerThread && serverConfig?.threadFind === true) {
8052 event.preventDefault();
8053 event.stopPropagation();
8054 openThreadFind();
8055 return;
8056 }
8057
80328058 if (command === "rightPanel.toggle") {
80338059 event.preventDefault();
80348060 event.stopPropagation();
......@@ -8211,8 +8237,28 @@ export default function ChatView(props: ChatViewProps) {
82118237 event.stopPropagation();
82128238 void runProjectScript(script);
82138239 };
8240 const dismissFind = (event: KeyboardEvent) => {
8241 if (
8242 event.key !== "Escape" ||
8243 !isThreadFindActive ||
8244 event.defaultPrevented ||
8245 event.isComposing ||
8246 event.keyCode === 229 ||
8247 isCommandPaletteOpen()
8248 )
8249 return;
8250 const context = getShortcutContext(event.target);
8251 if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return;
8252 event.preventDefault();
8253 closeThreadFind();
8254 focusComposer();
8255 };
82148256 window.addEventListener("keydown", handler, true);
8215 return () => window.removeEventListener("keydown", handler, true);
8257 window.addEventListener("keydown", dismissFind);
8258 return () => {
8259 window.removeEventListener("keydown", handler, true);
8260 window.removeEventListener("keydown", dismissFind);
8261 };
82168262 }, [
82178263 activeProject,
82188264 activeRightPanelSurface,
......@@ -8239,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) {
82398285 scriptKeybindings,
82408286 handleUnsettleActiveThread,
82418287 isServerThread,
8288 serverConfig?.threadFind,
82428289 onInterrupt,
82438290 onToggleDiff,
82448291 pinThread,
......@@ -8248,6 +8295,9 @@ export default function ChatView(props: ChatViewProps) {
82488295 confirmAndUnpinThread,
82498296 copyActiveThreadReference,
82508297 getShortcutContext,
8298 openThreadFind,
8299 closeThreadFind,
8300 isThreadFindActive,
82518301 toggleRightPanel,
82528302 toggleThreadPanel,
82538303 toggleTerminalVisibility,
......@@ -11237,7 +11287,18 @@ export default function ChatView(props: ChatViewProps) {
1123711287 {/* Main content area with optional plan sidebar */}
1123811288 <div className="relative flex min-h-0 min-w-0 flex-1">
1123911289 {/* Chat column */}
11240 <ChatCanvas
11290 <ThreadFindCanvas
11291 findOptions={{
11292 skills: timelineSkills,
11293 progressive: serverConfig?.threadFindProgressive === true,
11294 thread: activeThreadRef,
11295 enabled:
11296 isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline,
11297 content: serverProjection ?? undefined,
11298 }}
11299 controlsRef={threadFindControlsRef}
11300 onOpenChange={setIsThreadFindActive}
11301 detailsCardTopInset={isThreadFindActive ? THREAD_FIND_BAR_RESERVED_HEIGHT : 0}
1124111302 composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
1124211303 data-chat-workspace-drop-target="true"
1124311304 onDragEnter={workspaceFileDropHandlers.onDragEnter}
......@@ -11245,6 +11306,7 @@ export default function ChatView(props: ChatViewProps) {
1124511306 onDragLeave={workspaceFileDropHandlers.onDragLeave}
1124611307 onDrop={workspaceFileDropHandlers.onDrop}
1124711308 >
11309 <ThreadFind onClose={focusComposer} />
1124811310 {isWorkspaceFileDragActive ? (
1124911311 <div
1125011312 className="pointer-events-none absolute inset-2 z-40 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/60 bg-primary/[0.035]"
......@@ -11367,11 +11429,7 @@ export default function ChatView(props: ChatViewProps) {
1136711429 ? (heldPaintContext?.workspaceRoot ?? undefined)
1136811430 : activeWorkspaceRoot
1136911431 }
11370 skills={
11371 activeProviderStatus
11372 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
11373 : EMPTY_PROVIDER_SKILLS
11374 }
11432 skills={timelineSkills}
1137511433 anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}
1137611434 onAnchorReady={onTimelineAnchorReady}
1137711435 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
......@@ -11390,7 +11448,7 @@ export default function ChatView(props: ChatViewProps) {
1139011448 />
1139111449
1139211450 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
11393 {showScrollToBottom && (
11451 {showScrollToBottom && !isThreadFindActive && (
1139411452 <div
1139511453 className="chat-scroll-to-bottom pointer-events-none absolute z-30 flex justify-center py-1.5"
1139611454 style={{ bottom: scrollToEndClearance + 4 }}
......@@ -11809,7 +11867,7 @@ export default function ChatView(props: ChatViewProps) {
1180911867 onPrepared={handlePreparedPullRequestThread}
1181011868 />
1181111869 ) : null}
11812 </ChatCanvas>
11870 </ThreadFindCanvas>
1181311871 {/* end chat column */}
1181411872 </div>
1181511873 {/* end horizontal flex container */}
apps/web/src/components/CommandPalette.tsx+27
......@@ -68,6 +68,7 @@ import {
6868 SunIcon,
6969 TextSearchIcon,
7070} from "lucide-react";
71import { requestThreadFindOpen } from "./chat/threadFindActionBus";
7172import {
7273 useCallback,
7374 useDeferredValue,
......@@ -1139,6 +1140,9 @@ function OpenCommandPaletteDialog(props: {
11391140 );
11401141
11411142 const activeThreadId = activeThread?.id;
1143 const supportsThreadFind =
1144 environments.find((environment) => environment.environmentId === activeThread?.environmentId)
1145 ?.serverConfig?.threadFind === true;
11421146 const currentProjectEnvironmentId =
11431147 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
11441148 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
......@@ -1929,6 +1933,20 @@ function OpenCommandPaletteDialog(props: {
19291933 });
19301934 }
19311935
1936 if (activeThreadId && supportsThreadFind) {
1937 actionItems.push({
1938 kind: "action",
1939 value: "find-current-thread",
1940 title: "Find in current thread",
1941 searchTerms: ["find", "search", "messages", "plans"],
1942 icon: <TextSearchIcon className={ITEM_ICON_CLASS} />,
1943 shortcutCommand: "chat.find",
1944 run: async () => {
1945 requestThreadFindOpen();
1946 },
1947 });
1948 }
1949
19321950 if (activeThreadReferenceCopyTarget !== null) {
19331951 actionItems.push({
19341952 kind: "action",
......@@ -3093,6 +3111,15 @@ function OpenCommandPaletteDialog(props: {
30933111 void copyActiveThreadReference();
30943112 return;
30953113 }
3114 // ChatView ignores shortcuts while the palette is open, so handle find here
3115 // instead of letting the browser's own Find open.
3116 if (command === "chat.find" && activeThreadId && !event.nativeEvent.isComposing) {
3117 event.preventDefault();
3118 event.stopPropagation();
3119 setOpen(false);
3120 requestThreadFindOpen();
3121 return;
3122 }
30963123
30973124 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
30983125 event.preventDefault();
apps/web/src/components/ComposerPromptEditorTiptap.tsx+1-1
......@@ -1,3 +1,4 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
12import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";
23import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
34import StarterKit from "@tiptap/starter-kit";
......@@ -102,7 +103,6 @@ import {
102103 ComposerContextRecordsContext,
103104} from "./composerContextPresentation";
104105import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
105import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
106106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
107107import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
108108import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
apps/web/src/components/GitActionsControl.tsx+1-1
......@@ -121,7 +121,7 @@ import { threadEnvironment } from "~/state/threads";
121121import { useAtomCommand } from "~/state/use-atom-command";
122122import { vcsActionManager, vcsEnvironment } from "~/state/vcs";
123123import { randomUUID } from "~/lib/utils";
124import { resolvePathLinkTarget } from "~/terminal-links";
124import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
125125import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
126126import {
127127 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
apps/web/src/components/ThreadTerminalDrawer.tsx+2-1
......@@ -60,7 +60,8 @@ import {
6060} from "~/terminal/ghostty/surface";
6161import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
6262import { useOpenInPreferredEditor } from "../editorPreferences";
63import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";
63import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
64import { isTerminalUrl } from "../terminal-links";
6465import {
6566 isDiffToggleShortcut,
6667 isTerminalClearShortcut,
apps/web/src/components/chat/ChatCanvas.tsx+12-1
......@@ -17,10 +17,12 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay
1717 */
1818export function ChatCanvas({
1919 composerOverlayElement,
20 detailsCardTopInset = 0,
2021 children,
2122 ...props
2223}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {
2324 composerOverlayElement: HTMLElement | null;
25 detailsCardTopInset?: number;
2426}) {
2527 const elementRef = useRef<HTMLDivElement | null>(null);
2628 const widthProbeRef = useRef<HTMLDivElement | null>(null);
......@@ -106,8 +108,17 @@ export function ChatCanvas({
106108 clearPreview,
107109 registerTimeline,
108110 reportDetailsCard,
111 detailsCardTopInset,
109112 };
110 }, [measurements, preview, detailsCard, reportPreview, clearPreview, reportDetailsCard]);
113 }, [
114 measurements,
115 preview,
116 detailsCard,
117 reportPreview,
118 clearPreview,
119 reportDetailsCard,
120 detailsCardTopInset,
121 ]);
111122 const { layout } = context;
112123 return (
113124 <ChatCanvasContext value={context}>
apps/web/src/components/chat/ChatCanvasContext.ts+2
......@@ -11,6 +11,8 @@ export const ChatCanvasContext = createContext<{
1111 clearPreview: (key: string) => void;
1212 registerTimeline: (element: HTMLElement | null) => void;
1313 reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void;
14 /** Height reserved above the details card, such as the open find bar. */
15 detailsCardTopInset: number;
1416} | null>(null);
1517
1618export const useChatCanvas = () => useContext(ChatCanvasContext);
apps/web/src/components/chat/ChatComposer.tsx+2-2
......@@ -1,3 +1,4 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
12import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
23import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
34import { isLocalEnvironmentDisabled } from "../../localEnvironment";
......@@ -1088,7 +1089,7 @@ import {
10881089 ShieldIcon,
10891090 XIcon,
10901091} from "lucide-react";
1091import { proposedPlanTitle } from "../../proposedPlan";
1092import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
10921093import { hasProviderSetup } from "./ProviderStatusBanner";
10931094import {
10941095 applyProviderInstanceSettings,
......@@ -1124,7 +1125,6 @@ import {
11241125} from "@t3tools/client-runtime/state/composer-dispatch";
11251126import type { ContextWindowSnapshot } from "../../lib/contextWindow";
11261127import {
1127 formatProviderSkillDisplayName,
11281128 getProviderSlashCommandsForSlashMenu,
11291129 getProviderSkillsForSlashMenu,
11301130 hasCompleteProviderWorkspaceSnapshot,
apps/web/src/components/chat/ComposerCommandMenu.tsx+1-1
......@@ -1,5 +1,5 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
12import {
2 formatProviderSkillDisplayName,
33 resolveProviderSkillSourceKind,
44 type ProviderSkillSourceKind,
55} from "@t3tools/client-runtime/providerSkills";
apps/web/src/components/chat/MermaidDiagram.tsx+5-1
......@@ -192,7 +192,11 @@ export function MermaidDiagram({
192192 </Button>
193193 ) : null}
194194 </div>
195 <pre className="mt-2 mb-0 overflow-auto font-mono text-xs whitespace-pre-wrap">
195 {/* Find matches the source through the hidden copy beside the diagram. */}
196 <pre
197 data-thread-find-ignore
198 className="mt-2 mb-0 overflow-auto font-mono text-xs whitespace-pre-wrap"
199 >
196200 {source}
197201 </pre>
198202 </div>
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+29-1
......@@ -1,3 +1,4 @@
1import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
12import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";
23import {
34 CheckpointRef,
......@@ -27,6 +28,7 @@ import {
2728 computeStableMessagesTimelineRows,
2829 computeMessageDurationStart,
2930 deriveMessagesTimelineRows,
31 timelineEntryTurnFoldRunId,
3032 deriveMessagesTimelineRowsWithState,
3133 shouldCollapseUserMessage,
3234 liveWorkEntryLabel,
......@@ -34,7 +36,6 @@ import {
3436 resolveAssistantMessageCopyState,
3537 resolveWorkGroupScrollIndex,
3638 shouldFollowWorkGroupAppend,
37 shouldPreserveAssistantLineBreaks,
3839 threadReadLabelPrefix,
3940 threadReadTargetId,
4041 threadReadTargetTitle,
......@@ -2404,6 +2405,33 @@ describe("deriveMessagesTimelineRows", () => {
24042405 const withoutPrompt = rows([]);
24052406 expect(withoutPrompt).toContain("turn-fold");
24062407 expect(withoutPrompt).not.toContain("assistant:imported-update");
2408
2409 // Find must open the fold holding a folded imported message by its synthetic key.
2410 const timelineEntries = [
2411 message("imported-prompt", "user", 0),
2412 message("imported-update", "assistant", 4),
2413 message("imported-answer", "assistant", 8),
2414 ];
2415 const foldInput = { timelineEntries, latestRun: null, isWorking: false };
2416 const foldRunId = timelineEntryTurnFoldRunId(foldInput, "imported-update");
2417 expect(foldRunId).not.toBeNull();
2418 // The rendered turn-fold row carries the same key, so find can map it back.
2419 const foldRow = deriveMessagesTimelineRows({
2420 ...foldInput,
2421 turnDiffSummaries: [],
2422 supportsConversationRollback: false,
2423 }).find((row) => row.kind === "turn-fold");
2424 expect(foldRow?.kind === "turn-fold" ? foldRow.runId : null).toBe(foldRunId);
2425 expect(timelineEntryTurnFoldRunId(foldInput, "imported-answer")).toBeNull();
2426 const expanded = deriveMessagesTimelineRows({
2427 ...foldInput,
2428 expandedRunIds: new Set([foldRunId!]),
2429 turnDiffSummaries: [],
2430 supportsConversationRollback: false,
2431 });
2432 expect(
2433 expanded.some((row) => row.kind === "message" && row.message.id === "imported-update"),
2434 ).toBe(true);
24072435 });
24082436
24092437 it("shows a provider-native subagent's runless tools as live work while it works", () => {
apps/web/src/components/chat/MessagesTimeline.logic.ts+60-8
......@@ -7,7 +7,7 @@ import {
77 collectAssistantCitations,
88} from "@t3tools/shared/assistantCitations";
99import { shallow } from "zustand/vanilla/shallow";
10import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
10import { renderCodexDirectivesForCopy } from "@t3tools/shared/codexMarkdownDirectives";
1111import {
1212 commandDisplayText,
1313 commandProgramName,
......@@ -351,10 +351,6 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo
351351 return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
352352}
353353
354export function shouldPreserveAssistantLineBreaks(text: string): boolean {
355 return /^★ Insight(?:\s|─)/mu.test(text);
356}
357
358354export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
359355 const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING);
360356 return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`;
......@@ -1298,6 +1294,35 @@ function settleSupersededReasoning(entries: ReadonlyArray<TimelineEntry>) {
12981294 });
12991295}
13001296
1297function timelineRowEntries(entries: ReadonlyArray<TimelineEntry>) {
1298 return withoutSubagentDelegationRows(settleSupersededReasoning(entries));
1299}
1300
1301/** The turn folds the timeline would draw, before applying expansion state. */
1302function deriveTimelineTurnFolds(
1303 input: Pick<
1304 MessagesTimelineRowsInput,
1305 "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId"
1306 >,
1307) {
1308 const timelineEntries = timelineRowEntries(input.timelineEntries);
1309 const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
1310 const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
1311 const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({
1312 timelineEntries,
1313 unsettledRunId,
1314 isWorking: input.isWorking,
1315 });
1316 return deriveTurnFolds({
1317 timelineEntries,
1318 terminalAssistantMessageIds: deriveTerminalAssistantMessageIds(timelineEntries),
1319 latestRun: input.latestRun ?? null,
1320 unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
1321 runlessWorkActive: input.isWorking && input.runlessWorkActive === true,
1322 liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries),
1323 });
1324}
1325
13011326export function deriveMessagesTimelineRows(input: {
13021327 timelineEntries: ReadonlyArray<TimelineEntry>;
13031328 latestRun?: TimelineLatestRun | null;
......@@ -1319,9 +1344,7 @@ export function deriveMessagesTimelineRows(input: {
13191344 /** Live bootstrap progress. Renders a stage card under the first user message. */
13201345 worktreeSetup?: WorktreeSetupSnapshot | null;
13211346}): MessagesTimelineRow[] {
1322 const timelineEntries = withoutSubagentDelegationRows(
1323 settleSupersededReasoning(input.timelineEntries),
1324 );
1347 const timelineEntries = timelineRowEntries(input.timelineEntries);
13251348 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
13261349 for (const summary of input.turnDiffSummaries) {
13271350 if (summary.assistantMessageId) {
......@@ -1989,6 +2012,35 @@ export interface MessagesTimelineRowsProjection {
19892012 readonly rows: MessagesTimelineRow[];
19902013}
19912014
2015/**
2016 * The turn fold that holds an entry, keyed as `expandedRunIds` expects. Runless
2017 * (imported V1) turns fold under a synthetic key, so the entry's own run id is
2018 * not enough to open them.
2019 */
2020export function timelineEntryTurnFoldRunId(
2021 input: Pick<
2022 MessagesTimelineRowsInput,
2023 "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId"
2024 >,
2025 entryId: string,
2026): RunId | null {
2027 return timelineTurnFoldRunIdsByEntryId(input).get(entryId) ?? null;
2028}
2029
2030/** Every folded entry's fold key, computed once for callers that check many entries. */
2031export function timelineTurnFoldRunIdsByEntryId(
2032 input: Pick<
2033 MessagesTimelineRowsInput,
2034 "timelineEntries" | "latestRun" | "isWorking" | "runlessWorkActive" | "runningRunId"
2035 >,
2036): ReadonlyMap<string, RunId> {
2037 const byEntryId = new Map<string, RunId>();
2038 for (const fold of deriveTimelineTurnFolds(input).values()) {
2039 for (const entryId of fold.hiddenEntryIds) byEntryId.set(entryId, fold.runId);
2040 }
2041 return byEntryId;
2042}
2043
19922044function sameCheckpointSummaries(
19932045 previous: MessagesTimelineRowsInput["turnDiffSummaries"],
19942046 next: MessagesTimelineRowsInput["turnDiffSummaries"],
apps/web/src/components/chat/MessagesTimeline.tsx+315-123
......@@ -1,3 +1,6 @@
1import { ThreadFindTimelineContext } from "./ThreadFindProvider";
2import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
3import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext";
14import { ComputerUseAppIcon } from "~/components/Icons";
25import { useChatCanvas } from "./ChatCanvasContext";
36import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";
......@@ -70,6 +73,7 @@ import {
7073 memo,
7174 use,
7275 useCallback,
76 useContext,
7377 useEffect,
7478 useLayoutEffect,
7579 useMemo,
......@@ -95,7 +99,7 @@ import {
9599 workEntrySignalsSevereFailure,
96100 workLogEntryIsToolLike,
97101} from "../../session-logic";
98import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
102import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
99103import {
100104 type ChatMessage,
101105 type ChatFileAttachment,
......@@ -209,7 +213,7 @@ import {
209213 resolveWorkGroupScrollIndex,
210214 shouldCollapseUserMessage,
211215 shouldFollowWorkGroupAppend,
212 shouldPreserveAssistantLineBreaks,
216 timelineTurnFoldRunIdsByEntryId,
213217 threadReadLabelPrefix,
214218 threadReadTargetId,
215219 threadReadTargetTitle,
......@@ -224,6 +228,10 @@ import {
224228 type TimelineLatestRun,
225229 type WorkGroupScrollAnchor,
226230} from "./MessagesTimeline.logic";
231import { type ThreadFindMatch } from "./threadFind";
232import { useThreadFindNavigation } from "./useThreadFindNavigation";
233import { readThreadFindPosition } from "./threadFindPosition";
234import type { ThreadFindPositionReader } from "./threadFind";
227235import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
228236import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
229237import { Spinner } from "../ui/spinner";
......@@ -327,6 +335,7 @@ interface TimelineRowSharedState {
327335 */
328336 onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void;
329337 onRunShellCommand: ((command: string) => void) | undefined;
338 findActive: boolean;
330339 onImageExpand: (preview: ExpandedImagePreview) => void;
331340 displayThreadKey?: string;
332341 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
......@@ -402,6 +411,7 @@ function TimelineListFooter({
402411 </div>
403412 );
404413}
414
405415const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
406416const TIMELINE_MAINTAIN_SCROLL_AT_END = {
407417 animated: false,
......@@ -433,7 +443,7 @@ export interface MessagesTimelineHistoryControls {
433443 readonly hasMoreHistory: boolean;
434444 readonly loading: boolean;
435445 readonly error: string | null;
436 readonly onLoadEarlier: () => void;
446 readonly onLoadEarlier: (throughEntryId?: string) => void;
437447}
438448
439449interface MessagesTimelineProps {
......@@ -519,19 +529,41 @@ interface MessagesTimelineProps {
519529 onContentOverflowChange?: (overflows: boolean) => void;
520530 onToolOutputCollapsedAtEnd?: () => void;
521531 onManualNavigation: () => void;
522 cancelPositionRestoreRef?: React.RefObject<(() => void) | null>;
532 findOpen?: boolean;
533 cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined;
523534 hideEmptyPlaceholder?: boolean;
524535 topFadeEnabled?: boolean;
525 historyControls?: MessagesTimelineHistoryControls;
536 historyControls?: MessagesTimelineHistoryControls | undefined;
526537 /** Non-null when older turns exist beyond the loaded window. */
527538 loadEarlier?: CitationHistoryPage | null;
539 findPositionReaderRef?: React.RefObject<ThreadFindPositionReader | null>;
540 findExpanded?: boolean;
541 findQuery?: string;
542 activeFindMatch?: ThreadFindMatch | null;
543 findNavigationId?: number;
528544}
529545
530546// ---------------------------------------------------------------------------
531547// MessagesTimeline — list owner
532548// ---------------------------------------------------------------------------
533549
534export const MessagesTimeline = memo(function MessagesTimeline({
550export function MessagesTimeline(props: MessagesTimelineProps) {
551 const find = useContext(ThreadFindTimelineContext);
552 const findOpen = find?.findOpen ?? props.findOpen;
553 const { onManualNavigation } = props;
554 useEffect(() => {
555 if (findOpen) onManualNavigation();
556 }, [findOpen, onManualNavigation]);
557 return (
558 <ConversationTimeline
559 {...props}
560 {...find}
561 liveFollowEnabled={!findOpen && props.liveFollowEnabled}
562 />
563 );
564}
565
566const ConversationTimeline = memo(function ConversationTimeline({
535567 citationRequest = null,
536568 citationHistoryLoading = false,
537569 onCiteAssistantText,
......@@ -551,6 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
551583 awaitingUser = false,
552584 listRef,
553585 timelineEntries,
586 findExpanded = false,
587 findQuery = "",
588 activeFindMatch = null,
589 findNavigationId = 0,
590 findPositionReaderRef,
554591 latestRun,
555592 runningRunId = null,
556593 turnDiffSummaries,
......@@ -651,6 +688,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
651688 const disclosureAnchorKeyRef = useRef<string | null>(null);
652689 const disclosureSettleFrameRef = useRef<number | null>(null);
653690 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
691 const normalizedFindQuery = findQuery.trim();
692 const findActive = findExpanded;
693
654694 useEffect(() => {
655695 return () => {
656696 if (disclosureSettleFrameRef.current !== null) {
......@@ -784,6 +824,54 @@ export const MessagesTimeline = memo(function MessagesTimeline({
784824 });
785825 }, [latestRun]);
786826
827 // Find the fold that hides the match: imported turns have no run id but still
828 // fold under a synthetic key, so the match's own run id cannot open them.
829 const findFoldRunIds = useMemo(
830 () =>
831 findActive
832 ? timelineTurnFoldRunIdsByEntryId({
833 timelineEntries,
834 latestRun,
835 runningRunId,
836 isWorking,
837 runlessWorkActive,
838 })
839 : null,
840 [findActive, timelineEntries, latestRun, runningRunId, isWorking, runlessWorkActive],
841 );
842 const activeFindRunId = activeFindMatch
843 ? findFoldRunIds?.get(activeFindMatch.entryId)
844 : undefined;
845 useEffect(() => {
846 if (!activeFindRunId) return;
847 setExpandedRunIds((previous) =>
848 previous.has(activeFindRunId) ? previous : new Set(previous).add(activeFindRunId),
849 );
850 }, [activeFindRunId]);
851 const visibleExpandedRunIds = useMemo(() => {
852 if (!activeFindRunId || paintedExpandedRunIds.has(activeFindRunId)) {
853 return paintedExpandedRunIds;
854 }
855 return new Set(paintedExpandedRunIds).add(activeFindRunId);
856 }, [activeFindRunId, paintedExpandedRunIds]);
857
858 const activeFindAttemptId = activeFindMatch
859 ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id
860 : undefined;
861 useEffect(() => {
862 if (!activeFindAttemptId) return;
863 setExpandedAttemptIds((previous) =>
864 previous.has(activeFindAttemptId) ? previous : new Set(previous).add(activeFindAttemptId),
865 );
866 }, [activeFindAttemptId]);
867 const visibleExpandedAttemptIds = useMemo(
868 () =>
869 activeFindAttemptId && !paintedExpandedAttemptIds.has(activeFindAttemptId)
870 ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId)
871 : paintedExpandedAttemptIds,
872 [activeFindAttemptId, paintedExpandedAttemptIds],
873 );
874
787875 const rowsProjectionRef = useRef<{
788876 readonly threadKey: string;
789877 readonly workspaceRoot: string | undefined;
......@@ -796,8 +884,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
796884 timelineEntries,
797885 latestRun,
798886 runningRunId,
799 expandedRunIds,
800 expandedAttemptIds,
887 expandedRunIds: visibleExpandedRunIds,
888 expandedAttemptIds: visibleExpandedAttemptIds,
801889 expandedWorkGroupIds,
802890 isWorking,
803891 runlessWorkActive,
......@@ -819,8 +907,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
819907 timelineEntries,
820908 latestRun,
821909 runningRunId,
822 expandedRunIds,
823 expandedAttemptIds,
910 visibleExpandedRunIds,
911 visibleExpandedAttemptIds,
824912 expandedWorkGroupIds,
825913 isWorking,
826914 runlessWorkActive,
......@@ -979,6 +1067,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
9791067 onExpandTurn: expandCitedRun,
9801068 onManualNavigation,
9811069 });
1070 const [findListReady, setFindListReady] = useState(false);
1071 const handleListLoad = useCallback(() => {
1072 onCitationListLoad();
1073 setFindListReady(true);
1074 }, [onCitationListLoad]);
9821075 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
9831076 const [fullscreenAppRowId, setFullscreenAppRowId] = useState<string | null>(null);
9841077 // Only the row that holds the pin can release it.
......@@ -1220,6 +1313,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
12201313 activeThreadEnvironmentId,
12211314 onRevertToTurnCount,
12221315 onRunShellCommand,
1316 findActive,
12231317 onImageExpand,
12241318 onFileOpen,
12251319 onUseArtifactTemplate,
......@@ -1257,6 +1351,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
12571351 activeThreadEnvironmentId,
12581352 onRevertToTurnCount,
12591353 onRunShellCommand,
1354 findActive,
12601355 onImageExpand,
12611356 onFileOpen,
12621357 onUseArtifactTemplate,
......@@ -1351,6 +1446,86 @@ export const MessagesTimeline = memo(function MessagesTimeline({
13511446 [listRef, registerTimeline],
13521447 );
13531448
1449 useLayoutEffect(() => {
1450 if (!findPositionReaderRef || !timelineViewportElement) return;
1451 const read: ThreadFindPositionReader = (query) => {
1452 // Imported turns fold under a synthetic key, so match folds by key, not run id.
1453 const foldRunIds =
1454 findFoldRunIds ??
1455 timelineTurnFoldRunIdsByEntryId({
1456 timelineEntries,
1457 latestRun,
1458 runningRunId,
1459 isWorking,
1460 runlessWorkActive,
1461 });
1462 const entryById = new Map(timelineEntries.map((entry) => [entry.id, entry]));
1463 return readThreadFindPosition(
1464 timelineViewportElement,
1465 query,
1466 (rowId) => {
1467 const rowIndex = rows.findIndex((row) => row.id === rowId);
1468 for (const row of rows.slice(Math.max(0, rowIndex))) {
1469 // Ordinary rows map to one entry by id; only folds need a scan.
1470 if (row.kind !== "turn-fold" && row.kind !== "attempt-fold") {
1471 const entry = entryById.get(row.kind === "assistant-meta" ? row.message.id : row.id);
1472 if (entry && (entry.kind === "message" || entry.kind === "proposed-plan"))
1473 return entry.id;
1474 continue;
1475 }
1476 const entry = timelineEntries.find((entry) => {
1477 const runId =
1478 row.kind === "turn-fold"
1479 ? foldRunIds.get(entry.id)
1480 : entry.kind === "message"
1481 ? entry.message.runId
1482 : entry.kind === "proposed-plan"
1483 ? entry.proposedPlan.runId
1484 : null;
1485 return (
1486 runId === row.runId &&
1487 (entry.kind !== "message" || entry.message.role === "assistant") &&
1488 (row.kind !== "attempt-fold" || entry.attempt?.id === row.attemptId)
1489 );
1490 });
1491 if (entry && (entry.kind === "message" || entry.kind === "proposed-plan"))
1492 return entry.id;
1493 }
1494 return undefined;
1495 },
1496 contentInsetEndAdjustment,
1497 );
1498 };
1499 findPositionReaderRef.current = read;
1500 return () => {
1501 if (findPositionReaderRef.current === read) findPositionReaderRef.current = null;
1502 };
1503 }, [
1504 contentInsetEndAdjustment,
1505 findFoldRunIds,
1506 findPositionReaderRef,
1507 isWorking,
1508 latestRun,
1509 rows,
1510 runlessWorkActive,
1511 runningRunId,
1512 timelineEntries,
1513 timelineViewportElement,
1514 ]);
1515
1516 useThreadFindNavigation({
1517 listReady: findListReady,
1518 historyControls,
1519 entries: timelineEntries,
1520 container: timelineViewportElement,
1521 query: normalizedFindQuery,
1522 match: activeFindMatch,
1523 navigationId: findNavigationId,
1524 rowIndex: activeFindMatch ? rows.findIndex((row) => row.id === activeFindMatch.entryId) : -1,
1525 listRef,
1526 contentInsetEndAdjustment,
1527 });
1528
13541529 // Stable renderItem — no closure deps. Row components read shared state
13551530 // from TimelineRowCtx, which propagates through LegendList's memo.
13561531 const renderItem = useCallback(
......@@ -1387,81 +1562,86 @@ export const MessagesTimeline = memo(function MessagesTimeline({
13871562 }
13881563
13891564 return (
1390 <TimelineRowCtx value={sharedState}>
1391 <TimelineRowActivityCtx value={activityState}>
1392 <TooltipScrollDismissArea
1393 ref={setTimelineViewportElement}
1394 className="relative h-full min-h-0"
1395 data-assistant-citation-viewport="true"
1396 >
1397 {onCiteAssistantText && citationThreadRef ? (
1398 <AssistantSelectionToolbar
1399 viewport={timelineViewportElement}
1400 threadRef={citationThreadRef}
1401 onCite={onCiteAssistantText}
1565 <MarkdownFindContext value={findActive}>
1566 <TimelineRowCtx value={sharedState}>
1567 <TimelineRowActivityCtx value={activityState}>
1568 <TooltipScrollDismissArea
1569 ref={setTimelineViewportElement}
1570 className="relative h-full min-h-0"
1571 data-assistant-citation-viewport="true"
1572 >
1573 {onCiteAssistantText && citationThreadRef ? (
1574 <AssistantSelectionToolbar
1575 viewport={timelineViewportElement}
1576 threadRef={citationThreadRef}
1577 onCite={onCiteAssistantText}
1578 />
1579 ) : null}
1580 <LegendList<MessagesTimelineRow>
1581 ref={setTimelineList}
1582 data={rows}
1583 extraData={`${listIdentityKey}:${rows.length}`}
1584 keyExtractor={keyExtractor}
1585 getItemType={getItemType}
1586 renderItem={renderItem}
1587 estimatedItemSize={90}
1588 initialScrollAtEnd={
1589 !findActive && citationRequest === null && rememberedPosition?.atEnd !== false
1590 }
1591 // Legend needs a data refresh to mount new pins without a scroll event.
1592 dataVersion={readyCitationRequest?.key ?? listIdentityKey}
1593 {...(alwaysRender ? { alwaysRender } : {})}
1594 onLoad={handleListLoad}
1595 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1596 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
1597 maintainScrollAtEnd={
1598 citationPositioning ||
1599 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||
1600 anchoredEndSpace ||
1601 !liveFollowEnabled ||
1602 fullscreenAppRowId !== null ||
1603 disclosureToggleSettling
1604 ? false
1605 : isWorking && !prefersReducedMotion && settlingListIdentity === null
1606 ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH
1607 : TIMELINE_MAINTAIN_SCROLL_AT_END
1608 }
1609 maintainVisibleContentPosition={
1610 findActive ||
1611 citationPositioning ||
1612 (restoringThreadPosition && rememberedPosition?.atEnd === false)
1613 ? false
1614 : maintainVisibleContentPosition
1615 }
1616 maintainScrollAtEndThreshold={1}
1617 onScroll={handleScroll}
1618 onItemSizeChanged={reportContentOverflow}
1619 className={cn(
1620 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]",
1621 topFadeEnabled && "topbar-scroll-fade",
1622 )}
1623 ListHeaderComponent={listHeader}
1624 ListFooterComponent={timelineListFooter}
14021625 />
1403 ) : null}
1404 <LegendList<MessagesTimelineRow>
1405 ref={setTimelineList}
1406 data={rows}
1407 extraData={`${listIdentityKey}:${rows.length}`}
1408 keyExtractor={keyExtractor}
1409 getItemType={getItemType}
1410 renderItem={renderItem}
1411 estimatedItemSize={90}
1412 initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false}
1413 // Legend needs a data refresh to mount new pins without a scroll event.
1414 dataVersion={readyCitationRequest?.key ?? listIdentityKey}
1415 {...(alwaysRender ? { alwaysRender } : {})}
1416 onLoad={onCitationListLoad}
1417 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1418 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
1419 maintainScrollAtEnd={
1420 citationPositioning ||
1421 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||
1422 anchoredEndSpace ||
1423 !liveFollowEnabled ||
1424 fullscreenAppRowId !== null ||
1425 disclosureToggleSettling
1426 ? false
1427 : isWorking && !prefersReducedMotion && settlingListIdentity === null
1428 ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH
1429 : TIMELINE_MAINTAIN_SCROLL_AT_END
1430 }
1431 maintainVisibleContentPosition={
1432 citationPositioning ||
1433 (restoringThreadPosition && rememberedPosition?.atEnd === false)
1434 ? false
1435 : maintainVisibleContentPosition
1436 }
1437 maintainScrollAtEndThreshold={1}
1438 onScroll={handleScroll}
1439 onItemSizeChanged={reportContentOverflow}
1440 className={cn(
1441 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]",
1442 topFadeEnabled && "topbar-scroll-fade",
1443 )}
1444 ListHeaderComponent={listHeader}
1445 ListFooterComponent={timelineListFooter}
1446 />
1447 <TimelineMinimap
1448 items={minimapItems}
1449 hasPersistentGutter={minimapHasPersistentGutter}
1450 hitStripWidth={minimapHitStripWidth}
1451 currentIndex={minimapCurrentIndex}
1452 stripMap={minimapStripMap}
1453 onSelect={(item) => {
1454 onManualNavigation();
1455 void listRef.current?.scrollToIndex({
1456 index: item.rowIndex,
1457 animated: true,
1458 viewOffset: 24,
1459 });
1460 }}
1461 />
1462 </TooltipScrollDismissArea>
1463 </TimelineRowActivityCtx>
1464 </TimelineRowCtx>
1626 <TimelineMinimap
1627 items={minimapItems}
1628 hasPersistentGutter={minimapHasPersistentGutter}
1629 hitStripWidth={minimapHitStripWidth}
1630 currentIndex={minimapCurrentIndex}
1631 stripMap={minimapStripMap}
1632 onSelect={(item) => {
1633 onManualNavigation();
1634 void listRef.current?.scrollToIndex({
1635 index: item.rowIndex,
1636 animated: true,
1637 viewOffset: 24,
1638 });
1639 }}
1640 />
1641 </TooltipScrollDismissArea>
1642 </TimelineRowActivityCtx>
1643 </TimelineRowCtx>
1644 </MarkdownFindContext>
14651645 );
14661646});
14671647
......@@ -1477,7 +1657,7 @@ function TimelineHistoryControl(props: MessagesTimelineHistoryControls) {
14771657 type="button"
14781658 disabled={props.loading}
14791659 aria-label="Load earlier turns"
1480 onClick={props.onLoadEarlier}
1660 onClick={() => props.onLoadEarlier()}
14811661 className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default"
14821662 >
14831663 {props.loading ? "Loading earlier turns…" : "Load earlier turns"}
......@@ -2144,21 +2324,23 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
21442324 ? (userFiles.find((file) => file.id === record.attachmentId) ?? null)
21452325 : null;
21462326 return (
2147 <UserMessageContextReferenceChip
2148 reference={reference}
2149 record={record}
2150 annotationImage={annotationImage}
2151 attachment={attachment}
2152 onExpandImage={(image) => {
2153 const preview = buildExpandedImagePreview(userImages, image.id);
2154 if (preview) onImageExpand(preview);
2155 }}
2156 onOpenFile={onFileOpen}
2157 onExpandVideo={(file) => {
2158 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
2159 if (preview) onImageExpand(preview);
2160 }}
2161 />
2327 <span data-thread-find-ignore>
2328 <UserMessageContextReferenceChip
2329 reference={reference}
2330 record={record}
2331 annotationImage={annotationImage}
2332 attachment={attachment}
2333 onExpandImage={(image) => {
2334 const preview = buildExpandedImagePreview(userImages, image.id);
2335 if (preview) onImageExpand(preview);
2336 }}
2337 onOpenFile={onFileOpen}
2338 onExpandVideo={(file) => {
2339 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
2340 if (preview) onImageExpand(preview);
2341 }}
2342 />
2343 </span>
21622344 );
21632345 },
21642346 [
......@@ -2568,26 +2750,28 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
25682750 <>
25692751 <div className="relative min-w-0 px-1 py-0.5">
25702752 <MessageAuthorHeading>T3 Code</MessageAuthorHeading>
2571 <AssistantCitationSource
2572 messageId={row.message.id}
2573 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
2574 itemKey={row.id}
2575 request={ctx.citationRequest}
2576 listRef={ctx.listRef}
2577 >
2578 <ChatMarkdown
2579 text={messageText}
2580 cwd={ctx.markdownCwd}
2581 threadRef={ctx.threadRef ?? undefined}
2582 isStreaming={Boolean(row.message.streaming)}
2583 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
2584 skills={ctx.skills}
2585 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2586 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2587 onRunShellCommand={ctx.onRunShellCommand}
2588 onImageExpand={ctx.onImageExpand}
2589 />
2590 </AssistantCitationSource>
2753 <div data-thread-find-text="true">
2754 <AssistantCitationSource
2755 messageId={row.message.id}
2756 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
2757 itemKey={row.id}
2758 request={ctx.citationRequest}
2759 listRef={ctx.listRef}
2760 >
2761 <ChatMarkdown
2762 text={messageText}
2763 cwd={ctx.markdownCwd}
2764 threadRef={ctx.threadRef ?? undefined}
2765 isStreaming={Boolean(row.message.streaming)}
2766 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
2767 skills={ctx.skills}
2768 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2769 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2770 onRunShellCommand={ctx.onRunShellCommand}
2771 onImageExpand={ctx.onImageExpand}
2772 />
2773 </AssistantCitationSource>
2774 </div>
25912775 <AssistantChangedFilesSection
25922776 turnSummary={row.assistantTurnDiffSummary}
25932777 routeThreadKey={ctx.routeThreadKey}
......@@ -2764,6 +2948,7 @@ function ProposedPlanTimelineRow({
27642948 threadRef={ctx.threadRef ?? undefined}
27652949 cwd={ctx.markdownCwd}
27662950 workspaceRoot={ctx.workspaceRoot}
2951 findActive={ctx.findActive}
27672952 />
27682953 </div>
27692954 );
......@@ -4534,16 +4719,23 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
45344719 footer?: ReactNode;
45354720}) {
45364721 const [expanded, setExpanded] = useState(false);
4722 // Find opens the body only when it selects a match in the clipped part.
4723 const revealForFind = useCallback(() => setExpanded(true), []);
4724 const findRevealRef = useFindRevealRef(revealForFind);
45374725 const hasVisibleBody = props.text.trim().length > 0;
45384726 const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
45394727 const isCollapsed = canCollapse && !expanded;
4728 const showCollapseControl = canCollapse;
45404729
45414730 return (
45424731 <div>
45434732 {hasVisibleBody ? (
45444733 <div
4734 ref={findRevealRef}
45454735 className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
45464736 data-user-message-body="true"
4737 data-thread-find-text="true"
4738 data-thread-find-fold={isCollapsed ? "" : undefined}
45474739 data-user-message-collapsed={isCollapsed ? "true" : "false"}
45484740 data-user-message-collapsible={canCollapse ? "true" : "false"}
45494741 data-user-message-fade={isCollapsed ? "true" : "false"}
......@@ -4564,15 +4756,15 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
45644756 />
45654757 </div>
45664758 ) : null}
4567 {canCollapse || props.footer ? (
4759 {showCollapseControl || props.footer ? (
45684760 <div
45694761 className={cn(
45704762 "mt-1.5 flex items-center gap-2",
4571 canCollapse && props.footer ? "justify-between" : "justify-end",
4763 showCollapseControl && props.footer ? "justify-between" : "justify-end",
45724764 )}
45734765 data-user-message-footer="true"
45744766 >
4575 {canCollapse ? (
4767 {showCollapseControl ? (
45764768 <Button
45774769 type="button"
45784770 size="xs"
apps/web/src/components/chat/ProposedPlanCard.tsx+22-7
......@@ -1,4 +1,6 @@
1import { memo, useState, useId } from "react";
1import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "@t3tools/shared/proposedPlanText";
2import { memo, useCallback, useState, useId } from "react";
3import { useFindRevealRef } from "./markdownFindContext";
24import {
35 isAtomCommandInterrupted,
46 squashAtomCommandFailure,
......@@ -13,8 +15,6 @@ import {
1315 buildProposedPlanMarkdownFilename,
1416 downloadPlanAsTextFile,
1517 normalizePlanMarkdownForExport,
16 proposedPlanTitle,
17 stripDisplayedPlanMarkdown,
1818} from "../../proposedPlan";
1919import ChatMarkdown from "../ChatMarkdown";
2020import { EllipsisIcon } from "lucide-react";
......@@ -44,12 +44,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
4444 threadRef,
4545 cwd,
4646 workspaceRoot,
47 findActive = false,
4748}: {
4849 planMarkdown: string;
4950 environmentId: EnvironmentId;
5051 threadRef?: ScopedThreadRef | undefined;
5152 cwd: string | undefined;
5253 workspaceRoot: string | undefined;
54 findActive?: boolean;
5355}) {
5456 const [expanded, setExpanded] = useState(false);
5557 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
......@@ -79,6 +81,12 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
7981 const collapsedPreview = canCollapse
8082 ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 })
8183 : null;
84 const isCollapsed = canCollapse && !expanded;
85 // While finding, the full plan stays mounted but clipped, so a match past the
86 // preview can be counted and then opened only once it is selected.
87 const showPreview = isCollapsed && !findActive;
88 const revealForFind = useCallback(() => setExpanded(true), []);
89 const findRevealRef = useFindRevealRef(revealForFind);
8290 const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);
8391 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
8492
......@@ -159,7 +167,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
159167 <Badge variant="secondary">Plan</Badge>
160168 {/* Same heading level as the message author headings in the timeline,
161169 so a plan's own headings nest beneath it in the outline. */}
162 <h3 className="truncate text-sm font-medium text-foreground">{title}</h3>
170 <h3 data-thread-find-text="true" className="truncate text-sm font-medium text-foreground">
171 {title}
172 </h3>
163173 </div>
164174 <Menu>
165175 <MenuTrigger
......@@ -182,8 +192,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
182192 </Menu>
183193 </div>
184194 <div className="mt-4">
185 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>
186 {canCollapse && !expanded ? (
195 <div
196 ref={findRevealRef}
197 className={cn("relative", isCollapsed && "max-h-104 overflow-hidden")}
198 data-thread-find-text="true"
199 data-thread-find-fold={isCollapsed ? "" : undefined}
200 >
201 {showPreview ? (
187202 <ChatMarkdown
188203 text={collapsedPreview ?? ""}
189204 cwd={cwd}
......@@ -202,7 +217,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
202217 headingLevelOffset={3}
203218 />
204219 )}
205 {canCollapse && !expanded ? (
220 {isCollapsed ? (
206221 <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-linear-to-t from-card/95 via-card/80 to-transparent" />
207222 ) : null}
208223 </div>
apps/web/src/components/chat/SkillInlineText.tsx+5-19
......@@ -1,34 +1,20 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
12import { Children, cloneElement, isValidElement, type ReactNode } from "react";
2import type { ServerProviderSkill } from "@t3tools/contracts";
3import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
3import { matchInlineSkills, type InlineSkill } from "@t3tools/shared/inlineSkills";
44
55import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip";
66import { ContextChip, ContextChipLabel } from "../ContextChip";
77
8const SKILL_TOKEN_REGEX =
9 /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;
10
11type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
12
138export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) {
149 const nodes: ReactNode[] = [];
1510 let cursor = 0;
1611
17 for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) {
18 const prefix = match[1] ?? "";
19 const name = match[2] ?? "";
20 const start = (match.index ?? 0) + prefix.length;
21 const rawText = `$${name}`;
22 const skill = props.skills.find((candidate) => candidate.name === name);
23 if (!skill) {
24 continue;
25 }
26
12 for (const { start, end, skill, rawText } of matchInlineSkills(props.text, props.skills)) {
2713 if (start > cursor) {
2814 nodes.push(props.text.slice(cursor, start));
2915 }
30 nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);
31 cursor = (match.index ?? 0) + match[0].length;
16 nodes.push(<SkillChip key={`${start}:${skill.name}`} skill={skill} rawText={rawText} />);
17 cursor = end;
3218 }
3319
3420 if (cursor === 0) {
apps/web/src/components/chat/ThreadDetailsCard.tsx+2
......@@ -31,6 +31,7 @@ export function ThreadDetailsCard({
3131 container: canvas.container,
3232 lane: canvas.lane,
3333 frame: null,
34 topInset: canvas.detailsCardTopInset,
3435 })
3536 : null;
3637 const placement = canvas
......@@ -39,6 +40,7 @@ export function ThreadDetailsCard({
3940 lane: canvas.lane,
4041 frame: canvas.layout.frame,
4142 overlapsDetailsCard: canvas.layout.overlapsDetailsCard,
43 topInset: canvas.detailsCardTopInset,
4244 })
4345 : null;
4446 const mode = placement ? "inline" : "popover";
apps/web/src/components/chat/ThreadFindBar.tsx created+123
......@@ -0,0 +1,123 @@
1import { useEffect, useRef, type KeyboardEvent } from "react";
2import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
3import { Button } from "../ui/button";
4import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
5import { cn } from "~/lib/utils";
6import { formatThreadFindCount } from "./threadFind";
7import { THREAD_DETAILS_CARD_GAP } from "./threadDetailsCardLayout";
8
9interface ThreadFindBarProps {
10 readonly open: boolean;
11 readonly query: string;
12 readonly matchCount: number;
13 readonly counting?: boolean;
14 readonly status: "loading" | "error" | null;
15 readonly onRetry: () => void;
16 readonly activeIndex: number;
17 readonly focusRequestId: number;
18 readonly onQueryChange: (query: string) => void;
19 readonly onNext: () => void;
20 readonly onPrevious: () => void;
21 readonly onClose: () => void;
22}
23
24/**
25 * Room the open bar takes at the top of the chat canvas: its `h-9` height plus
26 * the shared 12px gap below it, so the details card starts on the same rhythm.
27 */
28export const THREAD_FIND_BAR_RESERVED_HEIGHT = 36 + THREAD_DETAILS_CARD_GAP;
29
30export function ThreadFindBar(props: ThreadFindBarProps) {
31 const inputRef = useRef<HTMLInputElement | null>(null);
32
33 useEffect(() => {
34 if (!props.open) return;
35 inputRef.current?.focus();
36 inputRef.current?.select();
37 }, [props.focusRequestId, props.open]);
38
39 if (!props.open) return null;
40
41 const hasQuery = props.query.trim().length > 0;
42 const noResults = hasQuery && !props.status && props.matchCount === 0;
43 let label = "";
44 if (hasQuery) {
45 label = props.counting ? "1/…" : formatThreadFindCount(props.activeIndex, props.matchCount);
46 }
47 if (props.status === "loading") label = "Searching…";
48 if (props.status === "error") label = "Search failed";
49 const navigationDisabled =
50 props.matchCount === 0 || props.status === "loading" || props.counting === true;
51 const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
52 if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
53 if (event.key === "Enter") {
54 event.preventDefault();
55 event.stopPropagation();
56 if (event.shiftKey) props.onPrevious();
57 else props.onNext();
58 }
59 };
60
61 return (
62 <InputGroup
63 variant="popover"
64 size="lg"
65 onContextMenu={(event) => event.stopPropagation()}
66 role="search"
67 aria-label="Find in thread"
68 aria-busy={props.status === "loading" || props.counting === true}
69 className="absolute top-3 right-3 z-40 w-[min(24rem,calc(100%-1.5rem))]"
70 >
71 <InputGroupInput
72 ref={inputRef}
73 type="search"
74 size="sm"
75 value={props.query}
76 aria-label="Find in thread"
77 placeholder="Find in thread"
78 maxLength={200}
79 spellCheck={false}
80 autoComplete="off"
81 onChange={(event) => props.onQueryChange(event.target.value)}
82 onKeyDown={handleKeyDown}
83 />
84 <InputGroupAddon align="inline-end" inset="pill">
85 <span
86 aria-live="polite"
87 className={cn(
88 "min-w-10 text-center text-xs tabular-nums",
89 noResults ? "text-destructive" : "text-muted-foreground",
90 )}
91 >
92 {label}
93 </span>
94 {props.status === "error" ? (
95 <Button size="xs" variant="ghost" onClick={props.onRetry}>
96 Retry
97 </Button>
98 ) : null}
99 <Button
100 size="icon-xs"
101 variant="ghost"
102 aria-label="Previous match"
103 disabled={navigationDisabled}
104 onClick={props.onPrevious}
105 >
106 <ChevronUpIcon />
107 </Button>
108 <Button
109 size="icon-xs"
110 variant="ghost"
111 aria-label="Next match"
112 disabled={navigationDisabled}
113 onClick={props.onNext}
114 >
115 <ChevronDownIcon />
116 </Button>
117 <Button size="icon-xs" variant="ghost" aria-label="Close find" onClick={props.onClose}>
118 <XIcon />
119 </Button>
120 </InputGroupAddon>
121 </InputGroup>
122 );
123}
apps/web/src/components/chat/ThreadFindProvider.test.tsx created+127
......@@ -0,0 +1,127 @@
1import { act, createRef, useContext, useState } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, expect, it, vi } from "vite-plus/test";
4import {
5 EnvironmentId,
6 ThreadId,
7 type OrchestrationV2SearchThreadResult,
8} from "@t3tools/contracts";
9import {
10 ThreadFind,
11 ThreadFindProvider,
12 ThreadFindTimelineContext,
13 type ThreadFindControls,
14} from "./ThreadFindProvider";
15import type { ThreadFindBar } from "./ThreadFindBar";
16
17const query = vi.hoisted(() => ({
18 value: {
19 data: null as OrchestrationV2SearchThreadResult | null,
20 isPending: false,
21 error: null,
22 refresh: vi.fn(),
23 },
24 listeners: new Set<() => void>(),
25}));
26let bar: Parameters<typeof ThreadFindBar>[0];
27vi.mock("./ThreadFindBar", () => ({
28 ThreadFindBar: (props: typeof bar) => {
29 bar = props;
30 return null;
31 },
32}));
33vi.mock("~/state/orchestration", () => ({
34 orchestrationEnvironment: { threadFind: (target: unknown) => target },
35}));
36vi.mock("~/state/queries", () => ({ useDebouncedValue: <T,>(value: T) => value }));
37vi.mock("~/state/query", async () => {
38 const { useSyncExternalStore } = await import("react");
39 return {
40 useEnvironmentQuery: (atom: unknown) => {
41 const value = useSyncExternalStore(
42 (listener) => {
43 query.listeners.add(listener);
44 return () => query.listeners.delete(listener);
45 },
46 () => query.value,
47 );
48 return { ...value, data: atom ? value.data : null };
49 },
50 };
51});
52vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
53
54let renderer: ReactTestRenderer | undefined;
55afterEach(async () => {
56 if (renderer) await act(async () => renderer?.unmount());
57 renderer = undefined;
58 query.listeners.clear();
59 vi.unstubAllGlobals();
60});
61
62it("keeps navigation and pending responses out of the surrounding chat and unrelated children", async () => {
63 vi.stubGlobal("window", new EventTarget());
64 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
65 const controls = createRef<ThreadFindControls>();
66 const parentRender = vi.fn();
67 const composerRender = vi.fn();
68 const timelineRender = vi.fn();
69 function Composer() {
70 composerRender();
71 return null;
72 }
73 function Timeline() {
74 timelineRender(useContext(ThreadFindTimelineContext));
75 return null;
76 }
77 function Chat() {
78 parentRender();
79 const [, setOpen] = useState(false);
80 return (
81 <ThreadFindProvider
82 thread={{ environmentId: EnvironmentId.make("dev"), threadId: ThreadId.make("thread") }}
83 enabled
84 content={undefined}
85 controlsRef={controls}
86 onOpenChange={setOpen}
87 >
88 <ThreadFind onClose={() => {}} />
89 <Timeline />
90 <Composer />
91 </ThreadFindProvider>
92 );
93 }
94 const response = (index: number): OrchestrationV2SearchThreadResult => ({
95 totalMatches: 3,
96 activeIndex: index,
97 snapshotSequence: 1,
98 match: { entryId: "message", runId: null, occurrence: index },
99 });
100 query.value = { ...query.value, data: response(0), isPending: false };
101 await act(async () => {
102 renderer = create(<Chat />);
103 });
104 await act(async () => controls.current?.open());
105 await act(async () => bar.onQueryChange("COD4"));
106 const parentCount = parentRender.mock.calls.length;
107 const composerCount = composerRender.mock.calls.length;
108 const timelineCount = timelineRender.mock.calls.length;
109 await act(async () => {
110 query.value = { ...query.value, data: null, isPending: true };
111 for (const listener of query.listeners) listener();
112 bar.onNext();
113 });
114 expect(parentRender).toHaveBeenCalledTimes(parentCount);
115 expect(composerRender).toHaveBeenCalledTimes(composerCount);
116 expect(timelineRender).toHaveBeenCalledTimes(timelineCount);
117 await act(async () => {
118 query.value = { ...query.value, data: response(1), isPending: false };
119 for (const listener of query.listeners) listener();
120 });
121 expect(parentRender).toHaveBeenCalledTimes(parentCount);
122 expect(composerRender).toHaveBeenCalledTimes(composerCount);
123 expect(timelineRender).toHaveBeenCalledTimes(timelineCount + 1);
124 expect(timelineRender.mock.lastCall?.[0]?.activeFindMatch?.occurrence).toBe(1);
125 await act(async () => controls.current?.close());
126 expect(parentRender.mock.calls.length).toBeGreaterThan(parentCount);
127});
apps/web/src/components/chat/ThreadFindProvider.tsx created+72
......@@ -0,0 +1,72 @@
1import {
2 createContext,
3 use,
4 useEffect,
5 useImperativeHandle,
6 type ReactNode,
7 type ComponentProps,
8 type Ref,
9} from "react";
10import { useThreadFind } from "./useThreadFind";
11import { ThreadFindBar } from "./ThreadFindBar";
12import { ChatCanvas } from "./ChatCanvas";
13
14type Find = ReturnType<typeof useThreadFind>;
15export type ThreadFindControls = Pick<Find, "open" | "close">;
16export const ThreadFindTimelineContext = createContext<Find["timelineProps"] | null>(null);
17const ThreadFindBarContext = createContext<Find["barProps"] | null>(null);
18
19export function ThreadFindCanvas({
20 findOptions,
21 controlsRef,
22 onOpenChange,
23 ...canvasProps
24}: ComponentProps<typeof ChatCanvas> & {
25 findOptions: Parameters<typeof useThreadFind>[0];
26 controlsRef: Ref<ThreadFindControls>;
27 onOpenChange: (open: boolean) => void;
28}) {
29 return (
30 <ThreadFindProvider {...findOptions} controlsRef={controlsRef} onOpenChange={onOpenChange}>
31 <ChatCanvas {...canvasProps} />
32 </ThreadFindProvider>
33 );
34}
35
36/** Owns search updates below the chat view so cycling does not rerender the composer and panels. */
37export function ThreadFindProvider({
38 controlsRef,
39 onOpenChange,
40 children,
41 ...options
42}: Parameters<typeof useThreadFind>[0] & {
43 controlsRef: Ref<ThreadFindControls>;
44 onOpenChange: (open: boolean) => void;
45 children: ReactNode;
46}) {
47 const find = useThreadFind(options);
48 useImperativeHandle(controlsRef, () => ({ open: find.open, close: find.close }), [
49 find.open,
50 find.close,
51 ]);
52 useEffect(() => onOpenChange(find.isOpen), [find.isOpen, onOpenChange]);
53 return (
54 <ThreadFindBarContext value={find.barProps}>
55 <ThreadFindTimelineContext value={find.timelineProps}>{children}</ThreadFindTimelineContext>
56 </ThreadFindBarContext>
57 );
58}
59
60export function ThreadFind({ onClose }: { onClose: () => void }) {
61 const bar = use(ThreadFindBarContext);
62 if (!bar) return null;
63 return (
64 <ThreadFindBar
65 {...bar}
66 onClose={() => {
67 bar.onClose();
68 onClose();
69 }}
70 />
71 );
72}
apps/web/src/components/chat/markdownFindContext.ts created+20
......@@ -0,0 +1,20 @@
1import { createContext, useCallback } from "react";
2
3// While find is active, folded content stays mounted (hidden) so its text can be
4// matched. Nothing opens until the selected match lies inside it.
5export const MarkdownFindContext = createContext(false);
6
7/**
8 * Find dispatches `beforematch` on hidden or clipped ancestors of the selected
9 * match, like the browser's own find-in-page. `onReveal` must be stable.
10 */
11export function useFindRevealRef(onReveal: () => void) {
12 return useCallback(
13 (element: HTMLElement | null) => {
14 if (!element) return;
15 element.addEventListener("beforematch", onReveal);
16 return () => element.removeEventListener("beforematch", onReveal);
17 },
18 [onReveal],
19 );
20}
apps/web/src/components/chat/threadDetailsCardLayout.test.ts+10
......@@ -22,6 +22,16 @@ describe("workspace card", () => {
2222 });
2323 expect(resolve(1344, 900)).toMatchObject({ x: 1052, width: 280 });
2424 });
25 it("starts below the open find bar, keeping the bottom inset", () => {
26 expect(
27 resolveThreadDetailsCardLayout({
28 container: { width: 1600, height: 900 },
29 lane,
30 frame: null,
31 topInset: 48,
32 }),
33 ).toEqual({ x: 1308, y: 60, width: 280, height: 828 });
34 });
2535 it("hides when a readable chat lane cannot fit beside it", () => {
2636 expect(resolve(984, 900)).toMatchObject({ x: 692 });
2737 expect(resolve(983, 900)).toBeNull();
apps/web/src/components/chat/threadDetailsCardLayout.ts+11-4
......@@ -1,6 +1,9 @@
11import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";
22import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";
33
4/** Inset of the card from the canvas edges; the find bar shares it to line up. */
5export const THREAD_DETAILS_CARD_GAP = 12;
6
47export function resolveThreadDetailsCardDensity(
58 height: number,
69 content: { full: number; compact: number },
......@@ -19,28 +22,32 @@ export function resolveThreadDetailsCardLayout({
1922 lane,
2023 frame,
2124 overlapsDetailsCard = false,
25 topInset = 0,
2226}: {
2327 container: { width: number; height: number };
2428 lane: { padding: number; minChatWidth: number };
2529 frame: PreviewMiniPlayerFrame | null;
2630 overlapsDetailsCard?: boolean;
31 /** Space taken above the card, such as the open find bar. */
32 topInset?: number;
2733}) {
28 const gap = 12;
34 const gap = THREAD_DETAILS_CARD_GAP;
2935 // Keep in sync with --thread-details-panel-width, which sizes the popover.
3036 const width = 280;
3137 const x = container.width - width - gap;
3238 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;
39 const y = gap + topInset;
3340 // Resizing consumes the height above the player. Dragging first tries to
3441 // clear the full card and folds it only when there is no readable placement.
3542 const height =
3643 overlapsDetailsCard && frame && frame.x + frame.width > x - gap && frame.x < x + width + gap
37 ? Math.min(container.height - gap * 2, frame.y - gap * 2)
38 : container.height - gap * 2;
44 ? Math.min(container.height - y - gap, frame.y - y - gap)
45 : container.height - y - gap;
3946 if (height < 160) return null;
4047 return {
4148 x,
4249 width,
43 y: gap,
50 y,
4451 height,
4552 } as const;
4653}
apps/web/src/components/chat/threadFind.test.ts created+11
......@@ -0,0 +1,11 @@
1import { describe, expect, it } from "vite-plus/test";
2import { formatThreadFindCount, stepThreadFindIndex } from "./threadFind";
3
4describe("thread find navigation", () => {
5 it("clamps, wraps, and formats positions", () => {
6 expect(stepThreadFindIndex(2, 3, 1)).toBe(0);
7 expect(stepThreadFindIndex(0, 3, -1)).toBe(2);
8 expect(formatThreadFindCount(4, 2)).toBe("2/2");
9 expect(formatThreadFindCount(0, 0)).toBe("0/0");
10 });
11});
apps/web/src/components/chat/threadFind.ts created+28
......@@ -0,0 +1,28 @@
1import type { OrchestrationV2SearchThreadInput, RunId } from "@t3tools/contracts";
2
3export type ThreadFindStart = NonNullable<OrchestrationV2SearchThreadInput["start"]>;
4export type ThreadFindPositionReader = (query: string) => ThreadFindStart | undefined;
5
6/** One occurrence of the query inside a searchable timeline entry. */
7export interface ThreadFindMatch {
8 readonly entryId: string;
9 /** The turn to expand when the matching entry is folded. */
10 readonly runId: RunId | null;
11 /** Zero-based occurrence within this timeline entry. */
12 readonly occurrence: number;
13}
14
15function clampThreadFindIndex(index: number, total: number): number {
16 if (total <= 0 || !Number.isFinite(index) || index < 0) return 0;
17 return Math.min(Math.trunc(index), total - 1);
18}
19
20export function stepThreadFindIndex(index: number, total: number, delta: number): number {
21 if (total <= 0) return 0;
22 const clamped = clampThreadFindIndex(index, total);
23 return (((clamped + delta) % total) + total) % total;
24}
25
26export function formatThreadFindCount(index: number, total: number): string {
27 return total <= 0 ? "0/0" : `${clampThreadFindIndex(index, total) + 1}/${total}`;
28}
apps/web/src/components/chat/threadFindActionBus.ts created+10
......@@ -0,0 +1,10 @@
1const THREAD_FIND_OPEN_EVENT = "t3:thread-find-open";
2
3export function requestThreadFindOpen(): void {
4 window.dispatchEvent(new Event(THREAD_FIND_OPEN_EVENT));
5}
6
7export function subscribeThreadFindOpen(listener: () => void): () => void {
8 window.addEventListener(THREAD_FIND_OPEN_EVENT, listener);
9 return () => window.removeEventListener(THREAD_FIND_OPEN_EVENT, listener);
10}
apps/web/src/components/chat/threadFindHighlights.test.tsx created+276
......@@ -0,0 +1,276 @@
1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import { collectThreadFindRanges, useThreadFindHighlights } from "./threadFindHighlights";
7
8function Probe(props: Parameters<typeof useThreadFindHighlights>[0]) {
9 useThreadFindHighlights(props);
10 return null;
11}
12
13let root: Root;
14let host: HTMLElement;
15let container: HTMLElement;
16const onActiveRange = vi.fn();
17const activeRanges = () => [
18 ...(CSS.highlights.get("t3-thread-find-active") as unknown as Set<Range>),
19];
20
21beforeEach(() => {
22 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
23 vi.stubGlobal("CSS", { highlights: new Map() });
24 vi.stubGlobal(
25 "Highlight",
26 class extends Set<Range> {
27 constructor(...ranges: Range[]) {
28 super(ranges);
29 }
30 },
31 );
32 host = document.createElement("div");
33 container = document.createElement("div");
34 container.innerHTML =
35 '<div data-timeline-row-id="first"><p data-thread-find-text>COD4 and COD4</p></div>' +
36 '<div data-timeline-row-id="second"><p data-thread-find-text>COD4</p></div>';
37 document.body.append(host, container);
38 root = createRoot(host);
39});
40afterEach(async () => {
41 await act(async () => root.unmount());
42 host.remove();
43 container.remove();
44 vi.restoreAllMocks();
45 vi.clearAllMocks();
46 vi.unstubAllGlobals();
47});
48
49describe("find highlight caching", () => {
50 it("checks only the old and new occurrence when cycling a message with many clipped matches", async () => {
51 container.innerHTML =
52 '<div data-timeline-row-id="first"><div data-thread-find-fold><p data-thread-find-text>' +
53 "COD4 ".repeat(1500) +
54 "</p></div></div>";
55 const bounds = vi.fn(() => new DOMRect(0, 0, 40, 20));
56 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
57 configurable: true,
58 value: bounds,
59 });
60 container.querySelector<HTMLElement>("[data-thread-find-fold]")!.getBoundingClientRect = () =>
61 new DOMRect(0, 0, 800, 100);
62 const props = {
63 container,
64 query: "COD4",
65 activeRowId: "first",
66 activeOccurrence: 0,
67 onActiveRange,
68 };
69 try {
70 await act(async () => root.render(<Probe {...props} />));
71 const inactive = CSS.highlights.get("t3-thread-find");
72 const first = activeRanges()[0];
73 bounds.mockClear();
74 await act(async () => root.render(<Probe {...props} activeOccurrence={1} />));
75 expect(bounds).toHaveBeenCalledTimes(2);
76 expect(CSS.highlights.get("t3-thread-find")).toBe(inactive);
77 expect(inactive?.size).toBe(1499);
78 expect(inactive?.has(first!)).toBe(true);
79 expect(activeRanges()[0]?.startOffset).toBe(5);
80 expect(inactive?.has(activeRanges()[0]!)).toBe(false);
81 } finally {
82 Reflect.deleteProperty(Range.prototype, "getBoundingClientRect");
83 }
84 });
85
86 it("repaints for searchable text changes but not for timers outside it", async () => {
87 const timer = document.createElement("span");
88 container.firstElementChild!.append(timer);
89 await act(async () =>
90 root.render(
91 <Probe
92 container={container}
93 query="COD4"
94 activeRowId="first"
95 activeOccurrence={0}
96 onActiveRange={onActiveRange}
97 />,
98 ),
99 );
100 onActiveRange.mockClear();
101 await act(async () => {
102 timer.textContent = "0:01";
103 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
104 });
105 expect(onActiveRange).not.toHaveBeenCalled();
106 await act(async () => {
107 container.querySelector("p")!.append(" COD4");
108 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
109 });
110 expect(onActiveRange).toHaveBeenCalled();
111 });
112
113 it("opens only the selected fold while reusing its cached text", async () => {
114 container.innerHTML =
115 '<div data-timeline-row-id="first"><div hidden><p data-thread-find-text>COD4 COD4</p></div></div>' +
116 '<div data-timeline-row-id="second"><div hidden><p data-thread-find-text>COD4</p></div></div>';
117 const folds = [...container.querySelectorAll<HTMLElement>("[hidden]")];
118 for (const fold of folds)
119 fold.addEventListener("beforematch", () => fold.removeAttribute("hidden"));
120 const createRange = vi.spyOn(document, "createRange");
121 await act(async () =>
122 root.render(
123 <Probe
124 container={container}
125 query="COD4"
126 activeRowId="first"
127 activeOccurrence={0}
128 onActiveRange={onActiveRange}
129 />,
130 ),
131 );
132 await act(async () => {
133 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
134 });
135 expect(folds[0]?.hasAttribute("hidden")).toBe(false);
136 expect(folds[1]?.hasAttribute("hidden")).toBe(true);
137 expect(activeRanges()[0]?.toString()).toBe("COD4");
138 expect(CSS.highlights.get("t3-thread-find")?.size).toBe(1);
139 const scanned = createRange.mock.calls.length;
140 await act(async () =>
141 root.render(
142 <Probe
143 container={container}
144 query="COD4"
145 activeRowId="first"
146 activeOccurrence={1}
147 onActiveRange={onActiveRange}
148 />,
149 ),
150 );
151 expect(createRange).toHaveBeenCalledTimes(scanned);
152 expect(activeRanges()[0]?.startOffset).toBe(5);
153 });
154
155 it("observes rows mounted synchronously while revealing the first match", async () => {
156 container.replaceChildren();
157 onActiveRange.mockImplementationOnce(() => {
158 container.innerHTML =
159 '<div data-timeline-row-id="first"><p data-thread-find-text>COD4</p></div>';
160 });
161 await act(async () => {
162 root.render(
163 <Probe
164 container={container}
165 query="COD4"
166 activeRowId="first"
167 activeOccurrence={0}
168 onActiveRange={onActiveRange}
169 />,
170 );
171 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
172 });
173 await act(async () => {
174 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
175 });
176 expect(activeRanges()[0]?.toString()).toBe("COD4");
177 });
178
179 it("rebuilds ranges when the list moves or recycles an existing row", async () => {
180 const props = {
181 container,
182 query: "COD4",
183 activeRowId: "first",
184 activeOccurrence: 0,
185 onActiveRange,
186 };
187 await act(async () => root.render(<Probe {...props} />));
188 const initial = activeRanges()[0];
189 const row = container.querySelector<HTMLElement>('[data-timeline-row-id="first"]')!;
190 await act(async () => {
191 row.remove();
192 container.append(row);
193 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
194 });
195 expect(activeRanges()[0]).not.toBe(initial);
196 expect(activeRanges()[0]?.toString()).toBe("COD4");
197 await act(async () => {
198 row.dataset.timelineRowId = "recycled";
199 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
200 });
201 expect(activeRanges()).toHaveLength(0);
202 await act(async () => root.render(<Probe {...props} activeRowId="recycled" />));
203 expect(activeRanges()[0]?.toString()).toBe("COD4");
204 });
205
206 it("reuses unchanged text ranges on navigation and scans only the streaming row", async () => {
207 const createRange = vi.spyOn(document, "createRange");
208 const props = {
209 container,
210 query: "COD4",
211 activeRowId: "first",
212 activeOccurrence: 0,
213 onActiveRange,
214 };
215 await act(async () => root.render(<Probe {...props} />));
216 expect(createRange).toHaveBeenCalledTimes(3);
217 const initial = activeRanges()[0];
218 await act(async () => root.render(<Probe {...props} activeOccurrence={1} />));
219 expect(createRange).toHaveBeenCalledTimes(3);
220 expect(activeRanges()[0]?.startOffset).toBe(9);
221 await act(async () => root.render(<Probe {...props} />));
222 expect(activeRanges()[0]).toBe(initial);
223 await act(async () => {
224 container.querySelector('[data-timeline-row-id="second"] p')!.firstChild!.nodeValue =
225 "COD4 COD4";
226 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
227 });
228 expect(createRange).toHaveBeenCalledTimes(5);
229 expect(activeRanges()[0]).toBe(initial);
230 await act(async () =>
231 root.render(<Probe {...props} activeRowId="second" activeOccurrence={1} />),
232 );
233 expect(createRange).toHaveBeenCalledTimes(5);
234 expect(activeRanges()[0]?.toString()).toBe("COD4");
235 expect(activeRanges()[0]?.startOffset).toBe(5);
236 await act(async () => {
237 container.querySelector('[data-timeline-row-id="second"]')!.remove();
238 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
239 });
240 expect(activeRanges()).toHaveLength(0);
241 expect(onActiveRange).toHaveBeenLastCalledWith(null);
242 expect(CSS.highlights.get("t3-thread-find")?.size).toBe(2);
243 await act(async () => root.render(<Probe {...props} query="missing" />));
244 expect(CSS.highlights.get("t3-thread-find")?.size).toBe(0);
245 });
246});
247
248function row(html: string) {
249 const container = document.createElement("div");
250 container.innerHTML = `<div data-timeline-row-id="row"><div data-thread-find-text>${html}</div></div>`;
251 return container;
252}
253
254describe("collectThreadFindRanges", () => {
255 it("finds matches split across inline token spans, in order", () => {
256 // Shiki-style tokens split one identifier over several text nodes.
257 const container = row(
258 "<pre><code><span>con</span><span>st</span> a = <span>c</span>onst; const</code></pre>",
259 );
260 const ranges = collectThreadFindRanges(container, "const");
261 expect(ranges.map((match) => [match.occurrence, match.range.toString()])).toEqual([
262 [0, "const"],
263 [1, "const"],
264 [2, "const"],
265 ]);
266 });
267
268 it("finds every match across thousands of token nodes", () => {
269 const tokens = Array.from({ length: 4000 }, (_, i) => `<span>id</span><span>${i} </span>`);
270 const container = row(`<pre><code>${tokens.join("")}</code></pre>`);
271 const ranges = collectThreadFindRanges(container, "id");
272 expect(ranges).toHaveLength(4000);
273 expect(ranges.map((match) => match.occurrence)).toEqual([...ranges.keys()]);
274 expect(ranges.every((match) => match.range.toString() === "id")).toBe(true);
275 });
276});
apps/web/src/components/chat/threadFindHighlights.ts created+269
......@@ -0,0 +1,269 @@
1import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch";
2import { useEffect, useLayoutEffect, useRef } from "react";
3import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText";
4
5const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find";
6const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active";
7
8const THREAD_FIND_TEXT_SELECTOR = "[data-thread-find-text]";
9const THREAD_FIND_IGNORE_SELECTOR = "[data-thread-find-ignore]";
10
11interface ThreadFindRange {
12 readonly rowId: string;
13 readonly occurrence: number;
14 readonly range: Range;
15}
16
17/** Collects visible occurrences without modifying rendered markdown. */
18export function collectThreadFindRanges(container: HTMLElement, query: string): ThreadFindRange[] {
19 if (query.length === 0) return [];
20
21 const ranges: ThreadFindRange[] = [];
22 const occurrenceByRowId = new Map<string, number>();
23
24 for (const scope of container.querySelectorAll(THREAD_FIND_TEXT_SELECTOR)) {
25 if (scope.parentElement?.closest(THREAD_FIND_TEXT_SELECTOR)) continue;
26 const rowId = scope.closest("[data-timeline-row-id]")?.getAttribute("data-timeline-row-id");
27 if (!rowId) continue;
28
29 let text = "";
30 let nodes: { node: Node; start: number; end: number }[] = [];
31 const flush = () => {
32 // Offsets ascend, so both node cursors only move forward: linear in text nodes.
33 let startIndex = 0;
34 let endIndex = 0;
35 for (const offset of findThreadSearchOccurrences(text, query)) {
36 while (startIndex < nodes.length && nodes[startIndex]!.end <= offset) startIndex++;
37 if (endIndex < startIndex) endIndex = startIndex;
38 while (endIndex < nodes.length && nodes[endIndex]!.end < offset + query.length) endIndex++;
39 const start = nodes[startIndex];
40 const end = nodes[endIndex];
41 if (!start || !end) continue;
42 const range = container.ownerDocument.createRange();
43 range.setStart(start.node, offset - start.start);
44 range.setEnd(end.node, offset + query.length - end.start);
45 const occurrence = occurrenceByRowId.get(rowId) ?? 0;
46 ranges.push({ rowId, occurrence, range });
47 occurrenceByRowId.set(rowId, occurrence + 1);
48 }
49 text = "";
50 nodes = [];
51 };
52 const visit = (node: Node, inPre = false) => {
53 const element = node.nodeType === 1 ? (node as Element) : null;
54 if (element?.matches("svg")) return;
55 if (element?.matches(`${THREAD_FIND_IGNORE_SELECTOR}, [role="toolbar"]`)) {
56 flush();
57 return;
58 }
59 const tag = element?.tagName.toLowerCase() ?? "";
60 const block = THREAD_FIND_BLOCK_TAGS.has(tag);
61 if (block) flush();
62 if (node.nodeType === 3) {
63 const value = node.nodeValue ?? "";
64 const start = text.length;
65 text += inPre ? value : value.replace(/\r?\n/g, " ");
66 nodes.push({ node, start, end: text.length });
67 }
68 for (const child of node.childNodes) visit(child, inPre || tag === "pre");
69 if (block) flush();
70 };
71 visit(scope);
72 flush();
73 }
74 return ranges;
75}
76
77/** Ancestors that hide (`hidden`) or clip (`data-thread-find-fold`) text until find opens them. */
78const FOLD_SELECTOR = "[hidden], [data-thread-find-fold]";
79
80/** Folds around the range that currently keep it out of sight, innermost first. */
81function foldsHiding(range: Range, container: HTMLElement): Element[] {
82 const folds: Element[] = [];
83 for (
84 let fold = range.startContainer.parentElement?.closest(FOLD_SELECTOR);
85 fold && container.contains(fold);
86 fold = fold.parentElement?.closest(FOLD_SELECTOR)
87 ) {
88 // A clipped body still shows its first lines; only text past the cutoff is folded.
89 if (
90 fold.hasAttribute("hidden") ||
91 range.getBoundingClientRect().bottom > fold.getBoundingClientRect().bottom
92 )
93 folds.push(fold);
94 }
95 return folds;
96}
97
98/** Dispatches `beforematch` on each fold hiding the range; returns whether any existed. */
99function revealFolded(range: Range, container: HTMLElement): boolean {
100 const folds = foldsHiding(range, container);
101 for (const fold of folds) fold.dispatchEvent(new Event("beforematch"));
102 return folds.length > 0;
103}
104
105function affectsSearchableText(record: MutationRecord): boolean {
106 if (record.type === "attributes") return true;
107 const target =
108 record.target.nodeType === 1 ? (record.target as Element) : record.target.parentElement;
109 if (target?.closest(THREAD_FIND_TEXT_SELECTOR)) return true;
110 // Rows mounting or unmounting add or remove whole searchable scopes.
111 const touchesScope = (node: Node) =>
112 node.nodeType === 1 &&
113 ((node as Element).matches(THREAD_FIND_TEXT_SELECTOR) ||
114 (node as Element).querySelector(THREAD_FIND_TEXT_SELECTOR) !== null);
115 return [...record.addedNodes, ...record.removedNodes].some(touchesScope);
116}
117
118export function useThreadFindHighlights(input: {
119 readonly container: HTMLElement | null;
120 readonly query: string;
121 readonly activeRowId: string | null;
122 readonly activeOccurrence: number;
123 readonly onActiveRange: (range: Range | null) => void;
124}): void {
125 const { container, query, activeRowId, activeOccurrence, onActiveRange } = input;
126
127 const rangesRef = useRef<readonly ThreadFindRange[]>([]);
128 const paintRef = useRef<ReturnType<typeof makeThreadFindHighlightPainter> | null>(null);
129 const selectionRef = useRef({ activeRowId, activeOccurrence, onActiveRange });
130 useLayoutEffect(() => {
131 selectionRef.current = { activeRowId, activeOccurrence, onActiveRange };
132 }, [activeRowId, activeOccurrence, onActiveRange]);
133
134 useEffect(() => {
135 if (typeof CSS === "undefined" || !CSS.highlights || typeof Highlight === "undefined") {
136 selectionRef.current.onActiveRange(null);
137 return;
138 }
139 const clearHighlights = () => {
140 CSS.highlights.delete(THREAD_FIND_HIGHLIGHT_NAME);
141 CSS.highlights.delete(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME);
142 };
143 rangesRef.current = [];
144 if (!container || query.length === 0) {
145 selectionRef.current.onActiveRange(null);
146 clearHighlights();
147 return;
148 }
149
150 // Selection changes reuse ranges. Only changed or newly mounted rows
151 // need their text walked again; removed rows release their DOM references.
152 const cache = new Map<Element, readonly ThreadFindRange[]>();
153 const paint = makeThreadFindHighlightPainter(container);
154 paintRef.current = paint;
155 const repaint = () => {
156 const rows = new Set(container.querySelectorAll("[data-timeline-row-id]"));
157 const ranges: ThreadFindRange[] = [];
158 for (const row of rows) {
159 let matches = cache.get(row);
160 if (
161 !matches ||
162 matches.some(
163 ({ range }) => !row.contains(range.startContainer) || !row.contains(range.endContainer),
164 )
165 ) {
166 matches = collectThreadFindRanges(row as HTMLElement, query);
167 cache.set(row, matches);
168 }
169 ranges.push(...matches);
170 }
171 for (const row of cache.keys()) if (!rows.has(row)) cache.delete(row);
172 rangesRef.current = ranges;
173 paint(ranges, selectionRef.current);
174 };
175 let frame: number | null = null;
176 const observer = new MutationObserver((mutations) => {
177 // Timers and status chrome tick every second; only searchable text and folds matter.
178 const relevant = mutations.filter(affectsSearchableText);
179 if (relevant.length === 0) return;
180 for (const mutation of relevant) {
181 const target =
182 mutation.target.nodeType === 1
183 ? (mutation.target as Element)
184 : mutation.target.parentElement;
185 const row = target?.closest("[data-timeline-row-id]");
186 if (row) cache.delete(row);
187 }
188 if (frame !== null) return;
189 frame = requestAnimationFrame(() => {
190 frame = null;
191 repaint();
192 });
193 });
194 observer.observe(container, {
195 subtree: true,
196 childList: true,
197 characterData: true,
198 attributes: true,
199 attributeFilter: [
200 "data-wrap",
201 "hidden",
202 "data-thread-find-fold",
203 "data-timeline-row-id",
204 "data-thread-find-text",
205 "data-thread-find-ignore",
206 ],
207 });
208 // Revealing the first match can synchronously mount or recycle list rows.
209 repaint();
210 return () => {
211 observer.disconnect();
212 if (frame !== null) cancelAnimationFrame(frame);
213 cache.clear();
214 paintRef.current = null;
215 rangesRef.current = [];
216 clearHighlights();
217 };
218 }, [container, query]);
219
220 useEffect(() => {
221 paintRef.current?.(rangesRef.current, { activeRowId, activeOccurrence, onActiveRange });
222 }, [activeOccurrence, activeRowId, onActiveRange]);
223}
224
225/** Selection changes touch only the old and new match; layout checks run once per DOM scan. */
226function makeThreadFindHighlightPainter(container: HTMLElement) {
227 let previousRanges: readonly ThreadFindRange[] | null = null;
228 const rangesByRow = new Map<string, Range[]>();
229 const inactive = new Highlight();
230 const active = new Highlight();
231 let previousActive: Range | null = null;
232 CSS.highlights.set(THREAD_FIND_HIGHLIGHT_NAME, inactive);
233 CSS.highlights.set(THREAD_FIND_ACTIVE_HIGHLIGHT_NAME, active);
234 return (
235 ranges: readonly ThreadFindRange[],
236 selection: Pick<
237 Parameters<typeof useThreadFindHighlights>[0],
238 "activeRowId" | "activeOccurrence" | "onActiveRange"
239 >,
240 ) => {
241 if (previousRanges !== ranges) {
242 previousRanges = ranges;
243 previousActive = null;
244 rangesByRow.clear();
245 inactive.clear();
246 for (const match of ranges) {
247 const row = rangesByRow.get(match.rowId) ?? [];
248 row[match.occurrence] = match.range;
249 rangesByRow.set(match.rowId, row);
250 if (foldsHiding(match.range, container).length === 0) inactive.add(match.range);
251 }
252 }
253 if (previousActive && foldsHiding(previousActive, container).length === 0)
254 inactive.add(previousActive);
255 const selected =
256 selection.activeRowId === null
257 ? null
258 : (rangesByRow.get(selection.activeRowId)?.[selection.activeOccurrence] ?? null);
259 active.clear();
260 if (selected) inactive.delete(selected);
261 previousActive = selected;
262 if (selected && revealFolded(selected, container)) {
263 selection.onActiveRange(null);
264 } else {
265 if (selected) active.add(selected);
266 selection.onActiveRange(selected);
267 }
268 };
269}
apps/web/src/components/chat/threadFindPosition.test.ts created+60
......@@ -0,0 +1,60 @@
1// @vitest-environment jsdom
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import { readThreadFindPosition } from "./threadFindPosition";
4
5afterEach(() => vi.restoreAllMocks());
6beforeEach(() => {
7 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
8 configurable: true,
9 writable: true,
10 value: () => new DOMRect(),
11 });
12});
13
14function viewport(text = "COD4 COD4") {
15 const container = document.createElement("div");
16 container.innerHTML = `<div data-timeline-row-id="above"><div data-thread-find-text>COD4</div></div>
17 <div data-timeline-row-id="reading"><div data-thread-find-text>${text}</div></div>`;
18 vi.spyOn(container, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 100, 500, 400));
19 vi.spyOn(container.children[0]!, "getBoundingClientRect").mockReturnValue(
20 new DOMRect(0, 0, 500, 80),
21 );
22 vi.spyOn(container.children[1]!, "getBoundingClientRect").mockReturnValue(
23 new DOMRect(0, 50, 500, 300),
24 );
25 return container;
26}
27
28describe("reading position for find", () => {
29 it("skips earlier rows and occurrences above the viewport within the current message", () => {
30 const container = viewport();
31 vi.spyOn(Range.prototype, "getBoundingClientRect").mockImplementation(function (this: Range) {
32 return new DOMRect(0, this.startOffset === 0 ? 60 : 160, 40, 20);
33 });
34 expect(readThreadFindPosition(container, "COD4", (id) => id, 100)).toEqual({
35 entryId: "reading",
36 occurrence: 1,
37 });
38 });
39
40 it("starts after the current message when all its occurrences are above the viewport", () => {
41 const container = viewport();
42 vi.spyOn(Range.prototype, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 60, 40, 20));
43 expect(readThreadFindPosition(container, "COD4", (id) => id, 100)).toEqual({
44 entryId: "reading",
45 occurrence: 2,
46 });
47 });
48
49 it("anchors unmatched or folded content and excludes rows hidden behind the composer", () => {
50 const container = viewport("nothing");
51 expect(readThreadFindPosition(container, "COD4", () => "folded-message", 100)).toEqual({
52 entryId: "folded-message",
53 occurrence: 0,
54 });
55 vi.spyOn(container.children[1]!, "getBoundingClientRect").mockReturnValue(
56 new DOMRect(0, 420, 500, 300),
57 );
58 expect(readThreadFindPosition(container, "COD4", (id) => id, 100)).toBeUndefined();
59 });
60});
apps/web/src/components/chat/threadFindPosition.ts created+24
......@@ -0,0 +1,24 @@
1import type { ThreadFindStart } from "./threadFind";
2import { collectThreadFindRanges } from "./threadFindHighlights";
3
4/** Anchor to the first row being read, skipping occurrences above its viewport. */
5export function readThreadFindPosition(
6 container: HTMLElement,
7 query: string,
8 resolveEntry: (rowId: string) => string | undefined,
9 contentInsetEndAdjustment: number,
10): ThreadFindStart | undefined {
11 const viewport = container.getBoundingClientRect();
12 for (const row of container.querySelectorAll<HTMLElement>("[data-timeline-row-id]")) {
13 const rect = row.getBoundingClientRect();
14 if (rect.bottom <= viewport.top || rect.top >= viewport.bottom - contentInsetEndAdjustment)
15 continue;
16 const rowId = row.dataset.timelineRowId;
17 const entryId = rowId ? resolveEntry(rowId) : undefined;
18 if (!entryId) continue;
19 const matches = collectThreadFindRanges(row, query);
20 const next = matches.find((match) => match.range.getBoundingClientRect().bottom > viewport.top);
21 return { entryId, occurrence: next?.occurrence ?? matches.length };
22 }
23 return undefined;
24}
apps/web/src/components/chat/useThreadFind.test.tsx created+720
......@@ -0,0 +1,720 @@
1import { act, useLayoutEffect } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4import {
5 EnvironmentId,
6 RunId,
7 ThreadId,
8 type OrchestrationV2SearchThreadInput,
9 type OrchestrationV2SearchThreadResult,
10} from "@t3tools/contracts";
11import { useThreadFind } from "./useThreadFind";
12import { orchestrationEnvironment } from "~/state/orchestration";
13import { requestThreadFindOpen } from "./threadFindActionBus";
14
15const queries = vi.hoisted(() => ({
16 results: new Map<string, OrchestrationV2SearchThreadResult>(),
17 pending: false,
18 refresh: vi.fn(),
19}));
20vi.mock("~/state/orchestration", () => ({
21 orchestrationEnvironment: {
22 threadFindProgressive: vi.fn(
23 (input: { environmentId: EnvironmentId; input: OrchestrationV2SearchThreadInput }) => input,
24 ),
25 threadFind: vi.fn(
26 (input: { environmentId: EnvironmentId; input: OrchestrationV2SearchThreadInput }) => input,
27 ),
28 },
29}));
30vi.mock("~/state/queries", () => ({ useDebouncedValue: <T,>(value: T) => value }));
31vi.mock("~/state/query", () => ({
32 useEnvironmentQuery: (atom: { environmentId: EnvironmentId } | null) => ({
33 data: atom === null ? null : (queries.results.get(atom.environmentId) ?? null),
34 isPending: queries.pending,
35 error: null,
36 refresh: queries.refresh,
37 }),
38}));
39
40vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
41
42let renderer: ReactTestRenderer | undefined;
43let find: ReturnType<typeof useThreadFind>;
44const a = EnvironmentId.make("environment:a");
45const b = EnvironmentId.make("environment:b");
46const threadId = ThreadId.make("shared-thread");
47const runId = RunId.make("run:plan");
48function Probe({
49 environmentId,
50 enabled = true,
51 progressive = false,
52 content,
53}: {
54 environmentId: EnvironmentId;
55 enabled?: boolean;
56 progressive?: boolean;
57 content?: Parameters<typeof useThreadFind>[0]["content"];
58}) {
59 const state = useThreadFind({
60 thread: { environmentId, threadId },
61 enabled,
62 progressive,
63 content,
64 });
65 useLayoutEffect(() => {
66 find = state;
67 });
68 return null;
69}
70beforeEach(() => {
71 vi.stubGlobal("window", new EventTarget());
72 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
73});
74afterEach(async () => {
75 if (renderer) await act(async () => renderer?.unmount());
76 renderer = undefined;
77 queries.results.clear();
78 queries.pending = false;
79 vi.clearAllMocks();
80 vi.useRealTimers();
81 vi.unstubAllGlobals();
82});
83
84describe("V2 find state", () => {
85 function messageResult(index: number, snapshotSequence = 9): OrchestrationV2SearchThreadResult {
86 return {
87 snapshotSequence,
88 totalMatches: 10,
89 activeIndex: index,
90 match: { entryId: `message:${index + 2}`, runId, occurrence: 0 },
91 };
92 }
93
94 it("reveals an early match and updates its count without requesting another jump", async () => {
95 await act(async () => {
96 renderer = create(<Probe environmentId={a} progressive />);
97 });
98 await act(async () => find.open());
99 await act(async () => find.barProps.onQueryChange("needle"));
100 const partial: OrchestrationV2SearchThreadResult = {
101 complete: false,
102 snapshotSequence: 9,
103 totalMatches: 1,
104 activeIndex: 0,
105 match: { entryId: "early", runId, occurrence: 2 },
106 };
107 queries.pending = true;
108 queries.results.set(a, partial);
109 await act(async () => renderer?.update(<Probe environmentId={a} progressive />));
110 expect(find.barProps.counting).toBe(true);
111 expect(find.barProps.status).toBeNull();
112 expect(find.timelineProps.activeFindMatch).toEqual(partial.match);
113 const match = find.timelineProps.activeFindMatch;
114 const navigationId = find.timelineProps.findNavigationId;
115 const requests = vi.mocked(orchestrationEnvironment.threadFindProgressive).mock.calls.length;
116 await act(async () => {
117 find.barProps.onNext();
118 find.barProps.onPrevious();
119 });
120 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests);
121 queries.results.set(a, { ...partial, complete: true, totalMatches: 123, activeIndex: 42 });
122 await act(async () => renderer?.update(<Probe environmentId={a} progressive />));
123 expect(find.barProps.counting).toBe(false);
124 expect(find.barProps.matchCount).toBe(123);
125 expect(find.barProps.activeIndex).toBe(42);
126 expect(find.timelineProps.activeFindMatch).toBe(match);
127 expect(find.timelineProps.findNavigationId).toBe(navigationId);
128 expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled();
129 queries.pending = false;
130 await act(async () => renderer?.update(<Probe environmentId={a} progressive />));
131 await act(async () => find.barProps.onNext());
132 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests + 1);
133 });
134
135 it("coalesces incoming pages until the progressive scan finishes", async () => {
136 vi.useFakeTimers();
137 queries.pending = true;
138 await act(async () => {
139 renderer = create(<Probe environmentId={a} progressive />);
140 });
141 await act(async () => find.open());
142 await act(async () => find.barProps.onQueryChange("needle"));
143 queries.results.set(a, { ...messageResult(0), complete: false, totalMatches: 1 });
144 const content = { visibleTurnItems: [], runs: [] };
145 await act(async () =>
146 renderer?.update(<Probe environmentId={a} progressive content={content} />),
147 );
148 const requests = vi.mocked(orchestrationEnvironment.threadFindProgressive).mock.calls.length;
149 await act(async () => vi.advanceTimersByTime(300));
150 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests);
151 expect(queries.refresh).not.toHaveBeenCalled();
152 queries.results.set(a, messageResult(0));
153 queries.pending = false;
154 await act(async () =>
155 renderer?.update(<Probe environmentId={a} progressive content={content} />),
156 );
157 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenCalledTimes(requests + 1);
158 expect(orchestrationEnvironment.threadFindProgressive).toHaveBeenLastCalledWith({
159 environmentId: a,
160 input: {
161 threadId,
162 query: "needle",
163 skills: [],
164 start: { entryId: "message:2", occurrence: 0 },
165 },
166 });
167 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:2");
168 });
169
170 it("requests a fresh jump when the same search wraps to the same match from a new position", async () => {
171 queries.results.set(a, messageResult(0));
172 await act(async () => {
173 renderer = create(<Probe environmentId={a} />);
174 });
175 await act(async () => find.open());
176 find.timelineProps.findPositionReaderRef.current = () => ({
177 entryId: "reading",
178 occurrence: 0,
179 });
180 await act(async () => find.barProps.onQueryChange("COD4"));
181 const firstNavigationId = find.timelineProps.findNavigationId;
182 const firstMatch = find.timelineProps.activeFindMatch;
183 find.timelineProps.findPositionReaderRef.current = () => ({ entryId: "bottom", occurrence: 0 });
184 await act(async () => find.barProps.onQueryChange("COD4"));
185 expect(find.timelineProps.findNavigationId).toBeGreaterThan(firstNavigationId);
186 expect(find.timelineProps.activeFindMatch).toEqual(firstMatch);
187 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
188 environmentId: a,
189 input: { threadId, query: "COD4", skills: [], start: { entryId: "bottom", occurrence: 0 } },
190 });
191 const repeatedNavigationId = find.timelineProps.findNavigationId;
192 find.timelineProps.findPositionReaderRef.current = () => ({
193 entryId: "new-bottom",
194 occurrence: 0,
195 });
196 await act(async () => requestThreadFindOpen());
197 expect(find.timelineProps.findNavigationId).toBeGreaterThan(repeatedNavigationId);
198 expect(find.timelineProps.activeFindMatch).toEqual(firstMatch);
199 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
200 environmentId: a,
201 input: {
202 threadId,
203 query: "COD4",
204 skills: [],
205 start: { entryId: "new-bottom", occurrence: 0 },
206 },
207 });
208 });
209
210 it("cycles cached entries immediately, wraps, and accumulates clicks in one event", async () => {
211 const initial = {
212 ...messageResult(0),
213 totalMatches: 5,
214 match: { entryId: "first", runId, occurrence: 0 },
215 navigation: [
216 { entryId: "first", runId, startIndex: 0, count: 3 },
217 { entryId: "second", runId, startIndex: 3, count: 2 },
218 ],
219 };
220 queries.results.set(a, initial);
221 await act(async () => {
222 renderer = create(<Probe environmentId={a} />);
223 });
224 await act(async () => find.open());
225 await act(async () => find.barProps.onQueryChange("COD4"));
226 const requests = vi.mocked(orchestrationEnvironment.threadFind).mock.calls.length;
227 await act(async () => {
228 find.barProps.onNext();
229 find.barProps.onNext();
230 });
231 expect(find.barProps.activeIndex).toBe(2);
232 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "first", runId, occurrence: 2 });
233 expect(find.timelineProps.findNavigationId).toBe(3);
234 await act(async () => find.barProps.onNext());
235 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "second", runId, occurrence: 0 });
236 await act(async () => {
237 find.barProps.onNext();
238 find.barProps.onNext();
239 });
240 expect(find.barProps.activeIndex).toBe(0);
241 await act(async () => find.barProps.onPrevious());
242 expect(find.barProps.activeIndex).toBe(4);
243 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "second", runId, occurrence: 1 });
244 expect(orchestrationEnvironment.threadFind).toHaveBeenCalledTimes(requests);
245 expect(queries.refresh).not.toHaveBeenCalled();
246 });
247
248 it("does not let an older background response undo a cached step", async () => {
249 const initial = {
250 ...messageResult(0),
251 match: { entryId: "first", runId, occurrence: 0 },
252 navigation: [{ entryId: "first", runId, startIndex: 0, count: 3 }],
253 };
254 queries.results.set(a, initial);
255 await act(async () => {
256 renderer = create(<Probe environmentId={a} />);
257 });
258 await act(async () => find.open());
259 await act(async () => find.barProps.onQueryChange("COD4"));
260 const requests = vi.mocked(orchestrationEnvironment.threadFind).mock.calls.length;
261 await act(async () => find.barProps.onNext());
262 queries.results.set(a, {
263 ...initial,
264 snapshotSequence: 10,
265 totalMatches: 11,
266 activeIndex: 1,
267 navigation: [{ entryId: "first", runId, startIndex: 1, count: 3 }],
268 });
269 await act(async () => renderer?.update(<Probe environmentId={a} />));
270 expect(find.timelineProps.activeFindMatch).toEqual({ entryId: "first", runId, occurrence: 1 });
271 expect(find.barProps.activeIndex).toBe(2);
272 expect(find.barProps.matchCount).toBe(11);
273 expect(find.timelineProps.findNavigationId).toBe(2);
274 await act(async () => find.barProps.onNext());
275 expect(find.timelineProps.activeFindMatch?.occurrence).toBe(2);
276 expect(find.barProps.activeIndex).toBe(3);
277 expect(orchestrationEnvironment.threadFind).toHaveBeenCalledTimes(requests);
278 });
279
280 it("fetches the next window from the locally selected match and retains it while pending", async () => {
281 const initial = {
282 ...messageResult(0),
283 match: { entryId: "first", runId, occurrence: 0 },
284 navigation: [{ entryId: "first", runId, startIndex: 0, count: 2 }],
285 };
286 queries.results.set(a, initial);
287 await act(async () => {
288 renderer = create(<Probe environmentId={a} />);
289 });
290 await act(async () => find.open());
291 await act(async () => find.barProps.onQueryChange("COD4"));
292 await act(async () => find.barProps.onNext());
293 expect(find.timelineProps.activeFindMatch?.occurrence).toBe(1);
294 queries.pending = true;
295 queries.results.delete(a);
296 await act(async () => {
297 find.barProps.onNext();
298 find.barProps.onNext();
299 });
300 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
301 environmentId: a,
302 input: {
303 threadId,
304 query: "COD4",
305 skills: [],
306 start: { entryId: "first", occurrence: 1 },
307 offset: 2,
308 },
309 });
310 expect(find.barProps.activeIndex).toBe(1);
311 expect(find.timelineProps.findNavigationId).toBe(2);
312 queries.pending = false;
313 queries.results.set(a, messageResult(3));
314 await act(async () => renderer?.update(<Probe environmentId={a} />));
315 expect(find.barProps.activeIndex).toBe(3);
316 expect(find.timelineProps.findNavigationId).toBe(4);
317 });
318
319 it("refreshes around the cached selection when live matches are inserted before it", async () => {
320 vi.useFakeTimers();
321 const initial = {
322 ...messageResult(0),
323 match: { entryId: "first", runId, occurrence: 0 },
324 navigation: [
325 { entryId: "first", runId, startIndex: 0, count: 1 },
326 { entryId: "second", runId, startIndex: 1, count: 1 },
327 ],
328 };
329 queries.results.set(a, initial);
330 await act(async () => {
331 renderer = create(<Probe environmentId={a} />);
332 });
333 await act(async () => find.open());
334 await act(async () => find.barProps.onQueryChange("COD4"));
335 await act(async () => find.barProps.onNext());
336 expect(find.timelineProps.activeFindMatch?.entryId).toBe("second");
337 const content = { runs: [], visibleTurnItems: [] };
338 await act(async () => renderer?.update(<Probe environmentId={a} content={content} />));
339 await act(async () => vi.advanceTimersByTime(300));
340 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
341 environmentId: a,
342 input: { threadId, query: "COD4", skills: [], start: { entryId: "second", occurrence: 0 } },
343 });
344 queries.results.set(a, {
345 ...initial,
346 snapshotSequence: 10,
347 totalMatches: 11,
348 activeIndex: 2,
349 match: { entryId: "second", runId, occurrence: 0 },
350 navigation: [
351 { entryId: "inserted", runId, startIndex: 1, count: 1 },
352 { entryId: "second", runId, startIndex: 2, count: 1 },
353 ],
354 });
355 await act(async () => renderer?.update(<Probe environmentId={a} content={content} />));
356 expect(find.barProps.matchCount).toBe(11);
357 expect(find.barProps.activeIndex).toBe(2);
358 expect(find.timelineProps.activeFindMatch?.entryId).toBe("second");
359 expect(find.timelineProps.findNavigationId).toBe(2);
360 await act(async () => find.barProps.onPrevious());
361 expect(find.timelineProps.activeFindMatch?.entryId).toBe("inserted");
362 });
363
364 it("asks the server rather than using stale navigation counts during live updates", async () => {
365 const initial = {
366 ...messageResult(0),
367 navigation: [{ entryId: "message:2", runId, startIndex: 0, count: 10 }],
368 };
369 queries.results.set(a, initial);
370 await act(async () => {
371 renderer = create(<Probe environmentId={a} />);
372 });
373 await act(async () => find.open());
374 await act(async () => find.barProps.onQueryChange("COD4"));
375 await act(async () =>
376 renderer?.update(<Probe environmentId={a} content={{ runs: [], visibleTurnItems: [] }} />),
377 );
378 await act(async () => find.barProps.onNext());
379 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
380 environmentId: a,
381 input: {
382 threadId,
383 query: "COD4",
384 skills: [],
385 start: { entryId: "message:2", occurrence: 0 },
386 offset: 1,
387 },
388 });
389 });
390
391 it("starts from the viewport and steps from the server-selected match", async () => {
392 queries.results.set(a, messageResult(4));
393 await act(async () => {
394 renderer = create(<Probe environmentId={a} />);
395 });
396 await act(async () => find.open());
397 find.timelineProps.findPositionReaderRef.current = () => ({
398 entryId: "message:6",
399 occurrence: 1,
400 });
401 await act(async () => find.barProps.onQueryChange("COD4"));
402 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
403 environmentId: a,
404 input: {
405 threadId,
406 query: "COD4",
407 skills: [],
408 start: { entryId: "message:6", occurrence: 1 },
409 },
410 });
411 expect(find.barProps.activeIndex).toBe(4);
412 queries.results.set(a, messageResult(5));
413 await act(async () => find.barProps.onNext());
414 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
415 environmentId: a,
416 input: {
417 threadId,
418 query: "COD4",
419 skills: [],
420 start: { entryId: "message:6", occurrence: 0 },
421 offset: 1,
422 },
423 });
424 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:7");
425 queries.results.set(a, messageResult(4));
426 await act(async () => find.barProps.onPrevious());
427 expect(find.barProps.activeIndex).toBe(4);
428 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
429 environmentId: a,
430 input: {
431 threadId,
432 query: "COD4",
433 skills: [],
434 start: { entryId: "message:7", occurrence: 0 },
435 offset: -1,
436 },
437 });
438 });
439
440 it("accumulates rapid navigation against the same match until the response arrives", async () => {
441 queries.results.set(a, messageResult(4));
442 await act(async () => {
443 renderer = create(<Probe environmentId={a} />);
444 });
445 await act(async () => find.open());
446 await act(async () => find.barProps.onQueryChange("COD4"));
447 queries.pending = true;
448 queries.results.delete(a);
449 await act(async () => find.barProps.onNext());
450 await act(async () => find.barProps.onNext());
451 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
452 environmentId: a,
453 input: {
454 threadId,
455 query: "COD4",
456 skills: [],
457 start: { entryId: "message:6", occurrence: 0 },
458 offset: 2,
459 },
460 });
461 expect(find.timelineProps.findNavigationId).toBe(1);
462 queries.pending = false;
463 queries.results.set(a, messageResult(6));
464 await act(async () => renderer?.update(<Probe environmentId={a} />));
465 expect(find.timelineProps.findNavigationId).toBe(3);
466 expect(find.barProps.activeIndex).toBe(6);
467 });
468
469 it("refreshes continuous updates without shifting the current match or showing Searching", async () => {
470 vi.useFakeTimers();
471 queries.results.set(a, messageResult(4));
472 await act(async () => {
473 renderer = create(<Probe environmentId={a} />);
474 });
475 await act(async () => find.open());
476 await act(async () => find.barProps.onQueryChange("COD4"));
477 for (let i = 0; i < 3; i++) {
478 await act(async () =>
479 renderer?.update(<Probe environmentId={a} content={{ visibleTurnItems: [], runs: [] }} />),
480 );
481 await act(async () => vi.advanceTimersByTime(100));
482 }
483 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
484 environmentId: a,
485 input: {
486 threadId,
487 query: "COD4",
488 skills: [],
489 start: { entryId: "message:6", occurrence: 0 },
490 },
491 });
492 const updated = { ...messageResult(5, 10), totalMatches: 11, match: messageResult(4).match };
493 queries.results.set(a, updated);
494 await act(async () => renderer?.update(<Probe environmentId={a} />));
495 expect(find.barProps.matchCount).toBe(11);
496 expect(find.barProps.activeIndex).toBe(5);
497 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:6");
498 expect(find.timelineProps.findNavigationId).toBe(1);
499 expect(find.barProps.status).toBeNull();
500 await act(async () => find.barProps.onNext());
501 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
502 environmentId: a,
503 input: {
504 threadId,
505 query: "COD4",
506 skills: [],
507 start: { entryId: "message:6", occurrence: 0 },
508 offset: 1,
509 },
510 });
511 });
512
513 it("keeps pending navigation when incoming content triggers a refresh", async () => {
514 vi.useFakeTimers();
515 queries.results.set(a, messageResult(4));
516 await act(async () => {
517 renderer = create(<Probe environmentId={a} />);
518 });
519 await act(async () => find.open());
520 await act(async () => find.barProps.onQueryChange("COD4"));
521 queries.pending = true;
522 queries.results.delete(a);
523 await act(async () => find.barProps.onNext());
524 await act(async () =>
525 renderer?.update(<Probe environmentId={a} content={{ visibleTurnItems: [], runs: [] }} />),
526 );
527 await act(async () => vi.advanceTimersByTime(300));
528 expect(queries.refresh).toHaveBeenCalledTimes(1);
529 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
530 environmentId: a,
531 input: {
532 threadId,
533 query: "COD4",
534 skills: [],
535 start: { entryId: "message:6", occurrence: 0 },
536 offset: 1,
537 },
538 });
539 queries.pending = false;
540 queries.results.set(a, { ...messageResult(6, 10), totalMatches: 11 });
541 await act(async () => renderer?.update(<Probe environmentId={a} />));
542 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:8");
543 expect(find.timelineProps.findNavigationId).toBe(2);
544 expect(find.barProps.matchCount).toBe(11);
545 });
546
547 it("does not navigate the previous result while a new match is loading", async () => {
548 queries.results.set(a, messageResult(0));
549 await act(async () => {
550 renderer = create(<Probe environmentId={a} />);
551 });
552 await act(async () => find.open());
553 await act(async () => find.barProps.onQueryChange("COD4"));
554 queries.pending = true;
555 queries.results.delete(a);
556 await act(async () => find.barProps.onNext());
557 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:2");
558 expect(find.timelineProps.findNavigationId).toBe(1);
559 expect(find.barProps.status).toBeNull();
560 expect(find.barProps.activeIndex).toBe(0);
561 expect(find.barProps.matchCount).toBe(10);
562 queries.pending = false;
563 queries.results.set(a, messageResult(1));
564 await act(async () => renderer?.update(<Probe environmentId={a} />));
565 expect(find.timelineProps.activeFindMatch?.entryId).toBe("message:3");
566 expect(find.timelineProps.findNavigationId).toBe(2);
567 });
568
569 it("keeps the reading-position start when Enter is pressed before results arrive", async () => {
570 queries.pending = true;
571 await act(async () => {
572 renderer = create(<Probe environmentId={a} />);
573 });
574 await act(async () => find.open());
575 find.timelineProps.findPositionReaderRef.current = () => ({
576 entryId: "message:6",
577 occurrence: 1,
578 });
579 await act(async () => find.barProps.onQueryChange("COD4"));
580 await act(async () => find.barProps.onNext());
581 expect(orchestrationEnvironment.threadFind).toHaveBeenLastCalledWith({
582 environmentId: a,
583 input: {
584 threadId,
585 query: "COD4",
586 skills: [],
587 start: { entryId: "message:6", occurrence: 1 },
588 },
589 });
590 });
591
592 it("shows Searching only when the query has no result yet", async () => {
593 queries.pending = true;
594 await act(async () => {
595 renderer = create(<Probe environmentId={a} />);
596 });
597 await act(async () => find.open());
598 await act(async () => find.barProps.onQueryChange("COD4"));
599 expect(find.barProps.status).toBe("loading");
600 queries.pending = false;
601 queries.results.set(a, messageResult(0));
602 await act(async () => renderer?.update(<Probe environmentId={a} />));
603 expect(find.barProps.status).toBeNull();
604 queries.pending = true;
605 queries.results.delete(a);
606 await act(async () => find.barProps.onQueryChange("different query"));
607 expect(find.barProps.status).toBe("loading");
608 expect(find.barProps.matchCount).toBe(0);
609 // Folded content stays expanded while the new query loads.
610 expect(find.timelineProps.findQuery).toBe("");
611 expect(find.timelineProps.findExpanded).toBe(true);
612 await act(async () => find.barProps.onQueryChange(""));
613 expect(find.timelineProps.findExpanded).toBe(false);
614 });
615
616 it("keeps search unavailable when the server does not support it", async () => {
617 await act(async () => {
618 renderer = create(<Probe environmentId={a} enabled={false} />);
619 });
620 await act(async () => {
621 find.open();
622 requestThreadFindOpen();
623 });
624 expect(find.isOpen).toBe(false);
625 expect(find.timelineProps.activeFindMatch).toBeNull();
626 expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled();
627 });
628
629 it("closes search when support disappears without restoring stale queries", async () => {
630 await act(async () => {
631 renderer = create(<Probe environmentId={a} />);
632 });
633 await act(async () => find.open());
634 await act(async () => find.barProps.onQueryChange("needle"));
635 expect(find.isOpen).toBe(true);
636 vi.mocked(orchestrationEnvironment.threadFind).mockClear();
637 await act(async () => {
638 renderer?.update(<Probe environmentId={a} enabled={false} />);
639 });
640 expect(find.isOpen).toBe(false);
641 expect(find.timelineProps.activeFindMatch).toBeNull();
642 expect(find.timelineProps.findQuery).toBe("");
643 expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled();
644 await act(async () => {
645 renderer?.update(<Probe environmentId={a} />);
646 });
647 expect(find.isOpen).toBe(false);
648 await act(async () => find.open());
649 expect(find.barProps.query).toBe("");
650 });
651
652 it("preserves plan item ID and run ownership for navigation", async () => {
653 queries.results.set(a, {
654 snapshotSequence: 9,
655 totalMatches: 1,
656 activeIndex: 0,
657 match: { entryId: "plan-item", runId, occurrence: 0 },
658 });
659 await act(async () => {
660 renderer = create(<Probe environmentId={a} />);
661 });
662 await act(async () => {
663 find.open();
664 });
665 await act(async () => {
666 find.barProps.onQueryChange("needle");
667 });
668 expect(find.timelineProps.activeFindMatch).toEqual({
669 entryId: "plan-item",
670 runId,
671 occurrence: 0,
672 });
673 await act(async () => {
674 find.close();
675 });
676 expect(find.timelineProps.activeFindMatch).toBeNull();
677 expect(find.timelineProps.findQuery).toBe("");
678 });
679
680 it("drops results on environment changes even when the thread IDs are identical", async () => {
681 queries.results.set(a, {
682 snapshotSequence: 1,
683 totalMatches: 3,
684 activeIndex: 0,
685 match: null,
686 });
687 queries.results.set(b, {
688 snapshotSequence: 2,
689 totalMatches: 0,
690 activeIndex: 0,
691 match: null,
692 });
693 await act(async () => {
694 renderer = create(<Probe environmentId={a} />);
695 });
696 await act(async () => {
697 find.open();
698 });
699 await act(async () => {
700 find.barProps.onQueryChange("needle");
701 });
702 expect(find.barProps.matchCount).toBe(3);
703 await act(async () => {
704 renderer?.update(<Probe environmentId={b} />);
705 });
706 expect(find.isOpen).toBe(false);
707 expect(find.timelineProps.activeFindMatch).toBeNull();
708 await act(async () => {
709 find.open();
710 });
711 await act(async () => {
712 find.barProps.onQueryChange("needle");
713 });
714 expect(find.barProps.matchCount).toBe(0);
715 await act(async () => {
716 renderer?.update(<Probe environmentId={a} />);
717 });
718 expect(find.isOpen).toBe(false);
719 });
720});
apps/web/src/components/chat/useThreadFind.ts created+386
......@@ -0,0 +1,386 @@
1import type { InlineSkill } from "@t3tools/shared/inlineSkills";
2import type {
3 OrchestrationV2ThreadProjection,
4 OrchestrationV2SearchThreadResult,
5 ScopedThreadRef,
6} from "@t3tools/contracts";
7import { scopedThreadKey } from "@t3tools/client-runtime/environment";
8import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
9import { orchestrationEnvironment } from "~/state/orchestration";
10import { useEnvironmentQuery } from "~/state/query";
11import { useDebouncedValue } from "~/state/queries";
12import {
13 type ThreadFindPositionReader,
14 type ThreadFindStart,
15 stepThreadFindIndex,
16} from "./threadFind";
17import { subscribeThreadFindOpen } from "./threadFindActionBus";
18import { toastManager } from "../ui/toast";
19
20const EMPTY_SKILLS: readonly InlineSkill[] = [];
21
22const CLOSED_FIND = {
23 threadKey: null as string | null,
24 query: "",
25 offset: 0,
26 start: undefined as ThreadFindStart | undefined,
27 focusRequestId: 0,
28 navigationId: 0,
29 requestId: 0,
30 localSelection: null as {
31 base: OrchestrationV2SearchThreadResult;
32 baseNavigationId: number;
33 activeIndex: number;
34 match: NonNullable<OrchestrationV2SearchThreadResult["match"]>;
35 navigationId: number;
36 } | null,
37};
38
39/** Owns find state for the active thread and its environment. */
40export function useThreadFind({
41 thread,
42 enabled,
43 progressive = false,
44 skills = EMPTY_SKILLS,
45 content,
46}: {
47 thread: ScopedThreadRef | null;
48 enabled: boolean;
49 progressive?: boolean;
50 skills?: readonly InlineSkill[];
51 content: Pick<OrchestrationV2ThreadProjection, "visibleTurnItems" | "runs"> | undefined;
52}) {
53 const findPositionReaderRef = useRef<ThreadFindPositionReader | null>(null);
54 const lastContentRef = useRef(content);
55 const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
56 const refreshAfterSearchRef = useRef(false);
57 const threadKey = thread ? scopedThreadKey(thread) : null;
58 const [state, setState] = useState(CLOSED_FIND);
59 if (state.threadKey !== null && (!enabled || state.threadKey !== threadKey))
60 setState(CLOSED_FIND);
61 const isOpen = enabled && threadKey !== null && state.threadKey === threadKey;
62 const open = useCallback(() => {
63 if (threadKey === null) return;
64 if (!enabled) {
65 toastManager.add({
66 id: "thread-find-unavailable",
67 title: "Thread search is unavailable on this server.",
68 description: "Update the server to enable it.",
69 });
70 return;
71 }
72 const start =
73 state.threadKey === threadKey && state.query.trim()
74 ? findPositionReaderRef.current?.(state.query.trim())
75 : undefined;
76 setState((previous) => {
77 const current = previous.threadKey === threadKey ? previous : CLOSED_FIND;
78 return {
79 ...current,
80 threadKey,
81 focusRequestId: previous.focusRequestId + 1,
82 ...(current.query.trim()
83 ? {
84 start,
85 offset: 0,
86 localSelection: null,
87 navigationId: current.navigationId + 1,
88 requestId: current.navigationId + 1,
89 }
90 : {}),
91 };
92 });
93 }, [enabled, state.query, state.threadKey, threadKey]);
94 const close = useCallback(() => setState(CLOSED_FIND), []);
95 useEffect(() => subscribeThreadFindOpen(open), [open]);
96
97 const remote = useServerResults(
98 isOpen ? thread : null,
99 state.query,
100 state.offset,
101 state.requestId,
102 state.start,
103 skills,
104 progressive,
105 );
106 const status: "loading" | "error" | null = remote.error
107 ? "error"
108 : remote.isPending && remote.data === null
109 ? "loading"
110 : null;
111 const localSelection = rebaseLocalSelection(
112 state.localSelection,
113 remote.data,
114 remote.navigationId,
115 );
116 const count = remote.data?.totalMatches ?? 0;
117 const activeIndex = localSelection?.activeIndex ?? remote.data?.activeIndex ?? 0;
118 const selectedMatch = localSelection?.match ?? remote.data?.match;
119 const entryId = selectedMatch?.entryId;
120 const runId = selectedMatch?.runId;
121 const occurrence = selectedMatch?.occurrence;
122 // Finishing the count must not rerender the timeline or request another jump.
123 const match = useMemo(
124 () =>
125 entryId !== undefined && occurrence !== undefined
126 ? { entryId, runId: runId ?? null, occurrence }
127 : null,
128 [entryId, runId, occurrence],
129 );
130 const counting = remote.data?.complete === false;
131 const navigationId = localSelection?.navigationId ?? remote.navigationId;
132 const step = (delta: number) => {
133 if (count === 0 || counting) return;
134 setState((previous) => {
135 const local = rebaseLocalSelection(previous.localSelection, remote.data, remote.navigationId);
136 const pending = previous.navigationId !== (local?.navigationId ?? remote.navigationId);
137 const selected = local?.match ?? remote.data?.match;
138 const nextIndex = stepThreadFindIndex(
139 local?.activeIndex ?? remote.data?.activeIndex ?? 0,
140 count,
141 delta,
142 );
143 const entry = remote.data?.navigation?.find(
144 (entry) => nextIndex >= entry.startIndex && nextIndex < entry.startIndex + entry.count,
145 );
146 const nextNavigationId = previous.navigationId + 1;
147 if (
148 !pending &&
149 !remote.isPending &&
150 entry &&
151 remote.data &&
152 (!local ||
153 local.base === remote.data ||
154 remote.data.navigation?.some(
155 (entry) =>
156 entry.entryId === local.match.entryId && local.match.occurrence < entry.count,
157 )) &&
158 refreshTimerRef.current === null &&
159 lastContentRef.current === content
160 ) {
161 return {
162 ...previous,
163 navigationId: nextNavigationId,
164 localSelection: {
165 base: remote.data,
166 baseNavigationId: remote.navigationId,
167 activeIndex: nextIndex,
168 match: {
169 entryId: entry.entryId,
170 runId: entry.runId,
171 occurrence: nextIndex - entry.startIndex,
172 },
173 navigationId: nextNavigationId,
174 },
175 };
176 }
177 return {
178 ...previous,
179 start:
180 pending || !selected
181 ? previous.start
182 : { entryId: selected.entryId, occurrence: selected.occurrence },
183 offset: pending ? previous.offset + delta : delta,
184 navigationId: nextNavigationId,
185 requestId: nextNavigationId,
186 };
187 });
188 };
189
190 // Refresh at most once per 300 ms, even while tokens continue arriving.
191 // An identity anchor keeps newly inserted matches from shifting the selection.
192 const refreshLiveResultsRef = useRef(() => {});
193 useLayoutEffect(() => {
194 refreshLiveResultsRef.current = () => {
195 if (!isOpen || !state.query.trim()) return;
196 // Paging can change client content while the server is still counting.
197 // Coalesce those updates instead of repeatedly restarting the scan.
198 if (progressive && remote.isPending) {
199 refreshAfterSearchRef.current = true;
200 return;
201 }
202 if (!match || state.navigationId !== navigationId) {
203 remote.refresh();
204 return;
205 }
206 const start = { entryId: match.entryId, occurrence: match.occurrence };
207 if (
208 state.offset === 0 &&
209 state.start?.entryId === start.entryId &&
210 state.start.occurrence === start.occurrence &&
211 state.requestId === state.navigationId
212 ) {
213 remote.refresh();
214 } else {
215 setState((previous) => ({
216 ...previous,
217 start,
218 offset: 0,
219 requestId: previous.navigationId,
220 }));
221 }
222 };
223 });
224 useEffect(() => {
225 if (!refreshAfterSearchRef.current || remote.isPending) return;
226 refreshAfterSearchRef.current = false;
227 if (!remote.error) refreshLiveResultsRef.current();
228 }, [remote.isPending, remote.error]);
229 useEffect(() => {
230 if (lastContentRef.current === content) return;
231 lastContentRef.current = content;
232 if (!isOpen || !state.query.trim() || refreshTimerRef.current !== null) return;
233 refreshTimerRef.current = setTimeout(() => {
234 refreshTimerRef.current = null;
235 refreshLiveResultsRef.current();
236 }, 300);
237 }, [content, isOpen, state.query]);
238 useEffect(() => {
239 if (!isOpen || threadKey === null || !state.query.trim()) return;
240 return () => {
241 if (refreshTimerRef.current !== null) clearTimeout(refreshTimerRef.current);
242 refreshTimerRef.current = null;
243 refreshAfterSearchRef.current = false;
244 };
245 }, [threadKey, isOpen, state.query]);
246
247 const findExpanded = isOpen && state.query.trim().length > 0;
248 const findQuery = isOpen && remote.data?.match ? state.query : "";
249 const timelineProps = useMemo(
250 () => ({
251 findOpen: isOpen,
252 // Keep folds open between keystrokes while the next result loads.
253 findExpanded,
254 findPositionReaderRef,
255 findQuery,
256 activeFindMatch: match,
257 findNavigationId: navigationId,
258 }),
259 [isOpen, findExpanded, findQuery, match, navigationId],
260 );
261
262 return {
263 isOpen,
264 open,
265 close,
266 barProps: {
267 open: isOpen,
268 query: state.query,
269 matchCount: count,
270 counting,
271 activeIndex,
272 status,
273 focusRequestId: state.focusRequestId,
274 onRetry: remote.refresh,
275 onQueryChange: (query: string) => {
276 const start = findPositionReaderRef.current?.(query.trim());
277 setState((previous) => ({
278 ...previous,
279 query,
280 offset: 0,
281 start,
282 localSelection: null,
283 navigationId: previous.navigationId + 1,
284 requestId: previous.navigationId + 1,
285 }));
286 },
287 onNext: () => step(1),
288 onPrevious: () => step(-1),
289 onClose: close,
290 },
291 timelineProps,
292 };
293}
294
295/** A response for an older anchor can update counts without undoing newer local steps. */
296function rebaseLocalSelection(
297 selection: typeof CLOSED_FIND.localSelection,
298 data: OrchestrationV2SearchThreadResult | null,
299 navigationId: number,
300) {
301 if (!selection || !data) return null;
302 if (selection.base === data && selection.baseNavigationId === navigationId) return selection;
303 if (navigationId >= selection.navigationId) return null;
304 const entry = data.navigation?.find((entry) => entry.entryId === selection.match.entryId);
305 if (!entry || selection.match.occurrence >= entry.count) return selection;
306 const activeIndex = entry.startIndex + selection.match.occurrence;
307 return activeIndex === selection.activeIndex ? selection : { ...selection, activeIndex };
308}
309
310/** Query atoms cancel obsolete requests; navigation retains only the current query's result. */
311function useServerResults(
312 thread: ScopedThreadRef | null,
313 query: string,
314 offset: number,
315 navigationId: number,
316 start: ThreadFindStart | undefined,
317 skills: readonly InlineSkill[],
318 progressive: boolean,
319) {
320 const skillLabels = useMemo(
321 () => skills.map(({ name, displayName }) => ({ name, displayName })),
322 [skills],
323 );
324 const normalizedQuery = query.trim();
325 const debouncedQuery = useDebouncedValue(normalizedQuery, 100);
326 const environmentId = thread?.environmentId;
327 const threadId = thread?.threadId;
328 const atom = useMemo(
329 () =>
330 environmentId && threadId && debouncedQuery && normalizedQuery === debouncedQuery
331 ? (progressive
332 ? orchestrationEnvironment.threadFindProgressive
333 : orchestrationEnvironment.threadFind)({
334 environmentId,
335 input: {
336 threadId,
337 query: debouncedQuery,
338 ...(start ? { start } : {}),
339 ...(offset !== 0 ? { offset } : {}),
340 skills: skillLabels,
341 },
342 })
343 : null,
344 [
345 environmentId,
346 threadId,
347 debouncedQuery,
348 normalizedQuery,
349 start,
350 offset,
351 skillLabels,
352 progressive,
353 ],
354 );
355 const result = useEnvironmentQuery(atom);
356 const key = thread
357 ? JSON.stringify([thread.environmentId, thread.threadId, normalizedQuery, skillLabels])
358 : null;
359 const [previous, setPrevious] = useState({
360 key,
361 data: result.data,
362 navigationId,
363 });
364 if (
365 previous.key !== key ||
366 (result.data !== null &&
367 (!result.isPending || (progressive && previous.data !== result.data)) &&
368 (previous.data !== result.data || previous.navigationId !== navigationId))
369 ) {
370 const response = result.data;
371 setPrevious({
372 key,
373 data: response,
374 navigationId,
375 });
376 }
377 return {
378 ...result,
379 data: key === previous.key && !result.error ? previous.data : null,
380 navigationId: previous.navigationId,
381 isPending:
382 thread !== null &&
383 normalizedQuery.length > 0 &&
384 (normalizedQuery !== debouncedQuery || result.isPending),
385 };
386}
apps/web/src/components/chat/useThreadFindNavigation.test.tsx created+393
......@@ -0,0 +1,393 @@
1// @vitest-environment jsdom
2
3import type { LegendListRef } from "@legendapp/list/react";
4import { act, type ComponentProps } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7import { useThreadFindNavigation } from "./useThreadFindNavigation";
8
9const renders = vi.fn();
10function Probe(props: Parameters<typeof useThreadFindNavigation>[0]) {
11 renders();
12 useThreadFindNavigation(props);
13 return null;
14}
15
16let root: Root;
17let host: HTMLDivElement;
18let container: HTMLDivElement;
19let rect: DOMRect;
20const scrollToIndex = vi.fn<LegendListRef["scrollToIndex"]>();
21const scrollToOffset = vi.fn<LegendListRef["scrollToOffset"]>();
22let props: ComponentProps<typeof Probe>;
23
24beforeEach(() => {
25 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
26 vi.stubGlobal("CSS", { highlights: new Map() });
27 vi.stubGlobal(
28 "Highlight",
29 class extends Set<Range> {
30 constructor(...ranges: Range[]) {
31 super(ranges);
32 }
33 },
34 );
35 rect = new DOMRect(0, 200, 40, 20);
36 Object.defineProperty(Range.prototype, "getBoundingClientRect", {
37 configurable: true,
38 value: () => rect,
39 });
40 host = document.createElement("div");
41 container = document.createElement("div");
42 container.innerHTML =
43 '<div data-timeline-row-id="message"><p data-thread-find-text>COD4 and COD4</p></div>';
44 container.getBoundingClientRect = () => new DOMRect(0, 0, 800, 600);
45 document.body.append(host, container);
46 root = createRoot(host);
47 scrollToIndex.mockResolvedValue();
48 props = {
49 container,
50 query: "COD4",
51 match: { entryId: "message", runId: null, occurrence: 0 },
52 navigationId: 0,
53 rowIndex: 0,
54 entries: [{ id: "message" }],
55 listRef: {
56 current: {
57 scrollToIndex,
58 scrollToOffset,
59 getState: () => ({ scroll: 300 }),
60 } as unknown as LegendListRef,
61 },
62 contentInsetEndAdjustment: 100,
63 listReady: true,
64 };
65});
66
67afterEach(async () => {
68 await act(async () => root.unmount());
69 host.remove();
70 container.remove();
71 Reflect.deleteProperty(Range.prototype, "getBoundingClientRect");
72 vi.clearAllMocks();
73 vi.unstubAllGlobals();
74});
75
76describe("find result navigation", () => {
77 it("retargets a pending first jump when progressive history moves the matching row", async () => {
78 container.replaceChildren();
79 let first!: () => void;
80 let second!: () => void;
81 scrollToIndex
82 .mockImplementationOnce(
83 () =>
84 new Promise<void>((resolve) => {
85 first = resolve;
86 }),
87 )
88 .mockImplementationOnce(
89 () =>
90 new Promise<void>((resolve) => {
91 second = resolve;
92 }),
93 );
94 await act(async () => root.render(<Probe {...props} rowIndex={2} />));
95 await act(async () =>
96 root.render(<Probe {...props} rowIndex={6} entries={[{ id: "tool" }, ...props.entries]} />),
97 );
98 expect(scrollToIndex.mock.calls.map(([request]) => request.index)).toEqual([2, 6]);
99 await act(async () => first());
100 expect(scrollToOffset).not.toHaveBeenCalled();
101 await act(async () => {
102 container.innerHTML =
103 '<div data-timeline-row-id="message"><p data-thread-find-text>COD4</p></div>';
104 rect = new DOMRect(0, 450, 40, 20);
105 second();
106 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
107 });
108 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
109 });
110
111 it("keeps the highlighted text at the same position when progressive activity arrives", async () => {
112 const historyControls = {
113 hasMoreHistory: true,
114 loading: true,
115 error: null,
116 onLoadEarlier: vi.fn(),
117 };
118 await act(async () => root.render(<Probe {...props} historyControls={historyControls} />));
119 rect = new DOMRect(0, 368, 40, 20);
120 scrollToOffset.mockImplementationOnce(async () => {
121 rect = new DOMRect(0, 200, 40, 20);
122 });
123 await act(async () =>
124 root.render(
125 <Probe
126 {...props}
127 entries={[{ id: "tool" }, { id: "message" }]}
128 historyControls={{ ...historyControls, loading: false }}
129 />,
130 ),
131 );
132 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 468, animated: false });
133 expect(scrollToIndex).not.toHaveBeenCalled();
134 });
135
136 it("respects scrolling away from a partial match before activity arrives", async () => {
137 const historyControls = {
138 hasMoreHistory: true,
139 loading: true,
140 error: null,
141 onLoadEarlier: vi.fn(),
142 };
143 await act(async () => root.render(<Probe {...props} historyControls={historyControls} />));
144 rect = new DOMRect(0, -600, 40, 20);
145 container.dispatchEvent(new Event("scroll"));
146 await act(async () =>
147 root.render(
148 <Probe
149 {...props}
150 entries={[{ id: "tool" }, { id: "message" }]}
151 historyControls={{ ...historyControls, loading: false }}
152 />,
153 ),
154 );
155 expect(scrollToOffset).not.toHaveBeenCalled();
156 });
157
158 it("reveals settled virtual text without another timeline render", async () => {
159 let complete!: () => void;
160 scrollToIndex.mockImplementationOnce(
161 () =>
162 new Promise<void>((resolve) => {
163 complete = resolve;
164 }),
165 );
166 rect = new DOMRect(0, 1800, 40, 20);
167 await act(async () => root.render(<Probe {...props} />));
168 expect(renders).toHaveBeenCalledTimes(1);
169 expect(scrollToOffset).not.toHaveBeenCalled();
170 rect = new DOMRect(0, 450, 40, 20);
171 await act(async () => {
172 complete();
173 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
174 });
175 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
176 expect(renders).toHaveBeenCalledTimes(1);
177 });
178
179 it("ignores an older scroll completion after navigation moves to another occurrence", async () => {
180 let first!: () => void;
181 let second!: () => void;
182 scrollToIndex
183 .mockImplementationOnce(
184 () =>
185 new Promise<void>((resolve) => {
186 first = resolve;
187 }),
188 )
189 .mockImplementationOnce(
190 () =>
191 new Promise<void>((resolve) => {
192 second = resolve;
193 }),
194 );
195 rect = new DOMRect(0, 1800, 40, 20);
196 await act(async () => root.render(<Probe {...props} />));
197 await act(async () =>
198 root.render(
199 <Probe
200 {...props}
201 navigationId={1}
202 match={{ entryId: "message", runId: null, occurrence: 1 }}
203 />,
204 ),
205 );
206 rect = new DOMRect(0, 450, 40, 20);
207 await act(async () => first());
208 expect(scrollToOffset).not.toHaveBeenCalled();
209 await act(async () => {
210 second();
211 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
212 });
213 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
214 });
215
216 it("loads history until a distant match can be revealed in the conversation", async () => {
217 const onLoadEarlier = vi.fn();
218 const historyControls = { hasMoreHistory: true, loading: false, error: null, onLoadEarlier };
219 const pending = { ...props, rowIndex: -1, entries: [{ id: "recent" }], historyControls };
220 await act(async () => root.render(<Probe {...pending} />));
221 expect(onLoadEarlier).toHaveBeenCalledTimes(1);
222 expect(scrollToIndex).not.toHaveBeenCalled();
223 await act(async () =>
224 root.render(<Probe {...pending} historyControls={{ ...historyControls, loading: true }} />),
225 );
226 expect(onLoadEarlier).toHaveBeenCalledTimes(1);
227 await act(async () =>
228 root.render(<Probe {...pending} entries={[{ id: "older" }, ...pending.entries]} />),
229 );
230 expect(onLoadEarlier).toHaveBeenCalledTimes(2);
231 rect = new DOMRect(0, 1800, 40, 20);
232 scrollToIndex.mockImplementationOnce(async () => {
233 rect = new DOMRect(0, 200, 40, 20);
234 });
235 await act(async () => root.render(<Probe {...props} historyControls={historyControls} />));
236 expect(onLoadEarlier).toHaveBeenCalledTimes(2);
237 expect(scrollToIndex).toHaveBeenCalledTimes(1);
238 });
239
240 it("waits for list bootstrap before positioning the first result", async () => {
241 rect = new DOMRect(0, 1800, 40, 20);
242 await act(async () => root.render(<Probe {...props} listReady={false} />));
243 expect(scrollToIndex).not.toHaveBeenCalled();
244 expect(scrollToOffset).not.toHaveBeenCalled();
245 scrollToIndex.mockImplementationOnce(async () => {
246 rect = new DOMRect(0, 200, 40, 20);
247 });
248 await act(async () => root.render(<Probe {...props} />));
249 expect(scrollToIndex).toHaveBeenCalledTimes(1);
250 expect(scrollToOffset).not.toHaveBeenCalled();
251 });
252 it("leaves the scroll position unchanged between already visible occurrences", async () => {
253 await act(async () => root.render(<Probe {...props} />));
254 await act(async () =>
255 root.render(
256 <Probe
257 {...props}
258 navigationId={1}
259 match={{ entryId: "message", runId: null, occurrence: 1 }}
260 />,
261 ),
262 );
263 expect(scrollToIndex).not.toHaveBeenCalled();
264 expect(scrollToOffset).not.toHaveBeenCalled();
265 const highlight = CSS.highlights.get("t3-thread-find-active");
266 const ranges = [...(highlight as unknown as Set<Range>)];
267 expect(ranges).toHaveLength(1);
268 expect(ranges[0]?.startOffset).toBe(9);
269 expect(ranges[0]?.toString()).toBe("COD4");
270 });
271
272 it("reveals the same match again when search restarts after scrolling away", async () => {
273 await act(async () => root.render(<Probe {...props} />));
274 rect = new DOMRect(0, -1800, 40, 20);
275 props.listRef.current = {
276 scrollToIndex,
277 scrollToOffset,
278 getState: () => ({ scroll: 2400 }),
279 } as unknown as LegendListRef;
280 await act(async () => root.render(<Probe {...props} navigationId={1} />));
281 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 504, animated: false });
282 expect(scrollToIndex).not.toHaveBeenCalled();
283 expect(
284 Array.from(CSS.highlights.get("t3-thread-find-active") ?? [], (range) => range.toString()),
285 ).toEqual(["COD4"]);
286 });
287
288 it("moves only far enough to reveal text below the composer", async () => {
289 await act(async () => root.render(<Probe {...props} />));
290 rect = new DOMRect(0, 450, 40, 20);
291 await act(async () =>
292 root.render(
293 <Probe
294 {...props}
295 navigationId={1}
296 match={{ entryId: "message", runId: null, occurrence: 1 }}
297 />,
298 ),
299 );
300 expect(scrollToIndex).not.toHaveBeenCalled();
301 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
302 });
303
304 it("moves only far enough to reveal text above the viewport margin", async () => {
305 await act(async () => root.render(<Probe {...props} />));
306 rect = new DOMRect(0, 40, 40, 20);
307 await act(async () =>
308 root.render(
309 <Probe
310 {...props}
311 navigationId={1}
312 match={{ entryId: "message", runId: null, occurrence: 1 }}
313 />,
314 ),
315 );
316 expect(scrollToIndex).not.toHaveBeenCalled();
317 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 244, animated: false });
318 });
319
320 it("keeps the selected text visible when newly measured rows shift during scrolling", async () => {
321 let scroll = 300;
322 props.listRef.current = {
323 scrollToIndex,
324 scrollToOffset,
325 getState: () => ({ scroll }),
326 } as unknown as LegendListRef;
327 scrollToOffset
328 .mockImplementationOnce(async ({ offset }) => {
329 scroll = offset;
330 // Newly mounted row measurements offset the first scroll movement.
331 })
332 .mockImplementationOnce(async ({ offset }) => {
333 scroll = offset;
334 rect = new DOMRect(0, 200, 40, 20);
335 });
336 await act(async () => root.render(<Probe {...props} />));
337 rect = new DOMRect(0, 450, 40, 20);
338 await act(async () =>
339 root.render(
340 <Probe
341 {...props}
342 navigationId={1}
343 match={{ entryId: "message", runId: null, occurrence: 1 }}
344 />,
345 ),
346 );
347 await act(async () => {
348 await new Promise<void>((resolve) =>
349 requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
350 );
351 });
352 expect(rect.top).toBe(200);
353 expect(scrollToOffset.mock.calls).toEqual([
354 [{ offset: 366, animated: false }],
355 [{ offset: 432, animated: false }],
356 ]);
357 expect(renders).toHaveBeenCalledTimes(2);
358 });
359
360 it("waits for a new context window to settle before revealing an offscreen result", async () => {
361 rect = new DOMRect(0, 1800, 40, 20);
362 scrollToIndex.mockImplementationOnce(async () => {
363 rect = new DOMRect(0, 450, 40, 20);
364 });
365 await act(async () => {
366 root.render(<Probe {...props} />);
367 });
368 await act(async () => {
369 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
370 });
371 expect(scrollToIndex).toHaveBeenCalledTimes(1);
372 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
373 });
374
375 it("materializes a virtualized message before revealing its occurrence", async () => {
376 container.replaceChildren();
377 await act(async () => root.render(<Probe {...props} rowIndex={4} />));
378 expect(scrollToIndex).toHaveBeenCalledExactlyOnceWith({
379 index: 4,
380 animated: false,
381 viewOffset: 96,
382 });
383 expect(scrollToOffset).not.toHaveBeenCalled();
384 rect = new DOMRect(0, 450, 40, 20);
385 await act(async () => {
386 container.innerHTML =
387 '<div data-timeline-row-id="message"><p data-thread-find-text>COD4</p></div>';
388 await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
389 });
390 expect(scrollToIndex).toHaveBeenCalledTimes(1);
391 expect(scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 366, animated: false });
392 });
393});
apps/web/src/components/chat/useThreadFindNavigation.ts created+261
......@@ -0,0 +1,261 @@
1import type { LegendListRef } from "@legendapp/list/react";
2import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from "react";
3import type { ThreadFindMatch } from "./threadFind";
4import { useThreadFindHighlights } from "./threadFindHighlights";
5
6const FIND_MATCH_VIEW_MARGIN = 96;
7
8/** Materializes missing results and otherwise scrolls only enough to reveal the active text. */
9export function useThreadFindNavigation({
10 container,
11 query,
12 match,
13 navigationId,
14 rowIndex,
15 entries,
16 listRef,
17 contentInsetEndAdjustment,
18 listReady,
19 historyControls,
20}: {
21 container: HTMLElement | null;
22 query: string;
23 match: ThreadFindMatch | null;
24 navigationId: number;
25 rowIndex: number;
26 entries: readonly { readonly id: string }[];
27 listRef: RefObject<LegendListRef | null>;
28 contentInsetEndAdjustment: number;
29 listReady: boolean;
30 historyControls?:
31 | {
32 readonly hasMoreHistory: boolean;
33 readonly loading: boolean;
34 readonly error: string | null;
35 readonly onLoadEarlier: (throughEntryId?: string) => void;
36 }
37 | undefined;
38}) {
39 const matchKey = match ? `${navigationId}:${query}:${match.entryId}:${match.occurrence}` : null;
40 const positioningRef = useRef<{
41 key: string;
42 rowIndex: number;
43 entries: typeof entries;
44 } | null>(null);
45 const settledMatchRef = useRef<string | null>(null);
46 const currentMatchKeyRef = useRef(matchKey);
47 const activeRangeRef = useRef<Range | null>(null);
48 const pagingPositionRef = useRef<{ key: string; entries: typeof entries; top: number } | null>(
49 null,
50 );
51 const currentEntriesRef = useRef(entries);
52 useLayoutEffect(() => {
53 currentEntriesRef.current = entries;
54 }, [entries]);
55 useEffect(() => {
56 if (!container) return;
57 const onScroll = () => {
58 const position = pagingPositionRef.current;
59 const range = activeRangeRef.current;
60 if (
61 !position ||
62 position.entries !== currentEntriesRef.current ||
63 !range?.startContainer.isConnected
64 )
65 return;
66 const rect = range.getBoundingClientRect();
67 const viewport = container.getBoundingClientRect();
68 if (rect.bottom < viewport.top || rect.top > viewport.bottom)
69 pagingPositionRef.current = null;
70 else position.top = rect.top;
71 };
72 container.addEventListener("scroll", onScroll, { passive: true });
73 return () => container.removeEventListener("scroll", onScroll);
74 }, [container]);
75 const revealRef = useRef<(range: Range | null) => void>(() => {});
76 useLayoutEffect(() => {
77 currentMatchKeyRef.current = matchKey;
78 return () => {
79 currentMatchKeyRef.current = null;
80 };
81 }, [matchKey]);
82 const revealedMatchRef = useRef<string | null>(null);
83 const positionedEntriesRef = useRef<typeof entries | null>(null);
84 const requestedPagesRef = useRef<{ key: string | null; pages: Set<string> }>({
85 key: null,
86 pages: new Set(),
87 });
88 useEffect(() => {
89 if (requestedPagesRef.current.key !== matchKey)
90 requestedPagesRef.current = { key: matchKey, pages: new Set() };
91 if (
92 !match ||
93 !listReady ||
94 rowIndex >= 0 ||
95 entries.some((entry) => entry.id === match.entryId)
96 )
97 return;
98 if (!historyControls?.hasMoreHistory || historyControls.loading || historyControls.error)
99 return;
100 const cursor = entries[0]?.id ?? "first";
101 if (requestedPagesRef.current.pages.has(cursor)) return;
102 requestedPagesRef.current.pages.add(cursor);
103
104 historyControls.onLoadEarlier(match.entryId);
105 }, [entries, historyControls, listReady, match, matchKey, rowIndex]);
106 const reveal = useCallback(
107 (range: Range | null) => {
108 activeRangeRef.current = range;
109 if (!matchKey) {
110 positioningRef.current = null;
111 settledMatchRef.current = null;
112 revealedMatchRef.current = null;
113 pagingPositionRef.current = null;
114 return;
115 }
116 const pagingPosition = pagingPositionRef.current;
117 if (pagingPosition && pagingPosition.key !== matchKey) pagingPositionRef.current = null;
118 if (
119 range?.startContainer.isConnected &&
120 pagingPosition?.key === matchKey &&
121 pagingPosition.entries !== entries
122 ) {
123 const restore = () => {
124 const pinned = pagingPositionRef.current;
125 const currentRange = activeRangeRef.current;
126 if (
127 currentMatchKeyRef.current !== matchKey ||
128 !pinned ||
129 !currentRange?.startContainer.isConnected
130 )
131 return;
132 const delta = currentRange.getBoundingClientRect().top - pinned.top;
133 const scroll = listRef.current?.getState?.().scroll;
134 if (Math.abs(delta) >= 1 && typeof scroll === "number")
135 listRef.current?.scrollToOffset({ offset: scroll + delta, animated: false });
136 };
137 pagingPosition.entries = entries;
138 restore();
139 // The list measures newly inserted activity after the first layout pass.
140 requestAnimationFrame(() =>
141 requestAnimationFrame(() => {
142 restore();
143 if (!historyControls?.loading && pagingPositionRef.current === pagingPosition)
144 pagingPositionRef.current = null;
145 }),
146 );
147 }
148 if (historyControls?.error) pagingPositionRef.current = null;
149 if (revealedMatchRef.current === matchKey) return;
150 if (!listReady || rowIndex < 0) return;
151 const materialize = () => {
152 const list = listRef.current;
153 const previous = positioningRef.current;
154 if (
155 !list ||
156 (previous?.key === matchKey &&
157 previous.rowIndex === rowIndex &&
158 previous.entries === entries)
159 )
160 return;
161 // Activity can insert rows while LegendList waits for layout. Retarget the
162 // pending jump instead of treating the first requested index as final.
163 const request = { key: matchKey, rowIndex, entries };
164 positioningRef.current = request;
165
166 void list
167 .scrollToIndex({
168 index: rowIndex,
169 animated: false,
170 viewOffset: FIND_MATCH_VIEW_MARGIN,
171 })
172 .then(() => {
173 if (positioningRef.current !== request || currentMatchKeyRef.current !== matchKey)
174 return;
175
176 positionedEntriesRef.current = entries;
177 settledMatchRef.current = matchKey;
178 revealRef.current(activeRangeRef.current);
179 });
180 };
181 if (!range) {
182 if (container) materialize();
183 return;
184 }
185
186 const codeScroller = range.startContainer.parentElement?.closest("pre");
187 if (codeScroller) {
188 const rect = range.getBoundingClientRect();
189 const viewport = codeScroller.getBoundingClientRect();
190 if (rect.left < viewport.left) codeScroller.scrollLeft += rect.left - viewport.left - 16;
191 else if (rect.right > viewport.right)
192 codeScroller.scrollLeft += rect.right - viewport.right + 16;
193 }
194 const rect = range.getBoundingClientRect();
195 const viewport = container?.getBoundingClientRect();
196 if (!viewport || rect.height === 0) return;
197 // Shrink the margin when the band above the composer is short, so the match stays inside it.
198 const margin = Math.min(
199 FIND_MATCH_VIEW_MARGIN,
200 Math.max(0, (viewport.height - contentInsetEndAdjustment - rect.height) / 2),
201 );
202 const top = viewport.top + margin;
203 const bottom = viewport.bottom - margin - contentInsetEndAdjustment;
204 const delta =
205 rect.top < top ? rect.top - top : rect.bottom > bottom ? rect.bottom - bottom : 0;
206 // A new list can expose DOM text before its virtual row sizes settle.
207 // Await row positioning before measuring an offscreen occurrence.
208 if (
209 Math.abs(delta) >= 1 &&
210 positionedEntriesRef.current !== entries &&
211 settledMatchRef.current !== matchKey
212 ) {
213 materialize();
214 return;
215 }
216 const scroll = listRef.current?.getState?.().scroll;
217 positionedEntriesRef.current = entries;
218
219 revealedMatchRef.current = matchKey;
220 if (historyControls?.loading)
221 pagingPositionRef.current = { key: matchKey, entries, top: rect.top - delta };
222 if (Math.abs(delta) >= 1 && typeof scroll === "number") {
223 listRef.current?.scrollToOffset({ offset: scroll + delta, animated: false });
224 // Scrolling can mount rows whose measured heights move the selected text again.
225 requestAnimationFrame(() => {
226 if (currentMatchKeyRef.current !== matchKey) return;
227 if (
228 listRef.current?.getState?.().scroll === scroll &&
229 range.getBoundingClientRect().top === rect.top
230 )
231 return;
232 revealedMatchRef.current = null;
233 revealRef.current(activeRangeRef.current);
234 });
235 }
236 },
237 [
238 container,
239 contentInsetEndAdjustment,
240 entries,
241 historyControls?.loading,
242 historyControls?.error,
243 listRef,
244 listReady,
245 matchKey,
246 rowIndex,
247 ],
248 );
249
250 useLayoutEffect(() => {
251 revealRef.current = reveal;
252 }, [reveal]);
253
254 useThreadFindHighlights({
255 container,
256 query,
257 activeRowId: match?.entryId ?? null,
258 activeOccurrence: match?.occurrence ?? 0,
259 onActiveRange: reveal,
260 });
261}
apps/web/src/components/files/FileBreadcrumbs.tsx+1-1
......@@ -19,7 +19,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1919import { useTheme } from "~/hooks/useTheme";
2020import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
2121import { cn } from "~/lib/utils";
22import { isAbsolutePath } from "~/terminal-links";
22import { isAbsolutePath } from "@t3tools/shared/path";
2323
2424import {
2525 type FileBreadcrumb,
apps/web/src/components/files/FileMarkdownPreview.tsx+1-1
......@@ -1,7 +1,7 @@
11import type { ScopedThreadRef } from "@t3tools/contracts";
22
33import ChatMarkdown from "~/components/ChatMarkdown";
4import { resolvePathLinkTarget } from "~/terminal-links";
4import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
55
66export function FileMarkdownPreview(props: {
77 readonly cwd: string;
apps/web/src/components/files/FilePreviewPanel.tsx+2-1
......@@ -57,7 +57,8 @@ import { resolveDiffThemeName } from "~/lib/diffRendering";
5757import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
5858import { cn } from "~/lib/utils";
5959import type { ChatFileAttachment } from "~/types";
60import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";
60import { isAbsolutePath } from "@t3tools/shared/path";
61import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
6162import { ScrollArea } from "~/components/ui/scroll-area";
6263import { stackedThreadToast, toastManager } from "~/components/ui/toast";
6364import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
apps/web/src/components/files/filePath.ts+1-3
......@@ -1,7 +1,5 @@
11import type { ProjectEntry } from "@t3tools/contracts";
2import { isWindowsAbsolutePath } from "@t3tools/shared/path";
3
4import { isAbsolutePath } from "~/terminal-links";
2import { isWindowsAbsolutePath, isAbsolutePath } from "@t3tools/shared/path";
53
64export interface FileBreadcrumb {
75 label: string;
apps/web/src/components/files/filePreviewMode.ts+1-2
......@@ -1,6 +1,5 @@
1import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
21import type { ProjectReadFileError } from "@t3tools/contracts";
3import { isAbsolutePath } from "~/terminal-links";
2import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path";
43
54/** Resolve workspace links before choosing between the explorer and a file preview. */
65export function resolveFilePreviewPath(path: string | null, cwd: string): string | null {
apps/web/src/components/settings/ThemeEditorPanel.tsx+18
......@@ -162,6 +162,24 @@ const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{
162162 },
163163 ],
164164 },
165 {
166 id: "search",
167 title: "Search",
168 families: [
169 {
170 id: "search-matches",
171 label: "Search matches",
172 role: "searchMatchBackground",
173 roles: ["searchMatchBackground", "searchMatchForeground"],
174 },
175 {
176 id: "current-match",
177 label: "Current match",
178 role: "searchMatchActiveBackground",
179 roles: ["searchMatchActiveBackground", "searchMatchActiveForeground"],
180 },
181 ],
182 },
165183 {
166184 id: "context",
167185 title: "Context",
apps/web/src/components/ui/input-group.tsx+29-2
......@@ -11,11 +11,19 @@ const inputGroupVariants = cva(
1111 {
1212 defaultVariants: {
1313 variant: "default",
14 size: "default",
1415 },
1516 variants: {
17 size: {
18 default: null,
19 lg: "h-9",
20 },
1621 variant: {
1722 default:
1823 "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%)]",
24 // Matches floating glass cards (no shadow); focus darkens the hairline instead of a ring.
25 popover:
26 "dropdown-glass rounded-2xl has-[input:focus-visible,textarea:focus-visible]:ring-0 has-[input:focus-visible,textarea:focus-visible]:border-[color-mix(in_srgb,var(--contrast-foreground)_18%,transparent)]",
1927 ghost:
2028 "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background",
2129 },
......@@ -26,11 +34,12 @@ const inputGroupVariants = cva(
2634function InputGroup({
2735 className,
2836 variant,
37 size,
2938 ...props
3039}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
3140 return (
3241 <div
33 className={cn(inputGroupVariants({ variant }), className)}
42 className={cn(inputGroupVariants({ variant, size }), className)}
3443 data-slot="input-group"
3544 role="group"
3645 {...props}
......@@ -44,7 +53,24 @@ const inputGroupAddonVariants = cva(
4453 defaultVariants: {
4554 align: "inline-start",
4655 },
56 compoundVariants: [
57 // Pill groups curve too tightly for buttons pulled to the edge; keep them inset.
58 {
59 align: "inline-end",
60 inset: "pill",
61 className: "has-[>button]:me-0 pe-1 [[data-size=sm]+&]:pe-1 gap-0.5",
62 },
63 {
64 align: "inline-start",
65 inset: "pill",
66 className: "has-[>button]:ms-0 ps-1 [[data-size=sm]+&]:ps-1 gap-0.5",
67 },
68 ],
4769 variants: {
70 inset: {
71 default: null,
72 pill: "[&_button]:rounded-full",
73 },
4874 align: {
4975 "block-end":
5076 "order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
......@@ -62,11 +88,12 @@ const inputGroupAddonVariants = cva(
6288function InputGroupAddon({
6389 className,
6490 align = "inline-start",
91 inset,
6592 ...props
6693}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
6794 return (
6895 <div
69 className={cn(inputGroupAddonVariants({ align }), className)}
96 className={cn(inputGroupAddonVariants({ align, inset }), className)}
7097 data-align={align}
7198 data-slot="input-group-addon"
7299 onMouseDown={(e) => {
apps/web/src/diffFileActions.ts+1-1
......@@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts";
22import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path";
33
44import { useRightPanelStore } from "./rightPanelStore";
5import { resolvePathLinkTarget } from "./terminal-links";
5import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
66
77interface OpenDiffFilePrimaryActionInput {
88 readonly threadRef: ScopedThreadRef | null;
apps/web/src/fileContextMenu.ts+1-1
......@@ -22,7 +22,7 @@ import { readLocalApi } from "./localApi";
2222import { serverEnvironment } from "./state/server";
2323import { shellEnvironment } from "./state/shell";
2424import { useAtomCommand } from "./state/use-atom-command";
25import { resolvePathLinkTarget } from "./terminal-links";
25import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
2626import { toastManager } from "./components/ui/toast";
2727import { useAtomValue } from "@effect/atom-react";
2828
apps/web/src/filePathDisplay.ts+3-4
......@@ -1,10 +1,9 @@
11import {
22 fileBasename,
3 formatFilePathPosition,
4 splitFilePathPosition,
53 stripSlashPrefixedWindowsDrive,
6} from "@t3tools/client-runtime/markdown-links";
7import { isWindowsAbsolutePath } from "@t3tools/shared/path";
4 isWindowsAbsolutePath,
5} from "@t3tools/shared/path";
6import { formatFilePathPosition, splitFilePathPosition } from "@t3tools/shared/fileLinks";
87
98function normalizePathSeparators(path: string): string {
109 return path.replaceAll("\\", "/");
apps/web/src/index.css+14
......@@ -373,6 +373,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
373373 }
374374}
375375
376/* Paint find results without changing rendered markdown or copied text. */
377::highlight(t3-thread-find) {
378 color: var(--app-theme-search-match-foreground, var(--color-foreground));
379 background-color: var(--app-theme-search-match-background, oklch(0.826487 0.157967 78.604 / 0.3));
380}
381
382::highlight(t3-thread-find-active) {
383 color: var(--app-theme-search-match-active-foreground, oklch(0.216164 0.028283 77.423));
384 background-color: var(
385 --app-theme-search-match-active-background,
386 oklch(0.774202 0.151946 74.331)
387 );
388}
389
376390@utility dialog-glass {
377391 background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);
378392 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
apps/web/src/keybindings.test.ts+8
......@@ -142,6 +142,14 @@ const DEFAULT_BINDINGS = compile([
142142 shortcut: modShortcut("t", { altKey: true, shiftKey: true }),
143143 command: "themeEditor.toggle",
144144 },
145 {
146 shortcut: modShortcut("f"),
147 command: "chat.find",
148 whenAst: whenAnd(
149 whenNot(whenIdentifier("terminalFocus")),
150 whenNot(whenIdentifier("previewFocus")),
151 ),
152 },
145153 {
146154 shortcut: modShortcut("m", { shiftKey: true }),
147155 command: "modelPicker.toggle",
apps/web/src/markdown-github-alerts.test.tsx deleted-60
......@@ -1,60 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import ReactMarkdown from "react-markdown";
3import remarkGfm from "remark-gfm";
4import { describe, expect, it } from "vite-plus/test";
5
6import { remarkGithubAlerts } from "./markdown-github-alerts";
7
8function renderMarkdown(markdown: string): string {
9 return renderToStaticMarkup(
10 <ReactMarkdown remarkPlugins={[remarkGfm, remarkGithubAlerts]}>{markdown}</ReactMarkdown>,
11 );
12}
13
14describe("remarkGithubAlerts", () => {
15 it("tags a quote whose first line is a marker, and drops the marker line", () => {
16 const html = renderMarkdown("> [!NOTE]\n> The content of the note.");
17
18 expect(html).toContain('data-alert="note"');
19 expect(html).not.toContain("[!NOTE]");
20 expect(html).toContain("The content of the note.");
21 });
22
23 it("keeps content that follows a marker-only paragraph", () => {
24 const html = renderMarkdown("> [!WARNING]\n>\n> ### A heading\n>\n> And a paragraph.");
25
26 expect(html).toContain('data-alert="warning"');
27 expect(html).not.toContain("[!WARNING]");
28 expect(html).toContain("A heading");
29 expect(html).toContain("And a paragraph.");
30 });
31
32 it("tags a quote whose next line opens with an inline node rather than text", () => {
33 const html = renderMarkdown("> [!NOTE]\n> **Medium Risk** Changes live transcript routing.");
34
35 expect(html).toContain('data-alert="note"');
36 expect(html).not.toContain("[!NOTE]");
37 expect(html).toContain("<strong>Medium Risk</strong>");
38 expect(html).toContain("Changes live transcript routing.");
39 });
40
41 it("reads the marker case-insensitively, normalizing the kind", () => {
42 const html = renderMarkdown("> [!important]\n> Read this.");
43
44 expect(html).toContain('data-alert="important"');
45 });
46
47 it("leaves a quote alone when the marker shares its line with anything else", () => {
48 const html = renderMarkdown("> [!NOTE] an ordinary quote");
49
50 expect(html).not.toContain("data-alert");
51 expect(html).toContain("[!NOTE] an ordinary quote");
52 });
53
54 it("leaves an unknown marker as the text it is", () => {
55 const html = renderMarkdown("> [!DANGER]\n> Not one of GitHub's.");
56
57 expect(html).not.toContain("data-alert");
58 expect(html).toContain("[!DANGER]");
59 });
60});
apps/web/src/markdown-github-alerts.ts deleted-72
......@@ -1,72 +0,0 @@
1/**
2 * GitHub's blockquote alerts: a quote whose first line is `[!NOTE]` — or TIP, IMPORTANT,
3 * WARNING, CAUTION — renders as a titled callout. They are GitHub's own extension rather than
4 * GFM, so remark-gfm leaves the marker as literal text in the quote. This lifts the marker off
5 * the mdast into a `data-alert` attribute for the blockquote renderer to style, and removes
6 * the marker line itself.
7 *
8 * Only a marker with nothing after it on its own line counts, which is GitHub's rule:
9 * `> [!NOTE] aside` is an ordinary quote.
10 */
11
12interface MarkdownAstNode {
13 type?: string;
14 value?: unknown;
15 data?: {
16 hProperties?: Record<string, unknown>;
17 };
18 children?: MarkdownAstNode[];
19}
20
21const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i;
22
23function readGithubAlert(node: MarkdownAstNode): void {
24 if (node.type !== "blockquote") return;
25 const paragraph = node.children?.[0];
26 const text = paragraph?.children?.[0];
27 if (paragraph?.type !== "paragraph" || text?.type !== "text" || typeof text.value !== "string") {
28 return;
29 }
30 const match = GITHUB_ALERT_MARKER.exec(text.value);
31 if (!match?.[1]) return;
32
33 const remainder = text.value.slice(match[0].length);
34 // Whether the marker's own line ended inside this text node. Without it, an empty remainder
35 // is ambiguous: `[!NOTE]\n**bold**` parses as [text "[!NOTE]\n", strong] — next-line content
36 // in a sibling — while `[!NOTE]*aside*` parses the same way minus the newline.
37 const markerEndsItsLine = match[0].endsWith("\n");
38 if (remainder.length > 0) {
39 // The quote continues on the next line inside the same text node: keep the paragraph,
40 // shed the marker line.
41 text.value = remainder;
42 } else if (markerEndsItsLine || paragraph.children?.length === 1) {
43 // The marker was the whole text node — a marker-only paragraph, or a marker line whose
44 // next line starts as a sibling inline. Drop the node, and the paragraph if it empties.
45 paragraph.children?.shift();
46 if (paragraph.children?.length === 0) {
47 node.children?.shift();
48 }
49 } else {
50 // Something else shares the marker's line — `[!NOTE]*aside*` — which GitHub does not
51 // read as an alert.
52 return;
53 }
54
55 node.data = {
56 ...node.data,
57 hProperties: {
58 ...node.data?.hProperties,
59 dataAlert: match[1].toLowerCase(),
60 },
61 };
62}
63
64export function remarkGithubAlerts() {
65 return (tree: MarkdownAstNode) => {
66 const visit = (node: MarkdownAstNode) => {
67 node.children?.forEach(visit);
68 readGithubAlert(node);
69 };
70 visit(tree);
71 };
72}
apps/web/src/markdown-incremental.test.tsx+5-6
......@@ -1,16 +1,15 @@
1import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline";
12import type { Root } from "mdast";
23import { renderToStaticMarkup } from "react-dom/server";
34import ReactMarkdown from "react-markdown";
4import rehypeRaw from "rehype-raw";
5import rehypeSanitize from "rehype-sanitize";
65import remarkGfm from "remark-gfm";
76import type { Plugin } from "unified";
87import { describe, expect, it } from "vite-plus/test";
98
10import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives";
11import { remarkGithubAlerts } from "./markdown-github-alerts";
9import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives";
10import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts";
1211import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
13import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";
12import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation";
1413
1514function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {
1615 let tree: Root | undefined;
......@@ -37,7 +36,7 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?:
3736 remarkCodexDirectives,
3837 ...(incremental ? [incremental] : []),
3938 ]}
40 rehypePlugins={[rehypeRaw, rehypeSanitize]}
39 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
4140 >
4241 {source}
4342 </ReactMarkdown>,
apps/web/src/markdown-links.test.ts-260
......@@ -1,66 +1,13 @@
11import { describe, expect, it } from "vite-plus/test";
2import { createElement } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import ReactMarkdown from "react-markdown";
52
63import {
7 extractMarkdownLinkHrefs,
8 isWindowsDrivePathHref,
94 resolveInlineCodeFileLinkMeta,
105 resolveMarkdownFileLinkMeta,
11 resolveMarkdownFileLinkTarget,
126 rewriteMarkdownFileUriHref,
137 shouldOpenMarkdownFileLinkInBrowserByDefault,
148 shouldOpenMarkdownFileLinkInEditor,
159} from "./markdown-links";
1610
17describe("isWindowsDrivePathHref", () => {
18 it.each([
19 ["C:\\repo\\image.png", true],
20 ["C:%5Crepo%5Cimage.png", true],
21 ["https://example.com/image.png", false],
22 ])("classifies %s as %s", (href, expected) => {
23 expect(isWindowsDrivePathHref(href)).toBe(expected);
24 });
25});
26
27function renderMarkdownLinkHref(markdown: string): string | undefined {
28 let renderedHref: string | undefined;
29 renderToStaticMarkup(
30 createElement(
31 ReactMarkdown,
32 {
33 components: {
34 a({ href }) {
35 renderedHref = href;
36 return createElement("a", { href });
37 },
38 },
39 },
40 markdown,
41 ),
42 );
43 return renderedHref;
44}
45
46describe("extractMarkdownLinkHrefs", () => {
47 it("extracts angle-bracketed paths containing spaces", () => {
48 expect(
49 extractMarkdownLinkHrefs(
50 "[Open the Bike Receipts folder](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)",
51 ),
52 ).toEqual(["/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts"]);
53 });
54
55 it("preserves ordinary destinations and ignores link titles", () => {
56 expect(
57 extractMarkdownLinkHrefs(
58 '[source](apps/web/src/markdown-links.ts "implementation") and [docs](https://example.com)',
59 ),
60 ).toEqual(["apps/web/src/markdown-links.ts", "https://example.com"]);
61 });
62});
63
6411describe("shouldOpenMarkdownFileLinkInEditor", () => {
6512 it("uses command-click on macOS", () => {
6613 expect(shouldOpenMarkdownFileLinkInEditor({ metaKey: true, ctrlKey: false }, "MacIntel")).toBe(
......@@ -130,213 +77,6 @@ describe("rewriteMarkdownFileUriHref", () => {
13077 });
13178});
13279
133describe("resolveMarkdownFileLinkTarget", () => {
134 it("resolves absolute posix file paths", () => {
135 expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe(
136 "/Users/julius/project/AGENTS.md",
137 );
138 });
139
140 it("resolves relative file paths against cwd", () => {
141 expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe(
142 "/Users/julius/project/src/processRunner.ts:71",
143 );
144 });
145
146 it("does not treat filename line references as external schemes", () => {
147 expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe(
148 "/Users/julius/project/script.ts:10",
149 );
150 });
151
152 it("resolves bare file names against cwd", () => {
153 expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe(
154 "/Users/julius/project/AGENTS.md",
155 );
156 });
157
158 it("maps #L line anchors to editor line suffixes", () => {
159 expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe(
160 "/Users/julius/project/src/main.ts:42:7",
161 );
162 });
163
164 it("ignores external urls", () => {
165 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();
166 expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull();
167 });
168
169 it("does not double-decode file URLs", () => {
170 expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe(
171 "/Users/julius/project/file%20name.md",
172 );
173 });
174
175 it("resolves file uri authorities as windows UNC paths", () => {
176 expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe(
177 "\\\\server\\share\\workspace-image.svg",
178 );
179 });
180
181 it("resolves a localhost file uri as a local path", () => {
182 expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe(
183 "/home/me/notes.md",
184 );
185 });
186
187 it("formats tooltip display paths relative to the cwd when possible", () => {
188 expect(
189 resolveMarkdownFileLinkMeta(
190 "file:///C:/Users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts#L501",
191 "C:/Users/mike/dev-stuff/t3code",
192 ),
193 ).toMatchObject({
194 displayPath: "t3code/apps/web/src/session-logic.ts:501",
195 workspaceRelativePath: "apps/web/src/session-logic.ts",
196 });
197 });
198
199 it("resolves the encoded spaces emitted by the markdown renderer", () => {
200 expect(
201 resolveMarkdownFileLinkMeta(
202 "/Users/dara/Downloads/Lime%20Ride%20Artifacts/Bike%20Receipts",
203 "/Users/dara/Downloads/Lime Ride Artifacts",
204 ),
205 ).toMatchObject({
206 targetPath: "/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts",
207 workspaceRelativePath: "Bike Receipts",
208 basename: "Bike Receipts",
209 });
210 });
211
212 it("resolves relative spaced folders from the markdown renderer", () => {
213 const href = renderMarkdownLinkHref("[folder](<docs/My Folder>)");
214
215 expect(href).toBe("docs/My%20Folder");
216 expect(resolveMarkdownFileLinkMeta(href, "/repo/project")).toMatchObject({
217 targetPath: "/repo/project/docs/My Folder",
218 workspaceRelativePath: "docs/My Folder",
219 basename: "My Folder",
220 });
221 });
222
223 it.each(["md", "html", "xml"])(
224 "resolves a bare spaced .%s filename from the markdown renderer",
225 (extension) => {
226 const href = renderMarkdownLinkHref(`[checklist](<Updated cutover checklist.${extension}>)`);
227
228 expect(href).toBe(`Updated%20cutover%20checklist.${extension}`);
229 expect(resolveMarkdownFileLinkMeta(href, "/repo/project")).toMatchObject({
230 targetPath: `/repo/project/Updated cutover checklist.${extension}`,
231 workspaceRelativePath: `Updated cutover checklist.${extension}`,
232 basename: `Updated cutover checklist.${extension}`,
233 });
234 },
235 );
236
237 it("formats tooltip display paths relative to the cwd for slash-prefixed windows paths", () => {
238 expect(
239 resolveMarkdownFileLinkMeta(
240 "/C:/Users/mike/dev-stuff/t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx",
241 "C:/Users/mike/dev-stuff/t3code",
242 ),
243 ).toMatchObject({
244 displayPath:
245 "t3code/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx",
246 workspaceRelativePath:
247 "apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx",
248 });
249 });
250
251 it("does not create a preview path for files outside the workspace", () => {
252 expect(resolveMarkdownFileLinkMeta("/tmp/report.ts", "/repo/project")).toMatchObject({
253 workspaceRelativePath: null,
254 });
255 });
256
257 it("does not classify a case-distinct POSIX sibling as a workspace file", () => {
258 expect(
259 resolveMarkdownFileLinkMeta(
260 "/tmp/t3code-case-test/project/probe.txt",
261 "/tmp/t3code-case-test/Project",
262 ),
263 ).toMatchObject({
264 displayPath: "/tmp/t3code-case-test/project/probe.txt",
265 workspaceRelativePath: null,
266 });
267 });
268
269 it("keeps Windows workspace comparisons case-insensitive", () => {
270 expect(
271 resolveMarkdownFileLinkMeta("C:/Users/MIKE/Project/src/main.ts", "c:/users/mike/project"),
272 ).toMatchObject({
273 displayPath: "project/src/main.ts",
274 workspaceRelativePath: "src/main.ts",
275 });
276 });
277
278 it("keeps drive-root workspace comparisons case-insensitive", () => {
279 expect(resolveMarkdownFileLinkMeta("C:/Users/MIKE/project.ts", "c:/")).toMatchObject({
280 displayPath: "c:/Users/MIKE/project.ts",
281 workspaceRelativePath: "Users/MIKE/project.ts",
282 });
283 });
284
285 it("keeps backslash UNC workspace comparisons case-insensitive", () => {
286 expect(
287 resolveMarkdownFileLinkMeta(
288 "\\\\server\\share\\PROJECT\\src\\main.ts",
289 "\\\\Server\\Share\\Project",
290 ),
291 ).toMatchObject({
292 displayPath: "Project/src/main.ts",
293 workspaceRelativePath: "src/main.ts",
294 });
295 });
296
297 it.each([
298 ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"],
299 ["C:/Users/MIKE/file.ts", "c:/", "Users/MIKE/file.ts"],
300 ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"],
301 ["/tmp/repo/file.ts%20", "/tmp/repo", "file.ts "],
302 ])("preserves the preview target for %s in workspace %s", (href, cwd, workspaceRelativePath) => {
303 expect(resolveMarkdownFileLinkMeta(href, cwd)).toMatchObject({ workspaceRelativePath });
304 });
305
306 it("keeps an encoded final space in the absolute target", () => {
307 expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe(
308 "/tmp/repo/file.ts ",
309 );
310 });
311
312 it("normalizes slash-prefixed windows drive paths before resolving", () => {
313 expect(
314 resolveMarkdownFileLinkTarget(
315 "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69",
316 ),
317 ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69");
318 });
319
320 it("resolves angle-bracketed windows drive paths", () => {
321 expect(
322 resolveMarkdownFileLinkTarget(
323 "</D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1>",
324 ),
325 ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1");
326 });
327
328 it("does not treat app routes as file links, even with a line anchor", () => {
329 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
330 expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull();
331 });
332
333 it("decodes an encoded drive colon in a file uri before dropping its slash", () => {
334 expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe(
335 "c:/Users/x/shot.png",
336 );
337 });
338});
339
34080describe("relative links inside a rendered host file", () => {
34181 it("anchor to the file's directory while workspace membership follows cwd", () => {
34282 const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report");
apps/web/src/markdown-links.ts+5-48
......@@ -1,23 +1,13 @@
1import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path";
12import {
2 fileBasename,
3 formatFilePathPosition,
43 inlineCodeFilePathCandidate,
5 isRelativeFilePath,
64 normalizeMarkdownLinkDestination,
7 parseFileUrlHref,
8 parseMarkdownFileLink,
9 safeDecodeURIComponent,
10 splitFilePathPosition,
11 workspaceRelativeFilePath,
12} from "@t3tools/client-runtime/markdown-links";
5 resolveMarkdownFileLinkTarget,
6} from "@t3tools/shared/markdownLinks";
7import { parseFileUrlHref, splitFilePathPosition } from "@t3tools/shared/fileLinks";
138
149import { formatWorkspaceRelativePath } from "./filePathDisplay";
15import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links";
16
17export { normalizeMarkdownLinkDestination };
18
19const MARKDOWN_LINK_HREF_PATTERN =
20 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
10import { isTerminalLinkActivation } from "./terminal-links";
2111
2212export interface MarkdownFileLinkMeta {
2313 filePath: string;
......@@ -29,15 +19,6 @@ export interface MarkdownFileLinkMeta {
2919 column?: number;
3020}
3121
32export function extractMarkdownLinkHrefs(markdown: string): string[] {
33 const hrefs: string[] = [];
34 for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
35 const href = (match[1] ?? match[2])?.trim();
36 if (href) hrefs.push(href);
37 }
38 return hrefs;
39}
40
4122export function shouldOpenMarkdownFileLinkInEditor(
4223 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
4324 platform?: string,
......@@ -49,36 +30,12 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool
4930 return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
5031}
5132
52export function isWindowsDrivePathHref(href: string): boolean {
53 return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
54}
55
5633export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {
5734 if (!href) return null;
5835 const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href));
5936 return target ? `${target.path}${target.hash}` : null;
6037}
6138
62/**
63 * `baseDir` anchors relative links; it defaults to the workspace root and is the
64 * file's own directory when rendering a markdown file. `cwd` stays the workspace
65 * root so the result still knows whether the target is inside it.
66 */
67export function resolveMarkdownFileLinkTarget(
68 href: string | undefined,
69 cwd?: string,
70 baseDir: string | undefined = cwd,
71): string | null {
72 if (!href) return null;
73 const target = parseMarkdownFileLink(href);
74 if (!target) return null;
75
76 const pathWithPosition = formatFilePathPosition(target);
77 if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition;
78 if (!baseDir) return null;
79 return resolvePathLinkTarget(pathWithPosition, baseDir);
80}
81
8239/**
8340 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,
8441 * `origin/main`) rather than deliberate link destinations, so auto-linking
apps/web/src/markdown-list-indentation.test.tsx deleted-82
......@@ -1,82 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import ReactMarkdown from "react-markdown";
3import remarkGfm from "remark-gfm";
4import { describe, expect, it } from "vite-plus/test";
5
6import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";
7
8function renderMarkdown(markdown: string): string {
9 return renderToStaticMarkup(
10 <ReactMarkdown remarkPlugins={[remarkGfm, remarkNormalizeListItemIndentation]}>
11 {markdown}
12 </ReactMarkdown>,
13 );
14}
15
16describe("remarkNormalizeListItemIndentation", () => {
17 it("renders same-line over-indented list content as list text", () => {
18 const html = renderMarkdown(`why did you do this?
19
20- for (const step of rest.steps) {
21- if (step.request.body) {
22- step.request.body = "<redacted>";
23- }
24- }`);
25
26 expect(html).not.toContain("<pre>");
27 expect(html).toContain("<li>for (const step of rest.steps) {</li>");
28 expect(html).toContain("<li>if (step.request.body) {</li>");
29 expect(html).toContain("<li>step.request.body = &quot;&lt;redacted&gt;&quot;;</li>");
30 });
31
32 it("parses inline markdown in recovered list content", () => {
33 const html = renderMarkdown(
34 "- **important** [docs](https://example.com) use `inline code`, not ~~plain text~~",
35 );
36
37 expect(html).toContain("<strong>important</strong>");
38 expect(html).toContain('<a href="https://example.com">docs</a>');
39 expect(html).toContain("<code>inline code</code>");
40 expect(html).toContain("<del>plain text</del>");
41 expect(html).not.toContain("**important**");
42 });
43
44 it("preserves every recovered block separated by blank lines", () => {
45 const html = renderMarkdown(`- **first block**
46
47 [second block](https://example.com)`);
48
49 expect(html).toContain("<strong>first block</strong>");
50 expect(html).toContain('<a href="https://example.com">second block</a>');
51 });
52
53 it("recursively normalizes lists in recovered tail blocks", () => {
54 const html = renderMarkdown(`- first block
55
56 - nested block`);
57
58 expect(html).not.toContain("<pre>");
59 expect(html).toContain("<li>nested block</li>");
60 });
61
62 it("preserves fenced code blocks within list items", () => {
63 const html = renderMarkdown(`- \`\`\`ts
64 const value = 1;
65 \`\`\``);
66
67 expect(html).toContain('<pre><code class="language-ts">const value = 1;');
68 });
69
70 it("preserves indented code blocks that start below a list marker", () => {
71 const html = renderMarkdown(`-
72 const value = 1;`);
73
74 expect(html).toContain("<pre><code>const value = 1;");
75 });
76
77 it("preserves same-line code blocks without excess indentation", () => {
78 const html = renderMarkdown("- const value = 1;");
79
80 expect(html).toContain("<pre><code>const value = 1;");
81 });
82});
apps/web/src/markdown-list-indentation.ts deleted-143
......@@ -1,143 +0,0 @@
1interface MarkdownPosition {
2 readonly start?: {
3 readonly line?: number;
4 readonly offset?: number;
5 };
6}
7
8interface MarkdownAstNode {
9 readonly type: string;
10 readonly value?: unknown;
11 readonly position?: MarkdownPosition;
12 children?: MarkdownAstNode[];
13}
14
15interface MarkdownFile {
16 readonly value?: unknown;
17}
18
19interface MarkdownParser {
20 parse(markdown: string): unknown;
21}
22
23interface RecoveredMarkdown {
24 readonly blocks: MarkdownAstNode[];
25 readonly source: string;
26}
27
28const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:";
29
30function isSameLineOverIndentedCode(
31 node: MarkdownAstNode,
32 parent: MarkdownAstNode | undefined,
33 markdown: string,
34): boolean {
35 if (
36 node.type !== "code" ||
37 parent?.type !== "listItem" ||
38 typeof node.value !== "string" ||
39 !/^[\t ]/.test(node.value)
40 ) {
41 return false;
42 }
43
44 const nodeStart = node.position?.start;
45 const parentStart = parent.position?.start;
46 if (
47 nodeStart?.line === undefined ||
48 nodeStart.offset === undefined ||
49 parentStart?.line === undefined ||
50 nodeStart.line !== parentStart.line
51 ) {
52 return false;
53 }
54
55 const sourceCharacter = markdown[nodeStart.offset];
56 return sourceCharacter !== "`" && sourceCharacter !== "~";
57}
58
59function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
60 // A text prefix forces block-looking input into a paragraph while preserving
61 // the processor's configured inline extensions (for example, GFM syntax).
62 // Later root children are kept as blocks so blank-line-separated content is
63 // never discarded.
64 const source = `${INLINE_PARSE_PREFIX}${value}`;
65 const document = parser.parse(source) as MarkdownAstNode;
66 const blocks = document.children;
67 const paragraph = blocks?.[0];
68 const children = paragraph?.type === "paragraph" ? paragraph.children : undefined;
69 const first = children?.[0];
70 if (
71 !blocks ||
72 !children ||
73 first?.type !== "text" ||
74 typeof first.value !== "string" ||
75 !first.value.startsWith(INLINE_PARSE_PREFIX)
76 ) {
77 return { blocks: [{ type: "text", value }], source };
78 }
79
80 const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
81 return {
82 blocks: [
83 {
84 ...paragraph,
85 type: "paragraph",
86 children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)],
87 },
88 ...blocks.slice(1),
89 ],
90 source,
91 };
92}
93
94function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
95 const value = typeof node.value === "string" ? node.value.trim() : "";
96 const recovered = parseRecoveredMarkdown(value, parser);
97 const first = recovered.blocks[0];
98 return {
99 ...recovered,
100 blocks:
101 first && node.position
102 ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
103 : recovered.blocks,
104 };
105}
106
107/**
108 * CommonMark treats four or more spaces after a list marker as an indented
109 * code block. In chat output, excessive spacing is commonly accidental
110 * alignment such as `- text`, which otherwise produces a full code card
111 * for every bullet. Only normalize blocks that retain excess indentation and
112 * start on the marker's own line; explicit fences and conventional indented
113 * blocks remain code.
114 */
115function attachListItemIndentationNormalizer(this: MarkdownParser) {
116 return (tree: MarkdownAstNode, file: MarkdownFile) => {
117 if (typeof file.value !== "string") {
118 return;
119 }
120 const markdown = file.value;
121
122 const visit = (node: MarkdownAstNode, source: string) => {
123 if (!node.children) {
124 return;
125 }
126 node.children = node.children.flatMap((child) => {
127 if (isSameLineOverIndentedCode(child, node, source)) {
128 const recovered = blocksFromIndentedCode(child, this);
129 for (const block of recovered.blocks) {
130 visit(block, recovered.source);
131 }
132 return recovered.blocks;
133 }
134 visit(child, source);
135 return [child];
136 });
137 };
138
139 visit(tree, markdown);
140 };
141}
142
143export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;
apps/web/src/markdownGithubAlerts.test.tsx created+60
......@@ -0,0 +1,60 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import ReactMarkdown from "react-markdown";
3import remarkGfm from "remark-gfm";
4import { describe, expect, it } from "vite-plus/test";
5
6import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts";
7
8function renderMarkdown(markdown: string): string {
9 return renderToStaticMarkup(
10 <ReactMarkdown remarkPlugins={[remarkGfm, remarkGithubAlerts]}>{markdown}</ReactMarkdown>,
11 );
12}
13
14describe("remarkGithubAlerts", () => {
15 it("tags a quote whose first line is a marker, and drops the marker line", () => {
16 const html = renderMarkdown("> [!NOTE]\n> The content of the note.");
17
18 expect(html).toContain('data-alert="note"');
19 expect(html).not.toContain("[!NOTE]");
20 expect(html).toContain("The content of the note.");
21 });
22
23 it("keeps content that follows a marker-only paragraph", () => {
24 const html = renderMarkdown("> [!WARNING]\n>\n> ### A heading\n>\n> And a paragraph.");
25
26 expect(html).toContain('data-alert="warning"');
27 expect(html).not.toContain("[!WARNING]");
28 expect(html).toContain("A heading");
29 expect(html).toContain("And a paragraph.");
30 });
31
32 it("tags a quote whose next line opens with an inline node rather than text", () => {
33 const html = renderMarkdown("> [!NOTE]\n> **Medium Risk** Changes live transcript routing.");
34
35 expect(html).toContain('data-alert="note"');
36 expect(html).not.toContain("[!NOTE]");
37 expect(html).toContain("<strong>Medium Risk</strong>");
38 expect(html).toContain("Changes live transcript routing.");
39 });
40
41 it("reads the marker case-insensitively, normalizing the kind", () => {
42 const html = renderMarkdown("> [!important]\n> Read this.");
43
44 expect(html).toContain('data-alert="important"');
45 });
46
47 it("leaves a quote alone when the marker shares its line with anything else", () => {
48 const html = renderMarkdown("> [!NOTE] an ordinary quote");
49
50 expect(html).not.toContain("data-alert");
51 expect(html).toContain("[!NOTE] an ordinary quote");
52 });
53
54 it("leaves an unknown marker as the text it is", () => {
55 const html = renderMarkdown("> [!DANGER]\n> Not one of GitHub's.");
56
57 expect(html).not.toContain("data-alert");
58 expect(html).toContain("[!DANGER]");
59 });
60});
apps/web/src/markdownListIndentation.test.tsx created+82
......@@ -0,0 +1,82 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import ReactMarkdown from "react-markdown";
3import remarkGfm from "remark-gfm";
4import { describe, expect, it } from "vite-plus/test";
5
6import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation";
7
8function renderMarkdown(markdown: string): string {
9 return renderToStaticMarkup(
10 <ReactMarkdown remarkPlugins={[remarkGfm, remarkNormalizeListItemIndentation]}>
11 {markdown}
12 </ReactMarkdown>,
13 );
14}
15
16describe("remarkNormalizeListItemIndentation", () => {
17 it("renders same-line over-indented list content as list text", () => {
18 const html = renderMarkdown(`why did you do this?
19
20- for (const step of rest.steps) {
21- if (step.request.body) {
22- step.request.body = "<redacted>";
23- }
24- }`);
25
26 expect(html).not.toContain("<pre>");
27 expect(html).toContain("<li>for (const step of rest.steps) {</li>");
28 expect(html).toContain("<li>if (step.request.body) {</li>");
29 expect(html).toContain("<li>step.request.body = &quot;&lt;redacted&gt;&quot;;</li>");
30 });
31
32 it("parses inline markdown in recovered list content", () => {
33 const html = renderMarkdown(
34 "- **important** [docs](https://example.com) use `inline code`, not ~~plain text~~",
35 );
36
37 expect(html).toContain("<strong>important</strong>");
38 expect(html).toContain('<a href="https://example.com">docs</a>');
39 expect(html).toContain("<code>inline code</code>");
40 expect(html).toContain("<del>plain text</del>");
41 expect(html).not.toContain("**important**");
42 });
43
44 it("preserves every recovered block separated by blank lines", () => {
45 const html = renderMarkdown(`- **first block**
46
47 [second block](https://example.com)`);
48
49 expect(html).toContain("<strong>first block</strong>");
50 expect(html).toContain('<a href="https://example.com">second block</a>');
51 });
52
53 it("recursively normalizes lists in recovered tail blocks", () => {
54 const html = renderMarkdown(`- first block
55
56 - nested block`);
57
58 expect(html).not.toContain("<pre>");
59 expect(html).toContain("<li>nested block</li>");
60 });
61
62 it("preserves fenced code blocks within list items", () => {
63 const html = renderMarkdown(`- \`\`\`ts
64 const value = 1;
65 \`\`\``);
66
67 expect(html).toContain('<pre><code class="language-ts">const value = 1;');
68 });
69
70 it("preserves indented code blocks that start below a list marker", () => {
71 const html = renderMarkdown(`-
72 const value = 1;`);
73
74 expect(html).toContain("<pre><code>const value = 1;");
75 });
76
77 it("preserves same-line code blocks without excess indentation", () => {
78 const html = renderMarkdown("- const value = 1;");
79
80 expect(html).toContain("<pre><code>const value = 1;");
81 });
82});
apps/web/src/openVsxThemes.test.ts+22-2
......@@ -305,6 +305,10 @@ describe("Open VSX themes", () => {
305305 "colors": {
306306 "editor.foreground": "#eeeeee",
307307 "focusBorder": "#8b5cf6",
308 "editor.findMatchBackground": "#663399",
309 "editor.findMatchForeground": "#ffffff",
310 "editor.findMatchHighlightBackground": "#445566",
311 "editor.findMatchHighlightForeground": "#ffffff",
308312 },
309313 }`,
310314 );
......@@ -312,7 +316,10 @@ describe("Open VSX themes", () => {
312316 "extension/themes/demo-dark.json",
313317 `{
314318 "include": "./base.jsonc",
315 "colors": { "editor.background": "#111111" }
319 "colors": {
320 "editor.background": "#111111",
321 "editor.findMatchHighlightBackground": "#ffffff20"
322 }
316323 }`,
317324 );
318325 zip.file(
......@@ -321,7 +328,11 @@ describe("Open VSX themes", () => {
321328 "colors": {
322329 "editor.background": "#fafafa",
323330 "editor.foreground": "#222222",
324 "focusBorder": "#8b5cf6"
331 "focusBorder": "#8b5cf6",
332 "editor.findMatchBackground": "#335577",
333 "editor.findMatchForeground": "#ffffff",
334 "editor.findMatchHighlightBackground": "#bbddff",
335 "editor.findMatchHighlightForeground": "#112233"
325336 }
326337 }`,
327338 );
......@@ -410,6 +421,15 @@ describe("Open VSX themes", () => {
410421 expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa");
411422 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111");
412423 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee");
424 const darkColors = getThemeColorsForMode(paired, "dark")!;
425 expect(themeColorToHex(darkColors.searchMatchActiveBackground)).toBe("#663399");
426 expect(themeColorToHex(darkColors.searchMatchActiveForeground)).toBe("#ffffff");
427 expect(themeColorToHex(darkColors.searchMatchBackground)).toBe("#2f2f2f");
428 expect(themeColorToHex(darkColors.searchMatchForeground)).toBe("#ffffff");
429 expect(themeColorToHex(paired.colors.searchMatchActiveBackground)).toBe("#335577");
430 expect(themeColorToHex(paired.colors.searchMatchActiveForeground)).toBe("#ffffff");
431 expect(themeColorToHex(paired.colors.searchMatchBackground)).toBe("#bbddff");
432 expect(themeColorToHex(paired.colors.searchMatchForeground)).toBe("#112233");
413433
414434 packagedManifest.contributes.themes[0]!.label = "Renamed Dark";
415435 packagedManifest.contributes.themes[1]!.label = "Renamed Light";
apps/web/src/openVsxThemes.ts+4
......@@ -49,6 +49,10 @@ const USED_WORKBENCH_COLORS = new Set([
4949 "dropdown.border",
5050 "editor.background",
5151 "editor.foreground",
52 "editor.findMatchBackground",
53 "editor.findMatchForeground",
54 "editor.findMatchHighlightBackground",
55 "editor.findMatchHighlightForeground",
5256 "editor.selectionBackground",
5357 "editorCursor.foreground",
5458 "editorError.foreground",
apps/web/src/proposedPlan.test.ts-26
......@@ -5,21 +5,9 @@ import {
55 buildPlanImplementationThreadTitle,
66 buildPlanImplementationPrompt,
77 buildProposedPlanMarkdownFilename,
8 proposedPlanTitle,
98 resolvePlanFollowUpSubmission,
10 stripDisplayedPlanMarkdown,
119} from "./proposedPlan";
1210
13describe("proposedPlanTitle", () => {
14 it("reads the first markdown heading as the plan title", () => {
15 expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC");
16 });
17
18 it("returns null when the plan has no heading", () => {
19 expect(proposedPlanTitle("- step 1")).toBeNull();
20 });
21});
22
2311describe("buildPlanImplementationPrompt", () => {
2412 it("formats the plan exactly like the Codex follow-up handoff prompt", () => {
2513 expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe(
......@@ -49,20 +37,6 @@ describe("buildCollapsedProposedPlanPreviewMarkdown", () => {
4937 });
5038});
5139
52describe("stripDisplayedPlanMarkdown", () => {
53 it("drops the leading title heading from displayed plan markdown", () => {
54 expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe(
55 "- step 1",
56 );
57 });
58
59 it("preserves non-summary headings after dropping the title heading", () => {
60 expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe(
61 "## Scope\n\n- step 1",
62 );
63 });
64});
65
6640describe("resolvePlanFollowUpSubmission", () => {
6741 it.each(["", "Consider this "])(
6842 "keeps context-bearing feedback in plan mode with prose %j",
apps/web/src/proposedPlan.ts+1-20
......@@ -1,23 +1,4 @@
1export function proposedPlanTitle(planMarkdown: string): string | null {
2 const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim();
3 return heading && heading.length > 0 ? heading : null;
4}
5
6export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
7 const lines = planMarkdown.trimEnd().split(/\r?\n/);
8 const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
9 while (sourceLines[0]?.trim().length === 0) {
10 sourceLines.shift();
11 }
12 const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/);
13 if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") {
14 sourceLines.shift();
15 while (sourceLines[0]?.trim().length === 0) {
16 sourceLines.shift();
17 }
18 }
19 return sourceLines.join("\n");
20}
1import { stripDisplayedPlanMarkdown, proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
212
223export function buildCollapsedProposedPlanPreviewMarkdown(
234 planMarkdown: string,
apps/web/src/providerSkillSearch.ts+1-1
......@@ -1,7 +1,7 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
12import type { ServerProviderSkill } from "@t3tools/contracts";
23import {
34 dedupeProviderSkillsByName,
4 formatProviderSkillDisplayName,
55 isProviderSkillUserInvocable,
66} from "@t3tools/client-runtime/providerSkills";
77import {
apps/web/src/terminal-links.test.ts+1-1
......@@ -1,3 +1,4 @@
1import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
12import { describe, expect, it } from "vite-plus/test";
23
34import {
......@@ -5,7 +6,6 @@ import {
56 extractTerminalLinks,
67 isTerminalLinkActivation,
78 isTerminalUrl,
8 resolvePathLinkTarget,
99 type TerminalBufferLineLike,
1010} from "./terminal-links";
1111
apps/web/src/terminal-links.ts-63
......@@ -1,8 +1,3 @@
1import {
2 formatFilePathPosition,
3 splitFilePathPosition,
4} from "@t3tools/client-runtime/markdown-links";
5
61import { isMacPlatform } from "./lib/utils";
72
83export type TerminalLinkKind = "url" | "path";
......@@ -98,45 +93,6 @@ function collectMatches(
9893 return matches;
9994}
10095
101function isWindowsAbsolutePath(value: string): boolean {
102 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
103}
104
105export function isAbsolutePath(value: string): boolean {
106 return value.startsWith("/") || isWindowsAbsolutePath(value);
107}
108
109function isWindowsPathStyle(value: string): boolean {
110 return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value);
111}
112
113function joinPath(base: string, next: string, separator: "/" | "\\"): string {
114 const cleanBase = base.replace(/[\\/]+$/, "");
115 if (separator === "\\") {
116 return `${cleanBase}\\${next.replaceAll("/", "\\")}`;
117 }
118 return `${cleanBase}/${next.replace(/^\/+/, "")}`;
119}
120
121function inferHomeFromCwd(cwd: string): string | undefined {
122 const posixUser = cwd.match(/^\/Users\/([^/]+)/);
123 if (posixUser?.[1]) {
124 return `/Users/${posixUser[1]}`;
125 }
126
127 const posixHome = cwd.match(/^\/home\/([^/]+)/);
128 if (posixHome?.[1]) {
129 return `/home/${posixHome[1]}`;
130 }
131
132 const windowsUser = cwd.match(/^([A-Za-z]:\\Users\\[^\\]+)/);
133 if (windowsUser?.[1]) {
134 return windowsUser[1];
135 }
136
137 return undefined;
138}
139
14096export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
14197 const urlMatches = collectMatches(line, "url", URL_PATTERN, []);
14298 const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches);
......@@ -203,22 +159,3 @@ export function isTerminalLinkActivation(
203159 ? event.metaKey && !event.ctrlKey
204160 : event.ctrlKey && !event.metaKey;
205161}
206
207export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
208 const position = splitFilePathPosition(rawPath);
209 const { path } = position;
210
211 let resolvedPath = path;
212 if (path.startsWith("~/")) {
213 const home = inferHomeFromCwd(cwd);
214 if (home) {
215 const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/";
216 resolvedPath = joinPath(home, path.slice(2), separator);
217 }
218 } else if (!isAbsolutePath(path)) {
219 const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/";
220 resolvedPath = joinPath(cwd, path, separator);
221 }
222
223 return formatFilePathPosition({ ...position, path: resolvedPath });
224}
apps/web/src/themePalette.test.ts+69
......@@ -33,6 +33,7 @@ import {
3333 IRIS_THEME,
3434 OCEAN_THEME,
3535 updateCustomTheme,
36 updateThemeColorFamily,
3637 CUSTOM_THEMES_STORAGE_KEY,
3738 createVividThemeColors,
3839 getDefaultThemeColors,
......@@ -90,6 +91,68 @@ describe("theme files", () => {
9091 }
9192 });
9293
94 it("keeps built-in and standard search pairs readable and distinct", () => {
95 const palettes = [
96 getStandardThemeColors("light"),
97 getStandardThemeColors("dark"),
98 ...BUILT_IN_THEMES.flatMap((theme) => [theme.colors, ...Object.values(theme.variants ?? {})]),
99 ];
100 for (const colors of palettes) {
101 expect(
102 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
103 ).toBeGreaterThanOrEqual(4.5);
104 expect(
105 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
106 ).toBeGreaterThanOrEqual(4.5);
107 expect(asHex(colors.searchMatchBackground)).not.toBe(
108 asHex(colors.searchMatchActiveBackground),
109 );
110 }
111 });
112
113 it("round-trips custom search colours and fills them in for older theme files", () => {
114 const legacy = parseThemeFile({
115 version: THEME_FILE_VERSION,
116 name: "Legacy",
117 appearance: "dark",
118 colors: { canvas: "#101010" },
119 });
120 expect(legacy.colors.searchMatchBackground).toBe(
121 getDefaultThemeColors("dark").searchMatchBackground,
122 );
123 const theme = parseThemeFile({
124 version: THEME_FILE_VERSION,
125 name: "Search",
126 appearance: "dark",
127 colors: {
128 searchMatchBackground: "#224466",
129 searchMatchForeground: "#ffffff",
130 searchMatchActiveBackground: "#aaddff",
131 searchMatchActiveForeground: "#112233",
132 },
133 variants: { light: { searchMatchBackground: "#ddeeff", searchMatchForeground: "#112233" } },
134 });
135 const restored = parseThemeFile(JSON.parse(serializeThemeFile(theme)));
136 expect(restored.colors).toEqual(theme.colors);
137 expect(restored.variants).toEqual(theme.variants);
138 });
139
140 it.each(["searchMatchBackground", "searchMatchActiveBackground"] as const)(
141 "editing %s derives readable text without changing the other match or warning colours",
142 (role) => {
143 const original = getDefaultThemeColors("dark");
144 const foreground =
145 role === "searchMatchBackground" ? "searchMatchForeground" : "searchMatchActiveForeground";
146 for (const value of ["#ffffff", "#000000", "#33669980"]) {
147 const updated = updateThemeColorFamily("dark", original, role, value);
148 expect(contrastRatio(updated[foreground], updated[role])).toBeGreaterThanOrEqual(4.5);
149 for (const key of Object.keys(original) as Array<keyof typeof original>) {
150 if (key !== role && key !== foreground) expect(updated[key]).toBe(original[key]);
151 }
152 }
153 },
154 );
155
93156 it("keeps stock dark controls in the neutral-black surface hierarchy", () => {
94157 expectThemeColors(getStandardThemeColors("dark"), {
95158 canvas: "#0a0a0a",
......@@ -155,6 +218,12 @@ describe("theme files", () => {
155218 );
156219 expect(colors.secondaryLabel).toBe(colors.textMuted);
157220 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5);
221 expect(
222 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
223 ).toBeGreaterThanOrEqual(4.5);
224 expect(
225 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
226 ).toBeGreaterThanOrEqual(4.5);
158227 expect(
159228 contrastRatio(colors.messageActionForeground, colors.messageAction),
160229 ).toBeGreaterThanOrEqual(4.5);
apps/web/src/themePalette.ts+24
......@@ -743,6 +743,10 @@ export function createVividThemeColors(
743743 const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6));
744744 const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6));
745745
746 // Keep matches visible even when an imported theme has no accent colour.
747 const searchMatch = themeOklchToRgb(surfaceAt(0.22, Math.min(0.12, accent.C)));
748 const searchMatchActive = themeOklchToRgb(surfaceAt(0.4, Math.min(0.18, accent.C)));
749
746750 const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) };
747751
748752 return {
......@@ -786,6 +790,10 @@ export function createVividThemeColors(
786790 messageActionHover: themeColor(actionHover),
787791 codeBackground: themeColor(codeBackground),
788792 codeForeground: themeRgbToThemeColor(textRgb),
793 searchMatchBackground: themeRgbToThemeColor(searchMatch),
794 searchMatchForeground: themeRgbToThemeColor(readableThemeForeground(searchMatch)),
795 searchMatchActiveBackground: themeRgbToThemeColor(searchMatchActive),
796 searchMatchActiveForeground: themeRgbToThemeColor(readableThemeForeground(searchMatchActive)),
789797 sidebar: themeColor(sidebar),
790798 sidebarForeground: foregroundOn(sidebarRgb),
791799 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)),
......@@ -1005,6 +1013,18 @@ export function updateThemeColorFamily(
10051013 messageSurface: normalized,
10061014 messageForeground: foregroundOn(selectedOnCanvas),
10071015 };
1016 case "searchMatchBackground":
1017 return {
1018 ...colors,
1019 searchMatchBackground: colorOf(selectedOnCanvas),
1020 searchMatchForeground: foregroundOn(selectedOnCanvas),
1021 };
1022 case "searchMatchActiveBackground":
1023 return {
1024 ...colors,
1025 searchMatchActiveBackground: colorOf(selectedOnCanvas),
1026 searchMatchActiveForeground: foregroundOn(selectedOnCanvas),
1027 };
10081028 case "codeBackground":
10091029 return {
10101030 ...colors,
......@@ -1469,6 +1489,10 @@ const APP_THEME_VARIABLES: Readonly<Record<ThemeColorRole, string>> = {
14691489 messageActionHover: "--app-theme-message-action-hover",
14701490 codeBackground: "--app-theme-code-background",
14711491 codeForeground: "--app-theme-code-foreground",
1492 searchMatchBackground: "--app-theme-search-match-background",
1493 searchMatchForeground: "--app-theme-search-match-foreground",
1494 searchMatchActiveBackground: "--app-theme-search-match-active-background",
1495 searchMatchActiveForeground: "--app-theme-search-match-active-foreground",
14721496 sidebar: "--app-theme-sidebar",
14731497 sidebarForeground: "--app-theme-sidebar-foreground",
14741498 sidebarMutedForeground: "--app-theme-sidebar-muted-foreground",
apps/web/src/vscodeThemeImport.test.ts+94
......@@ -292,6 +292,100 @@ describe("VS Code theme import", () => {
292292 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),
293293 ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised));
294294 });
295 it("imports current and other find matches as independent background/text pairs", () => {
296 const { colors } = parseVsCodeThemeFile({
297 type: "dark",
298 colors: {
299 "editor.background": "#202020",
300 "editor.findMatchBackground": "#663399",
301 "editor.findMatchForeground": "#ffffff",
302 "editor.findMatchHighlightBackground": "#bce4ff",
303 "editor.findMatchHighlightForeground": "#17314a",
304 },
305 });
306
307 expect(asHex(colors.searchMatchActiveBackground)).toBe("#663399");
308 expect(asHex(colors.searchMatchActiveForeground)).toBe("#ffffff");
309 expect(asHex(colors.searchMatchBackground)).toBe("#bce4ff");
310 expect(asHex(colors.searchMatchForeground)).toBe("#17314a");
311 });
312
313 it("composites find backgrounds onto the canvas and foregrounds onto their match", () => {
314 const { colors } = parseVsCodeThemeFile({
315 type: "dark",
316 colors: {
317 "editor.background": "#202020",
318 "editor.findMatchBackground": "#33669980",
319 "editor.findMatchForeground": "#ffffffcc",
320 "editor.findMatchHighlightBackground": "#ffcc0040",
321 "editor.findMatchHighlightForeground": "#ffffff",
322 },
323 });
324
325 expect(asHex(colors.searchMatchActiveBackground)).toBe("#2a435d");
326 expect(asHex(colors.searchMatchActiveForeground)).toBe("#d4d9df");
327 expect(asHex(colors.searchMatchBackground)).toBe("#584b18");
328 expect(asHex(colors.searchMatchForeground)).toBe("#ffffff");
329 });
330
331 it("uses editor text when no find foreground is specified", () => {
332 const { colors } = parseVsCodeThemeFile({
333 type: "dark",
334 colors: {
335 "editor.background": "#101010",
336 "editor.foreground": "#eeeeee",
337 "editor.findMatchBackground": "#304050",
338 "editor.findMatchHighlightBackground": "#403020",
339 },
340 });
341
342 expect(asHex(colors.searchMatchActiveForeground)).toBe("#eeeeee");
343 expect(asHex(colors.searchMatchForeground)).toBe("#eeeeee");
344 });
345
346 it.each(["light", "dark"])(
347 "keeps missing or invalid find colours readable in %s themes",
348 (type) => {
349 const base = {
350 "editor.background": type === "dark" ? "#101010" : "#fafafa",
351 };
352 const missing = parseVsCodeThemeFile({ type, colors: base }).colors;
353 const invalid = parseVsCodeThemeFile({
354 type,
355 colors: {
356 ...base,
357 "editor.findMatchBackground": "not-a-colour",
358 "editor.findMatchForeground": 42,
359 "editor.findMatchHighlightBackground": null,
360 "editor.findMatchHighlightForeground": "invalid",
361 },
362 }).colors;
363 const unreadable = parseVsCodeThemeFile({
364 type,
365 colors: {
366 ...base,
367 "editor.findMatchBackground": "#ffffcc",
368 "editor.findMatchForeground": "#ffffff",
369 "editor.findMatchHighlightBackground": "#101020",
370 "editor.findMatchHighlightForeground": "#111111",
371 },
372 }).colors;
373
374 for (const colors of [missing, invalid, unreadable]) {
375 expect(
376 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
377 ).toBeGreaterThanOrEqual(4.5);
378 expect(
379 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
380 ).toBeGreaterThanOrEqual(4.5);
381 }
382 expect(invalid.searchMatchBackground).toBe(missing.searchMatchBackground);
383 expect(invalid.searchMatchActiveBackground).toBe(missing.searchMatchActiveBackground);
384 expect(missing.searchMatchBackground).not.toBe(missing.searchMatchActiveBackground);
385 expect(missing.searchMatchBackground).not.toBe(missing.canvas);
386 expect(missing.searchMatchActiveBackground).not.toBe(missing.canvas);
387 },
388 );
295389
296390 it("fills every role the file omits with a readable derived value", () => {
297391 const theme = parseVsCodeThemeFile(VSCODE_DARK);
apps/web/src/vscodeThemeImport.ts+22
......@@ -321,6 +321,12 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
321321 break;
322322 }
323323 }
324 // Find overlays can cover prose, code, or message bubbles. Flatten them onto
325 // the editor canvas so each imported foreground has one predictable surface.
326 const searchMatchBackground =
327 solidOver(canvas, "editor.findMatchHighlightBackground") ?? derived.searchMatchBackground;
328 const searchMatchActiveBackground =
329 solidOver(canvas, "editor.findMatchBackground") ?? derived.searchMatchActiveBackground;
324330
325331 const overrides: Partial<Record<ThemeColorRole, string>> = {
326332 canvas: canvasHex,
......@@ -346,6 +352,22 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
346352 solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
347353 derived.accentSurface,
348354 codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground,
355 searchMatchBackground,
356 searchMatchForeground: readableOn(
357 searchMatchBackground,
358 derived.searchMatchForeground,
359 "editor.findMatchHighlightForeground",
360 "editor.foreground",
361 "foreground",
362 ),
363 searchMatchActiveBackground,
364 searchMatchActiveForeground: readableOn(
365 searchMatchActiveBackground,
366 derived.searchMatchActiveForeground,
367 "editor.findMatchForeground",
368 "editor.foreground",
369 "foreground",
370 ),
349371 sidebar: sidebarHex,
350372 sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),
351373 sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder,
docs/user/keybindings.md+8
......@@ -121,6 +121,14 @@ Combine keys with `!` for not, `&&` for and, `||` for or, and parentheses:
121121{ "key": "mod+j", "command": "terminal.toggle", "when": "terminalOpen && !terminalFocus" }
122122```
123123
124## Find in the current thread
125
126`chat.find` searches conversation messages and proposed plans in the active thread. Entering a
127query searches the entire thread, including older messages. Thread search requires server support;
128update an older server to enable it. Select **Retry** if a search fails. It defaults to
129`mod+f` outside terminals and previews. Press **Enter** or **Shift+Enter** to move between matches,
130and **Escape** to close find.
131
124132## Precedence
125133
126134The last rule whose key and condition both match wins, even if it belongs to a
packages/client-runtime/package.json+1-23
......@@ -63,10 +63,6 @@
6363 "types": "./src/markdownImages.ts",
6464 "default": "./src/markdownImages.ts"
6565 },
66 "./markdown-links": {
67 "types": "./src/markdownLinks.ts",
68 "default": "./src/markdownLinks.ts"
69 },
7066 "./media-reference": {
7167 "types": "./src/mediaReference.ts",
7268 "default": "./src/mediaReference.ts"
......@@ -79,18 +75,6 @@
7975 "types": "./src/mediaActions.ts",
8076 "default": "./src/mediaActions.ts"
8177 },
82 "./codex-file-citations": {
83 "types": "./src/codexFileCitations.ts",
84 "default": "./src/codexFileCitations.ts"
85 },
86 "./codex-artifact-templates": {
87 "types": "./src/codexArtifactTemplates.ts",
88 "default": "./src/codexArtifactTemplates.ts"
89 },
90 "./codex-markdown-directives": {
91 "types": "./src/codexMarkdownDirectives.ts",
92 "default": "./src/codexMarkdownDirectives.ts"
93 },
9478 "./errors": {
9579 "types": "./src/errors/index.ts",
9680 "default": "./src/errors/index.ts"
......@@ -412,17 +396,11 @@
412396 "@t3tools/contracts": "workspace:*",
413397 "@t3tools/shared": "workspace:*",
414398 "effect": "catalog:",
415 "mdast-util-directive": "^3.1.0",
416 "micromark-extension-directive": "^4.0.0",
417 "micromark-util-character": "^2.1.1",
418 "remark-parse": "^11.0.0",
419 "three": "0.180.0",
420 "unified": "^11.0.5"
399 "three": "0.180.0"
421400 },
422401 "devDependencies": {
423402 "@effect/vitest": "catalog:",
424403 "@types/three": "0.180.0",
425 "micromark-util-types": "^2.0.2",
426404 "vite-plus": "catalog:"
427405 }
428406}
packages/client-runtime/src/codexArtifactTemplates.test.ts deleted-119
......@@ -1,119 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 appendCodexArtifactTemplateUsePrompt,
5 codexArtifactTemplatePresentationLabel,
6 codexArtifactTemplateUsePrompt,
7 resolveCodexArtifactTemplate,
8 type CodexArtifactTemplate,
9} from "./codexArtifactTemplates.js";
10
11const HELLO_WORLD_TEMPLATE: CodexArtifactTemplate = {
12 artifactKind: "document",
13 displayName: "Hello World",
14 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
15 skillName: "artifact-template-hello-world",
16};
17
18describe("artifact template presentation", () => {
19 it("shares labels and copy text across clients", () => {
20 expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template");
21 });
22});
23
24describe("resolveCodexArtifactTemplate", () => {
25 it("accepts the template metadata emitted by Codex", () => {
26 expect(
27 resolveCodexArtifactTemplate({
28 artifact_kind: "document",
29 display_name: " Hello World ",
30 skill_directory: "/Users/test/.codex/skills/artifact-template-hello-world",
31 skill_name: "artifact-template-hello-world",
32 }),
33 ).toEqual(HELLO_WORLD_TEMPLATE);
34 });
35
36 it.each([
37 String.raw`C:\Users\test\.codex\skills\artifact-template-hello-world`,
38 String.raw`\\server\share\artifact-template-hello-world`,
39 "//server/share/artifact-template-hello-world",
40 ])("accepts absolute Windows skill directories: %s", (skillDirectory) => {
41 expect(
42 resolveCodexArtifactTemplate({
43 artifact_kind: "image",
44 display_name: "Reference image",
45 gallery_kind: "product-design",
46 skill_directory: skillDirectory,
47 skill_name: "artifact-template-reference-image",
48 }),
49 ).toMatchObject({
50 artifactKind: "image",
51 galleryKind: "product-design",
52 skillDirectory,
53 });
54 });
55
56 it.each([
57 { artifact_kind: "unknown" },
58 { display_name: " " },
59 { skill_directory: "relative/template" },
60 { skill_name: "hello-world" },
61 { gallery_kind: null },
62 { gallery_kind: "unknown" },
63 ])("rejects malformed template metadata: %o", (override) => {
64 expect(
65 resolveCodexArtifactTemplate({
66 artifact_kind: "document",
67 display_name: "Hello World",
68 skill_directory: "/templates/hello-world",
69 skill_name: "artifact-template-hello-world",
70 ...override,
71 }),
72 ).toBeNull();
73 });
74});
75
76describe("codexArtifactTemplateUsePrompt", () => {
77 it("builds the same document follow-up shape as Codex", () => {
78 expect(codexArtifactTemplateUsePrompt(HELLO_WORLD_TEMPLATE)).toBe(
79 "Create a document using this $artifact-template-hello-world about…",
80 );
81 });
82
83 it("uses the artifact-specific image wording", () => {
84 expect(
85 codexArtifactTemplateUsePrompt({
86 ...HELLO_WORLD_TEMPLATE,
87 artifactKind: "image",
88 }),
89 ).toBe("Create an image using this $artifact-template-hello-world of…");
90 });
91});
92
93describe("appendCodexArtifactTemplateUsePrompt", () => {
94 const prompt = "Create a document using this $artifact-template-hello-world about…";
95
96 it("adds the prompt to an empty draft", () => {
97 expect(appendCodexArtifactTemplateUsePrompt("", HELLO_WORLD_TEMPLATE)).toBe(prompt);
98 });
99
100 it("preserves existing draft text", () => {
101 expect(appendCodexArtifactTemplateUsePrompt("Write about otters", HELLO_WORLD_TEMPLATE)).toBe(
102 `Write about otters ${prompt}`,
103 );
104 });
105
106 it.each([prompt, `${prompt}\n`, `Notes\n\n${prompt}`])(
107 "does not append the same final prompt twice: %s",
108 (draft) => {
109 expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(draft);
110 },
111 );
112
113 it("does not mistake text containing the prompt for a final prompt", () => {
114 const draft = `${prompt}\nAdditional instructions`;
115 expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(
116 `${draft} ${prompt}`,
117 );
118 });
119});
packages/client-runtime/src/codexArtifactTemplates.ts deleted-136
......@@ -1,136 +0,0 @@
1const CODEX_ARTIFACT_TEMPLATE_KINDS = [
2 "document",
3 "presentation",
4 "spreadsheet",
5 "site",
6 "google-docs",
7 "google-slides",
8 "google-sheets",
9 "image",
10 "email",
11 "slack",
12] as const;
13
14export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number];
15
16const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const;
17
18export type CodexArtifactTemplateGalleryKind =
19 (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number];
20
21export interface CodexArtifactTemplate {
22 readonly artifactKind: CodexArtifactTemplateKind;
23 readonly displayName: string;
24 readonly galleryKind?: CodexArtifactTemplateGalleryKind;
25 readonly skillDirectory: string;
26 readonly skillName: string;
27}
28
29const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = {
30 document: "Document template",
31 presentation: "Presentation template",
32 spreadsheet: "Spreadsheet template",
33 site: "Site template",
34 "google-docs": "Google Doc template",
35 "google-slides": "Google Slides template",
36 "google-sheets": "Google Sheet template",
37 image: "Image template",
38 email: "Email template",
39 slack: "Slack template",
40} satisfies Record<CodexArtifactTemplateKind, string>;
41
42export type CodexArtifactTemplateAttributes = Readonly<Record<string, string | null | undefined>>;
43
44const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
45const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/;
46
47function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind {
48 return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value);
49}
50
51function isCodexArtifactTemplateGalleryKind(
52 value: unknown,
53): value is CodexArtifactTemplateGalleryKind {
54 return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value);
55}
56
57function isAbsoluteSkillDirectory(value: string): boolean {
58 return (
59 (value.startsWith("/") && !value.startsWith("//")) ||
60 WINDOWS_DRIVE_PATH_REGEX.test(value) ||
61 WINDOWS_UNC_PATH_REGEX.test(value)
62 );
63}
64
65/** Mirrors the Codex result-card schema so malformed directives remain literal Markdown. */
66export function resolveCodexArtifactTemplate(
67 attributes: CodexArtifactTemplateAttributes | null | undefined,
68): CodexArtifactTemplate | null {
69 const artifactKind = attributes?.artifact_kind;
70 const displayNameValue = attributes?.display_name;
71 const displayName = typeof displayNameValue === "string" ? displayNameValue.trim() : undefined;
72 const galleryKind = attributes?.gallery_kind;
73 const skillDirectory = attributes?.skill_directory;
74 const skillName = attributes?.skill_name;
75
76 if (
77 !isCodexArtifactTemplateKind(artifactKind) ||
78 !displayName ||
79 typeof skillDirectory !== "string" ||
80 !isAbsoluteSkillDirectory(skillDirectory) ||
81 typeof skillName !== "string" ||
82 !skillName.startsWith("artifact-template-") ||
83 (galleryKind !== undefined && !isCodexArtifactTemplateGalleryKind(galleryKind))
84 ) {
85 return null;
86 }
87
88 return {
89 artifactKind,
90 displayName,
91 ...(galleryKind === undefined ? {} : { galleryKind }),
92 skillDirectory,
93 skillName,
94 };
95}
96
97const USE_PROMPT_BY_KIND: Record<CodexArtifactTemplateKind, (skill: string) => string> = {
98 document: (skill) => `Create a document using this ${skill} about…`,
99 presentation: (skill) => `Create a presentation using the ${skill} template about…`,
100 spreadsheet: (skill) => `Create a spreadsheet using this ${skill} about…`,
101 site: (skill) => `Create a Site using this ${skill} about…`,
102 "google-docs": (skill) => `Create a Google Doc using this ${skill} about…`,
103 "google-slides": (skill) => `Create a Google Slides presentation using this ${skill} about…`,
104 "google-sheets": (skill) => `Create a Google Sheet using this ${skill} about…`,
105 image: (skill) => `Create an image using this ${skill} of…`,
106 email: (skill) => `Draft an email using this ${skill} about…`,
107 slack: (skill) => `Draft a Slack message using this ${skill} about…`,
108};
109
110export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string {
111 return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`);
112}
113
114export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string {
115 return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind];
116}
117
118export function appendCodexArtifactTemplateUsePrompt(
119 draft: string,
120 template: CodexArtifactTemplate,
121): string {
122 const prompt = codexArtifactTemplateUsePrompt(template);
123 const trimmedDraft = draft.trimEnd();
124 const promptStart = trimmedDraft.length - prompt.length;
125 const alreadyEndsWithPrompt =
126 promptStart >= 0 &&
127 trimmedDraft.slice(promptStart) === prompt &&
128 (promptStart === 0 || /\s/.test(trimmedDraft[promptStart - 1] ?? ""));
129
130 if (alreadyEndsWithPrompt) {
131 return draft;
132 }
133
134 const needsLeadingSpace = draft.length > 0 && !/\s$/.test(draft);
135 return `${draft}${needsLeadingSpace ? " " : ""}${prompt}`;
136}
packages/client-runtime/src/codexFileCitations.test.ts deleted-77
......@@ -1,77 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.js";
4
5describe("resolveCodexFileCitationLink", () => {
6 it("resolves the attributes emitted by Codex", () => {
7 expect(
8 resolveCodexFileCitationLink({
9 path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
10 purpose: "output",
11 }),
12 ).toEqual({
13 path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
14 href: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
15 label: "issue-2387-sparse-diagonal.xlsx",
16 });
17 });
18
19 it("carries the first cited line into the file href", () => {
20 expect(
21 resolveCodexFileCitationLink({
22 path: "src/main.ts",
23 line_range_start: "42",
24 line_range_end: "48",
25 git_url: "https://example.com/main.ts",
26 }),
27 ).toEqual({
28 path: "src/main.ts",
29 href: "src/main.ts#L42",
30 label: "main.ts",
31 lineRangeStart: 42,
32 });
33 });
34
35 it("rejects missing paths and invalid line numbers", () => {
36 expect(resolveCodexFileCitationLink({ purpose: "output" })).toBeNull();
37 expect(
38 resolveCodexFileCitationLink({ path: "src/main.ts", line_range_start: "not-a-line" }),
39 ).toEqual({
40 path: "src/main.ts",
41 href: "src/main.ts",
42 label: "main.ts",
43 });
44 });
45
46 it("preserves URL syntax characters in file paths", () => {
47 expect(
48 resolveCodexFileCitationLink({
49 path: "reports/100% #1? draft.md",
50 line_range_start: "7",
51 }),
52 ).toEqual({
53 path: "reports/100% #1? draft.md",
54 href: "reports/100%25 %231%3F draft.md#L7",
55 label: "100% #1? draft.md",
56 lineRangeStart: 7,
57 });
58 });
59});
60
61describe("codexFileCitationMarkdown", () => {
62 it("produces a portable Markdown link", () => {
63 const citation = resolveCodexFileCitationLink({ path: "reports/profit and loss.xlsx" });
64 expect(citation && codexFileCitationMarkdown(citation)).toBe(
65 "[profit and loss.xlsx](<reports/profit and loss.xlsx>)",
66 );
67 });
68
69 it("escapes Markdown syntax in the visible filename", () => {
70 const citation = resolveCodexFileCitationLink({
71 path: "reports/*draft*_[copy]`<&.txt",
72 });
73 expect(citation && codexFileCitationMarkdown(citation)).toBe(
74 "[\\*draft\\*\\_\\[copy\\]\\`\\<\\&.txt](<reports/*draft*_[copy]`%3C&.txt>)",
75 );
76 });
77});
packages/client-runtime/src/codexFileCitations.ts deleted-56
......@@ -1,56 +0,0 @@
1export interface CodexFileCitationLink {
2 readonly path: string;
3 readonly href: string;
4 readonly label: string;
5 readonly lineRangeStart?: number;
6}
7
8export type CodexFileCitationAttributes = Readonly<Record<string, string | null | undefined>>;
9
10function positiveInteger(value: string | null | undefined): number | undefined {
11 if (value === null || value === undefined || value.trim().length === 0) return undefined;
12 const parsed = Number(value);
13 return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
14}
15
16function citationLabel(path: string): string {
17 const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
18 return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File";
19}
20
21function markdownDestinationPath(path: string): string {
22 return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F");
23}
24
25export function resolveCodexFileCitationLink(
26 attributes: CodexFileCitationAttributes | null | undefined,
27): CodexFileCitationLink | null {
28 const path = attributes?.path?.trim();
29 if (!path) return null;
30
31 const lineRangeStart = positiveInteger(attributes?.line_range_start);
32 const destinationPath = markdownDestinationPath(path);
33 return {
34 path,
35 href: lineRangeStart === undefined ? destinationPath : `${destinationPath}#L${lineRangeStart}`,
36 label: citationLabel(path),
37 ...(lineRangeStart === undefined ? {} : { lineRangeStart }),
38 };
39}
40
41function markdownLabel(value: string): string {
42 return value.replace(/[\\[\]*_`<&]/g, "\\$&");
43}
44
45function markdownDestination(value: string): string {
46 return value
47 .replaceAll("\\", "%5C")
48 .replaceAll("<", "%3C")
49 .replaceAll(">", "%3E")
50 .replaceAll("\r", "%0D")
51 .replaceAll("\n", "%0A");
52}
53
54export function codexFileCitationMarkdown(citation: CodexFileCitationLink): string {
55 return `[${markdownLabel(citation.label)}](<${markdownDestination(citation.href)}>)`;
56}
packages/client-runtime/src/codexMarkdownDirectives.test.ts deleted-175
......@@ -1,175 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2import remarkParse from "remark-parse";
3import { unified } from "unified";
4
5import {
6 remarkCodexDirectives,
7 renderCodexDirectivesForCopy,
8 renderCodexFileCitationsAsMarkdown,
9 splitCodexArtifactTemplateMarkdown,
10} from "./codexMarkdownDirectives.js";
11import { parseMarkdownFileLink } from "./markdownLinks.js";
12
13interface TestNode {
14 readonly type: string;
15 readonly value?: string;
16 readonly url?: string;
17 readonly position?: {
18 readonly start: { readonly offset?: number };
19 readonly end: { readonly offset?: number };
20 };
21 readonly data?: {
22 readonly hName?: string;
23 readonly hProperties?: Readonly<Record<string, unknown>>;
24 };
25 readonly children?: readonly TestNode[];
26}
27
28const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}';
29const ARTIFACT_TEMPLATE =
30 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
31
32function parse(markdown: string): TestNode {
33 const processor = unified().use(remarkParse).use(remarkCodexDirectives);
34 return processor.runSync(processor.parse(markdown), { value: markdown }) as TestNode;
35}
36
37function parseOrdinaryMarkdown(markdown: string): TestNode {
38 return unified().use(remarkParse).parse(markdown) as TestNode;
39}
40
41describe("remarkCodexDirectives", () => {
42 it("renders a file citation as a link without changing its source position", () => {
43 const markdown = `Created ${FILE_CITATION}.`;
44 const link = parse(markdown).children?.[0]?.children?.[1];
45
46 expect(link).toMatchObject({
47 type: "link",
48 url: "outputs/report.xlsx",
49 children: [{ type: "text", value: "report.xlsx" }],
50 position: {
51 start: { offset: markdown.indexOf(FILE_CITATION) },
52 end: { offset: markdown.indexOf(FILE_CITATION) + FILE_CITATION.length },
53 },
54 });
55 });
56
57 it("renders an artifact template as semantic block metadata", () => {
58 expect(parse(ARTIFACT_TEMPLATE).children?.[0]).toMatchObject({
59 type: "paragraph",
60 children: [],
61 data: {
62 hName: "div",
63 hProperties: {
64 dataCodexArtifactTemplate: "true",
65 dataArtifactKind: "document",
66 dataDisplayName: "Hello World",
67 dataSkillName: "artifact-template-hello-world",
68 },
69 },
70 });
71 });
72
73 it.each([
74 "Meeting at 10:30",
75 "Open src/main.ts:42",
76 "Use :hover and :tada:",
77 "::note",
78 ":::note\ncontent\n:::",
79 ':codex-file-citation-extra{path="outputs/report.xlsx"}',
80 "::artifact-template-extra",
81 ])("does not change unrelated colon syntax: %s", (markdown) => {
82 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
83 });
84
85 it.each([
86 ':codex-file-citation{purpose="output"}',
87 '::artifact-template{skill_name="artifact-template-hello-world"}',
88 ])("keeps malformed supported directives literal: %s", (markdown) => {
89 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
90 });
91});
92
93describe.each([
94 { name: "renderCodexDirectivesForCopy", render: renderCodexDirectivesForCopy },
95 { name: "renderCodexFileCitationsAsMarkdown", render: renderCodexFileCitationsAsMarkdown },
96])("$name file citation round trips", ({ render }) => {
97 it.each([
98 "C:\\Users\\test\\[draft]\\report.md",
99 "\\\\server\\share\\report.md",
100 "outputs/report.md",
101 "/tmp/report%5C.md",
102 ])("preserves the literal path and line: %s", (path) => {
103 const markdown = render(`:codex-file-citation{path="${path}" line_range_start="7"}`);
104 const link = parseOrdinaryMarkdown(markdown).children?.[0]?.children?.[0];
105
106 expect(link?.type).toBe("link");
107 expect(parseMarkdownFileLink(link?.url ?? "")).toEqual({ path, line: 7 });
108 });
109});
110
111describe("native Markdown adapters", () => {
112 it("uses the same parser to render file citations as portable links", () => {
113 expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe(
114 "Created [report.xlsx](<outputs/report.xlsx>).",
115 );
116 });
117
118 it.each([
119 `\\${FILE_CITATION}`,
120 `\`${FILE_CITATION}\``,
121 `\`\`\`text\n${FILE_CITATION}\n\`\`\``,
122 `[See ${FILE_CITATION}](https://example.com)`,
123 ])("does not render excluded citation syntax: %s", (markdown) => {
124 expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown);
125 });
126
127 it("splits artifact cards from surrounding native Markdown", () => {
128 expect(splitCodexArtifactTemplateMarkdown(`Before\n\n${ARTIFACT_TEMPLATE}\n\nAfter`)).toEqual([
129 { kind: "markdown", markdown: "Before\n\n", sourceOffset: 0 },
130 {
131 kind: "artifact-template",
132 sourceOffset: 8,
133 template: {
134 artifactKind: "document",
135 displayName: "Hello World",
136 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
137 skillName: "artifact-template-hello-world",
138 },
139 },
140 {
141 kind: "markdown",
142 markdown: "\n\nAfter",
143 sourceOffset: 8 + ARTIFACT_TEMPLATE.length,
144 },
145 ]);
146 });
147
148 it("leaves malformed and code artifact-template examples in Markdown", () => {
149 const malformed = '::artifact-template{display_name="Hello World"}';
150 const code = `\`${ARTIFACT_TEMPLATE}\``;
151 expect(splitCodexArtifactTemplateMarkdown(malformed)).toEqual([
152 { kind: "markdown", markdown: malformed, sourceOffset: 0 },
153 ]);
154 expect(splitCodexArtifactTemplateMarkdown(code)).toEqual([
155 { kind: "markdown", markdown: code, sourceOffset: 0 },
156 ]);
157 });
158});
159
160describe("directive copy adapter", () => {
161 it("copies the Markdown representations shown by citation chips and template cards", () => {
162 expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe(
163 "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)",
164 );
165 });
166
167 it("leaves excluded and malformed directive source unchanged", () => {
168 const markdown = [
169 `\`${FILE_CITATION}\``,
170 '::artifact-template{display_name="Hello World"}',
171 ].join("\n\n");
172
173 expect(renderCodexDirectivesForCopy(markdown)).toBe(markdown);
174 });
175});
packages/client-runtime/src/codexMarkdownDirectives.ts deleted-388
......@@ -1,388 +0,0 @@
1import { directiveFromMarkdown } from "mdast-util-directive";
2import { directive } from "micromark-extension-directive";
3import {
4 markdownLineEnding,
5 unicodePunctuation,
6 unicodeWhitespace,
7} from "micromark-util-character";
8import type { Construct, Extension, Tokenizer } from "micromark-util-types";
9import remarkParse from "remark-parse";
10import { unified, type Processor } from "unified";
11
12import {
13 codexArtifactTemplatePresentationLabel,
14 resolveCodexArtifactTemplate,
15 type CodexArtifactTemplate,
16} from "./codexArtifactTemplates.ts";
17import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts";
18
19const COLON = 58;
20const DASH = 45;
21const UNDERSCORE = 95;
22const CODEX_FILE_CITATION_NAME = "codex-file-citation";
23const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template";
24
25export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [
26 "dataCodexArtifactTemplate",
27 "dataArtifactKind",
28 "dataDisplayName",
29 "dataGalleryKind",
30 "dataSkillDirectory",
31 "dataSkillName",
32] as const;
33
34interface MarkdownPosition {
35 readonly start: { readonly offset?: number };
36 readonly end: { readonly offset?: number };
37}
38
39interface MarkdownAstNode {
40 type?: string;
41 name?: string;
42 value?: string;
43 url?: string;
44 attributes?: Readonly<Record<string, string | null>>;
45 position?: MarkdownPosition;
46 data?: {
47 codexArtifactTemplate?: CodexArtifactTemplate;
48 codexFileCitationMarkdown?: string;
49 hName?: string;
50 hProperties?: Record<string, unknown>;
51 };
52 children?: MarkdownAstNode[];
53}
54
55interface MarkdownFile {
56 readonly value: unknown;
57}
58
59export type CodexArtifactTemplateMarkdownSegment =
60 | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number }
61 | {
62 readonly kind: "artifact-template";
63 readonly sourceOffset: number;
64 readonly template: CodexArtifactTemplate;
65 };
66
67function asConstruct(value: Construct | Construct[] | undefined, label: string): Construct {
68 const construct = Array.isArray(value) ? value[0] : value;
69 if (!construct) throw new Error(`Missing ${label} directive construct`);
70 return construct;
71}
72
73function directiveNameEnds(code: number | null): boolean {
74 return (
75 code === null ||
76 markdownLineEnding(code) ||
77 unicodeWhitespace(code) ||
78 (unicodePunctuation(code) && code !== DASH && code !== UNDERSCORE)
79 );
80}
81
82function directiveNameGate(markerCount: number, name: string): Construct {
83 const tokenize: Tokenizer = (effects, ok, nok) => {
84 let markerIndex = 0;
85 let nameIndex = 0;
86
87 return marker;
88
89 function marker(code: number | null) {
90 if (code !== COLON) return nok(code);
91 if (markerIndex === 0) effects.enter("data");
92 effects.consume(code);
93 markerIndex += 1;
94 return markerIndex === markerCount ? nameCharacter : marker;
95 }
96
97 function nameCharacter(code: number | null) {
98 if (code !== name.charCodeAt(nameIndex)) return nok(code);
99 effects.consume(code);
100 nameIndex += 1;
101 return nameIndex === name.length ? afterName : nameCharacter;
102 }
103
104 function afterName(code: number | null) {
105 effects.exit("data");
106 return directiveNameEnds(code) ? ok(code) : nok(code);
107 }
108 };
109
110 return { partial: true, tokenize };
111}
112
113function restrictedDirective(construct: Construct, markerCount: number, name: string): Construct {
114 const gate = directiveNameGate(markerCount, name);
115 return {
116 ...construct,
117 tokenize(effects, ok, nok) {
118 return effects.check(gate, construct.tokenize.call(this, effects, ok, nok), nok);
119 },
120 };
121}
122
123function codexDirectiveSyntax(): Extension {
124 const genericSyntax = directive();
125 const textDirective = asConstruct(genericSyntax.text?.[COLON], CODEX_FILE_CITATION_NAME);
126 const flowDirectives = genericSyntax.flow?.[COLON];
127 const leafDirective = Array.isArray(flowDirectives)
128 ? flowDirectives.find((construct) => construct.concrete !== true)
129 : flowDirectives;
130 if (!leafDirective)
131 throw new Error(`Missing ${CODEX_ARTIFACT_TEMPLATE_NAME} directive construct`);
132
133 return {
134 text: {
135 [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME),
136 },
137 flow: {
138 [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME),
139 },
140 };
141}
142
143const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax();
144const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown();
145
146function sourceForNode(node: MarkdownAstNode, source: string): string {
147 const start = node.position?.start.offset;
148 const end = node.position?.end.offset;
149 return start === undefined || end === undefined ? "" : source.slice(start, end);
150}
151
152function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string {
153 const prefix = `${marker}${node.name ?? ""}`;
154 const slicedSource = sourceForNode(node, source);
155 if (slicedSource.startsWith(prefix)) return slicedSource;
156
157 const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) =>
158 value === null ? name : `${name}=${JSON.stringify(value)}`,
159 );
160 return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`;
161}
162
163function restoreTextDirective(node: MarkdownAstNode, source: string): void {
164 node.type = "text";
165 node.value = sourceForDirective(node, source, ":");
166 delete node.name;
167 delete node.attributes;
168 delete node.url;
169 delete node.data;
170 delete node.children;
171}
172
173function restoreLeafDirective(node: MarkdownAstNode, source: string): void {
174 const value = sourceForDirective(node, source, "::");
175 node.type = "paragraph";
176 node.children = [
177 { type: "text", value, ...(node.position === undefined ? {} : { position: node.position }) },
178 ];
179 delete node.name;
180 delete node.attributes;
181 delete node.value;
182 delete node.url;
183 delete node.data;
184}
185
186function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: boolean): void {
187 const citation = resolveCodexFileCitationLink(node.attributes);
188 if (!citation || insideLink) {
189 restoreTextDirective(node, source);
190 return;
191 }
192
193 node.type = "link";
194 node.url = citation.href;
195 node.children = [{ type: "text", value: citation.label }];
196 node.data = { codexFileCitationMarkdown: codexFileCitationMarkdown(citation) };
197 delete node.name;
198 delete node.attributes;
199 delete node.value;
200}
201
202function renderArtifactTemplate(node: MarkdownAstNode, source: string): void {
203 const template = resolveCodexArtifactTemplate(node.attributes);
204 if (!template) {
205 restoreLeafDirective(node, source);
206 return;
207 }
208
209 node.type = "paragraph";
210 node.children = [];
211 node.data = {
212 codexArtifactTemplate: template,
213 hName: "div",
214 hProperties: {
215 dataCodexArtifactTemplate: "true",
216 dataArtifactKind: template.artifactKind,
217 dataDisplayName: template.displayName,
218 ...(template.galleryKind === undefined ? {} : { dataGalleryKind: template.galleryKind }),
219 dataSkillDirectory: template.skillDirectory,
220 dataSkillName: template.skillName,
221 },
222 };
223 delete node.name;
224 delete node.attributes;
225 delete node.value;
226 delete node.url;
227}
228
229function transformCodexDirectives(node: MarkdownAstNode, source: string, insideLink = false): void {
230 if (node.type === "textDirective" && node.name === CODEX_FILE_CITATION_NAME) {
231 renderFileCitation(node, source, insideLink);
232 return;
233 }
234 if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) {
235 renderArtifactTemplate(node, source);
236 return;
237 }
238
239 const childrenInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
240 for (const child of node.children ?? []) {
241 transformCodexDirectives(child, source, childrenInsideLink);
242 }
243}
244
245/** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */
246function attachCodexDirectives(this: Processor) {
247 const data = this.data();
248 const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []);
249 const fromMarkdownExtensions = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = []);
250 micromarkExtensions.push(CODEX_DIRECTIVE_SYNTAX);
251 fromMarkdownExtensions.push(CODEX_DIRECTIVE_FROM_MARKDOWN);
252
253 return (tree: unknown, file: MarkdownFile) => {
254 transformCodexDirectives(tree as MarkdownAstNode, String(file.value));
255 };
256}
257
258export const remarkCodexDirectives = attachCodexDirectives;
259
260const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze();
261
262function parseCodexMarkdown(markdown: string): MarkdownAstNode {
263 return directiveParser.runSync(directiveParser.parse(markdown), {
264 value: markdown,
265 }) as MarkdownAstNode;
266}
267
268interface DirectiveMatch {
269 readonly start: number;
270 readonly end: number;
271 readonly markdown?: string;
272 readonly template?: CodexArtifactTemplate;
273}
274
275function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void {
276 const start = node.position?.start.offset;
277 const end = node.position?.end.offset;
278 if (start !== undefined && end !== undefined) {
279 if (node.data?.codexFileCitationMarkdown !== undefined) {
280 matches.push({ start, end, markdown: node.data.codexFileCitationMarkdown });
281 return;
282 }
283 if (node.data?.codexArtifactTemplate !== undefined) {
284 matches.push({ start, end, template: node.data.codexArtifactTemplate });
285 return;
286 }
287 }
288 for (const child of node.children ?? []) collectDirectiveMatches(child, matches);
289}
290
291function renderDirectiveMatches(
292 markdown: string,
293 replacementFor: (match: DirectiveMatch) => string | undefined,
294): string {
295 const matches: DirectiveMatch[] = [];
296 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
297 let rendered = markdown;
298 for (const match of matches.sort((left, right) => right.start - left.start)) {
299 const replacement = replacementFor(match);
300 if (replacement !== undefined) {
301 rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end);
302 }
303 }
304 return rendered;
305}
306
307/** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */
308export function renderCodexFileCitationsAsMarkdown(markdown: string): string {
309 if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown;
310
311 return renderDirectiveMatches(markdown, (match) => match.markdown);
312}
313
314/** Matches the Markdown emitted when users copy rendered Codex directive UI. */
315export function renderCodexDirectivesForCopy(markdown: string): string {
316 if (
317 !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) &&
318 !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)
319 ) {
320 return markdown;
321 }
322
323 return renderDirectiveMatches(markdown, (match) => {
324 if (match.markdown !== undefined) return match.markdown;
325 if (match.template === undefined) return undefined;
326 return `${match.template.displayName} (${codexArtifactTemplatePresentationLabel(match.template.artifactKind)})`;
327 });
328}
329
330/** Native renderers split cards out because they cannot host a view inside Markdown text. */
331export function splitCodexArtifactTemplateMarkdown(
332 markdown: string,
333): ReadonlyArray<CodexArtifactTemplateMarkdownSegment> {
334 if (!markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)) {
335 return [{ kind: "markdown", markdown, sourceOffset: 0 }];
336 }
337
338 const matches: DirectiveMatch[] = [];
339 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
340 const templates = matches
341 .filter(
342 (match): match is DirectiveMatch & { readonly template: CodexArtifactTemplate } =>
343 match.template !== undefined,
344 )
345 .sort((left, right) => left.start - right.start);
346 if (templates.length === 0) {
347 return [{ kind: "markdown", markdown, sourceOffset: 0 }];
348 }
349
350 const segments: CodexArtifactTemplateMarkdownSegment[] = [];
351 let cursor = 0;
352 for (const match of templates) {
353 if (match.start > cursor) {
354 segments.push({
355 kind: "markdown",
356 markdown: markdown.slice(cursor, match.start),
357 sourceOffset: cursor,
358 });
359 }
360 segments.push({
361 kind: "artifact-template",
362 sourceOffset: match.start,
363 template: match.template,
364 });
365 cursor = match.end;
366 }
367 if (cursor < markdown.length) {
368 segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor });
369 }
370 return segments;
371}
372
373export function artifactTemplateFromHastProperties(
374 properties: Readonly<Record<string, unknown>> | null | undefined,
375): CodexArtifactTemplate | null {
376 if (properties?.dataCodexArtifactTemplate !== "true") return null;
377 const stringProperty = (name: string) => {
378 const value = properties[name];
379 return typeof value === "string" ? value : undefined;
380 };
381 return resolveCodexArtifactTemplate({
382 artifact_kind: stringProperty("dataArtifactKind"),
383 display_name: stringProperty("dataDisplayName"),
384 gallery_kind: stringProperty("dataGalleryKind"),
385 skill_directory: stringProperty("dataSkillDirectory"),
386 skill_name: stringProperty("dataSkillName"),
387 });
388}
packages/client-runtime/src/markdownImages.ts+3-4
......@@ -1,12 +1,11 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
1import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "@t3tools/shared/path";
22
33import {
44 normalizeMarkdownLinkDestination,
5 parseFileUrlHref,
65 safeDecodeURIComponent,
76 splitMarkdownLinkSearchAndHash,
8 stripSlashPrefixedWindowsDrive,
9} from "./markdownLinks.ts";
7} from "@t3tools/shared/markdownLinks";
8import { parseFileUrlHref } from "@t3tools/shared/fileLinks";
109
1110const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i;
1211const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;
packages/client-runtime/src/markdownLinks.test.ts deleted-208
......@@ -1,208 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 fileBasename,
5 inlineCodeFilePathCandidate,
6 isMarkdownFileLinkLabel,
7 parseFileUrlHref,
8 parseMarkdownFileLink,
9 splitFilePathPosition,
10 workspaceRelativeFilePath,
11} from "./markdownLinks.ts";
12
13describe("isMarkdownFileLinkLabel", () => {
14 it.each([
15 ["validates the input", "/repo/src/example.ts:12", false],
16 ["read src/example.ts", "/repo/src/example.ts:12", false],
17 ["example.ts?why this matters", "/repo/src/example.ts", false],
18 ["example.ts", "/repo/src/example.ts:12", true],
19 ["example.ts:12", "/repo/src/example.ts:12", true],
20 ["example.ts:99", "/repo/src/example.ts:12", false],
21 ["example.ts:12:2", "/repo/src/example.ts:12:2", true],
22 ["example.ts:12:3", "/repo/src/example.ts:12:2", false],
23 ["example.ts:12", "/repo/src/example.ts", false],
24 ["src/example.ts:12", "/repo/src/example.ts:12", true],
25 ["./src/example.ts", "/repo/src/example.ts", true],
26 ["/repo/src/example.ts", "/repo/src/example.ts", true],
27 ["src/", "/home/me/project/src/", true],
28 ["EXAMPLE.TS", "C:/repo/src/example.ts:12", true],
29 ["file name.ts", "file:///repo/file%20name.ts", true],
30 ["", "/repo/src/example.ts", true],
31 ])("classifies %s for %s", (label, href, expected) => {
32 expect(isMarkdownFileLinkLabel(label, href)).toBe(expected);
33 });
34});
35
36describe("inlineCodeFilePathCandidate", () => {
37 it.each([
38 ["src\\main.ts", "src/main.ts"],
39 ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"],
40 ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
41 ["conf.d/nginx.conf", "conf.d/nginx.conf"],
42 ["script.pl:10", "script.pl:10"],
43 ["node.meta", null],
44 ["Recorded evidence here: /tmp/image.png", null],
45 ["origin/main", null],
46 ["127.0.0.1:3000", null],
47 ["example.com/index.html", null],
48 ["example.pl/index.html", null],
49 ["z-ai/glm-5.3", null],
50 ["z-ai/glm-5.3:12", null],
51 ["python/3.12", null],
52 ["Qwen/Qwen2.5-Coder", null],
53 ["meta-llama/Llama-3.1-8B", null],
54 ["share/man/ls.1", "share/man/ls.1"],
55 ["usr/lib/libfoo.so.1", "usr/lib/libfoo.so.1"],
56 ["vendor/jquery-3.6.0.min.js", "vendor/jquery-3.6.0.min.js"],
57 ["./models/glm-5.3", "./models/glm-5.3"],
58 ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => {
59 expect(inlineCodeFilePathCandidate(source)).toBe(candidate);
60 });
61});
62
63describe("parseFileUrlHref", () => {
64 it.each([
65 ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"],
66 [
67 "file:///D:/Programme/t3code/OpenInPicker.tsx#L69",
68 "D:/Programme/t3code/OpenInPicker.tsx",
69 "#L69",
70 ],
71 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""],
72 ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""],
73 ])("parses %s", (href, path, hash) => {
74 expect(parseFileUrlHref(href)).toEqual({ path, hash });
75 });
76
77 it("keeps percent-encoding so the caller decodes once", () => {
78 expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe(
79 "/Users/julius/project/file%2520name.md",
80 );
81 expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png");
82 });
83
84 it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => {
85 expect(parseFileUrlHref(href)).toBeNull();
86 });
87});
88
89describe("splitFilePathPosition", () => {
90 it.each([
91 ["src/main.ts", "", { path: "src/main.ts" }],
92 ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }],
93 ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }],
94 ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }],
95 ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }],
96 ["src/main.ts:0", "", { path: "src/main.ts" }],
97 ["src/main.ts", "#section", { path: "src/main.ts" }],
98 ])("splits %s%s", (path, hash, expected) => {
99 expect(splitFilePathPosition(path, hash)).toEqual(expected);
100 });
101});
102
103describe("parseMarkdownFileLink", () => {
104 // Both clients consume this table, so a path the web app recognizes is one
105 // the mobile app recognizes too.
106 it.each([
107 ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"],
108 ["/home/me/notes.md", "/home/me/notes.md"],
109 ["/usr/local/bin/tool", "/usr/local/bin/tool"],
110 ["/workspace/Makefile", "/workspace/Makefile"],
111 ["/tmp/favicons/", "/tmp/favicons/"],
112 ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"],
113 ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"],
114 ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
115 ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"],
116 ["</D:/Programme/t3code/ChatMarkdown.tsx:1>", "D:/Programme/t3code/ChatMarkdown.tsx"],
117 ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"],
118 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"],
119 ["file://localhost/home/me/notes.md", "/home/me/notes.md"],
120 ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"],
121 ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"],
122 ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"],
123 ["./scripts/deploy", "./scripts/deploy"],
124 ["~/notes/today.md", "~/notes/today.md"],
125 ["AGENTS.md", "AGENTS.md"],
126 ["script.ts:10", "script.ts"],
127 ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"],
128 ])("recognizes %s as a file", (href, path) => {
129 expect(parseMarkdownFileLink(href)?.path).toBe(path);
130 });
131
132 it.each([
133 "",
134 "#anchor",
135 "//cdn.example.com/clip.mp4",
136 "https://example.com/docs",
137 "mailto:someone@example.com",
138 "javascript:alert(1)",
139 "/chat/settings",
140 "/chat/settings#L3",
141 "/app#L1",
142 "readme",
143 "TODO:12",
144 ])("does not treat %s as a file", (href) => {
145 expect(parseMarkdownFileLink(href)).toBeNull();
146 });
147
148 it("accepts conventional extensionless names with or without a position", () => {
149 expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" });
150 expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 });
151 expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" });
152 });
153
154 it("reads positions from suffixes and line anchors", () => {
155 expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({
156 path: "/Users/julius/project/src/main.ts",
157 line: 42,
158 column: 7,
159 });
160 expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({
161 path: "\\\\server\\share\\src\\main.ts",
162 line: 42,
163 column: 7,
164 });
165 });
166});
167
168describe("fileBasename", () => {
169 it.each([
170 ["/tmp/favicons/", "favicons"],
171 ["C:\\Users\\kelchm\\.claude\\", ".claude"],
172 ["/tmp/", "tmp"],
173 ["AGENTS.md", "AGENTS.md"],
174 ["/", "/"],
175 ])("labels %s as %s", (path, basename) => {
176 expect(fileBasename(path)).toBe(basename);
177 });
178});
179
180describe("workspaceRelativeFilePath", () => {
181 it.each([
182 ["/repo/project", "/repo/project", "."],
183 ["/repo/project/", "/repo/project/", "."],
184 ["/", "/", "."],
185 ["C:/USERS/mike/project", "c:/users/MIKE/project", "."],
186 ["C:/", "c:/", "."],
187 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],
188 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],
189 ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
190 ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
191 ["/Repo/Project/src/main.ts", "/repo/project", null],
192 ["/tmp/case/project/probe.txt", "/tmp/case/Project", null],
193 ["//tmp/case/project/probe.txt", "//tmp/case/Project", null],
194 ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"],
195 ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"],
196 ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"],
197 ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"],
198 ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"],
199 ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"],
200 ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"],
201 ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "],
202 ["/tmp/report.ts", "/repo/project", null],
203 ["/repo/project-two/a.ts", "/repo/project", null],
204 ["/repo/project/a.ts", undefined, null],
205 ])("relates %s to %s", (path, workspaceRoot, relativePath) => {
206 expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
207 });
208});
packages/client-runtime/src/markdownLinks.ts deleted-369
......@@ -1,369 +0,0 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
2
3const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/;
4const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
5const RELATIVE_FILE_PATH_PATTERN =
6 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
7const RELATIVE_FILE_NAME_PATTERN =
8 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
9const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
10const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
11const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/;
12const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i;
13const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
14const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
15const PATH_SEPARATOR_PATTERN = /[\\/]/;
16const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
17// A final dot between digits marks a version or model id (`glm-5.3`,
18// `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files.
19const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/;
20const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
21// Standard OS and dev-container roots; deliberately excludes app-route-ish
22// prefixes like /app/ or /chat/ so SPA routes never read as files.
23const POSIX_FILE_ROOT_PREFIXES = [
24 "/Users/",
25 "/home/",
26 "/tmp/",
27 "/var/",
28 "/etc/",
29 "/opt/",
30 "/mnt/",
31 "/Volumes/",
32 "/private/",
33 "/root/",
34 "/usr/",
35 "/bin/",
36 "/sbin/",
37 "/lib/",
38 "/lib64/",
39 "/srv/",
40 "/dev/",
41 "/proc/",
42 "/sys/",
43 "/run/",
44 "/boot/",
45 "/media/",
46 "/workspace/",
47 "/workspaces/",
48] as const;
49// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`.
50const EXTENSIONLESS_FILE_NAMES = new Set([
51 "Makefile",
52 "makefile",
53 "GNUmakefile",
54 "Dockerfile",
55 "Containerfile",
56 "Justfile",
57 "justfile",
58 "Rakefile",
59 "Gemfile",
60 "Procfile",
61 "Brewfile",
62 "Caddyfile",
63 "Vagrantfile",
64 "Jenkinsfile",
65 "Podfile",
66 "Fastfile",
67 "BUILD",
68 "WORKSPACE",
69 "LICENSE",
70 "LICENCE",
71 "COPYING",
72 "NOTICE",
73 "AUTHORS",
74 "CONTRIBUTORS",
75 "CHANGELOG",
76 "README",
77 "CODEOWNERS",
78]);
79const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
80// These allowlists avoid classifying dotted directories such as `conf.d/`
81// or filenames such as `Makefile.in:12` as hosts.
82const GENERIC_HOSTNAME_TLDS = new Set([
83 "com",
84 "net",
85 "org",
86 "io",
87 "dev",
88 "app",
89 "ai",
90 "co",
91 "edu",
92 "gov",
93 "mil",
94 "info",
95 "biz",
96 "xyz",
97 "me",
98 "tv",
99 "cc",
100 "gg",
101 "chat",
102 "cloud",
103 "site",
104 "online",
105 "tech",
106 "store",
107 "link",
108]);
109// Country codes also name file extensions. A :line suffix makes `.pl`
110// and `.pt` files more likely than hostnames.
111const COUNTRY_HOSTNAME_TLDS = new Set([
112 "uk",
113 "de",
114 "fr",
115 "nl",
116 "se",
117 "no",
118 "fi",
119 "dk",
120 "pl",
121 "ch",
122 "at",
123 "be",
124 "es",
125 "it",
126 "pt",
127 "eu",
128 "us",
129 "ca",
130 "au",
131 "nz",
132 "jp",
133 "kr",
134 "cn",
135 "br",
136 "ru",
137 "mx",
138 "ie",
139 "cz",
140 "tr",
141 "sg",
142 "hk",
143]);
144
145function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
146 if (segment.startsWith(".")) return false;
147 const lowered = segment.toLowerCase();
148 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
149 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
150 const labels = lowered.split(".");
151 const lastLabel = labels.at(-1);
152 if (labels.length < 2 || lastLabel === undefined) return false;
153 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
154 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
155}
156
157/**
158 * Picks path-shaped inline code for the client's markdown file-link resolver.
159 * It does not resolve paths or turn plain prose and fenced code into links.
160 */
161export function inlineCodeFilePathCandidate(codeText: string): string | null {
162 const trimmed = codeText.trim();
163 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
164
165 const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/");
166 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
167 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
168
169 const hasExplicitPathShape =
170 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
171 candidate.startsWith("/") ||
172 isWindowsAbsolutePath(candidate);
173 if (!hasExplicitPathShape) {
174 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
175 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
176 if (looksLikeHostname(firstSegment, hasPosition)) return null;
177 const basename =
178 withoutPosition
179 .replace(/[/\\]+$/, "")
180 .split(/[\\/]/)
181 .at(-1) ?? "";
182 if (VERSION_SUFFIX_PATTERN.test(basename)) return null;
183 if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null;
184 }
185 return candidate;
186}
187
188export function safeDecodeURIComponent(value: string): string {
189 try {
190 return decodeURIComponent(value);
191 } catch {
192 return value;
193 }
194}
195
196export function normalizeMarkdownLinkDestination(value: string): string {
197 const trimmed = value.trim();
198 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
199}
200
201/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */
202export function stripSlashPrefixedWindowsDrive(path: string): string {
203 return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path;
204}
205
206export function splitMarkdownLinkSearchAndHash(value: string): {
207 readonly path: string;
208 readonly hash: string;
209} {
210 const hashIndex = value.indexOf("#");
211 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
212 const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
213 const queryIndex = pathWithSearch.indexOf("?");
214 return {
215 path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
216 hash,
217 };
218}
219
220/**
221 * Turns a `file:` URL into a host path, still percent-encoded so callers that
222 * decode every destination in one place do not decode file URLs twice. A
223 * non-localhost authority becomes a UNC share.
224 */
225export function parseFileUrlHref(
226 href: string,
227): { readonly path: string; readonly hash: string } | null {
228 try {
229 const parsed = new URL(href);
230 if (parsed.protocol.toLowerCase() !== "file:") return null;
231
232 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
233 const path = uncHostname
234 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
235 : parsed.pathname;
236 if (path.length === 0) return null;
237 return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash };
238 } catch {
239 return null;
240 }
241}
242
243export interface FilePathPosition {
244 readonly path: string;
245 readonly line?: number;
246 readonly column?: number;
247}
248
249export function splitFilePathPosition(path: string, hash = ""): FilePathPosition {
250 const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN);
251 const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN);
252 if (!match?.[1]) return { path };
253
254 const line = Number.parseInt(match[1], 10);
255 const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10);
256 return {
257 path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path,
258 ...(line > 0 ? { line } : {}),
259 ...(column !== undefined && column > 0 ? { column } : {}),
260 };
261}
262
263export function formatFilePathPosition(position: FilePathPosition): string {
264 if (!position.line) return position.path;
265 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
266}
267
268/** Keeps filename and destination-path labels compact without discarding prose. */
269export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
270 const destination = parseMarkdownFileLink(href);
271 if (!destination) return false;
272 const normalize = (path: string) =>
273 path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
274 const labelPosition = splitFilePathPosition(label.trim());
275 if (
276 (labelPosition.line !== undefined && labelPosition.line !== destination.line) ||
277 (labelPosition.column !== undefined && labelPosition.column !== destination.column)
278 ) {
279 return false;
280 }
281 let labelPath = normalize(labelPosition.path);
282 let destinationPath = normalize(destination.path);
283 if (labelPath.length === 0) return true;
284 if (isWindowsAbsolutePath(destination.path)) {
285 labelPath = labelPath.toLowerCase();
286 destinationPath = destinationPath.toLowerCase();
287 }
288 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
289}
290
291export function isRelativeFilePath(path: string): boolean {
292 return (
293 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
294 (!path.startsWith("/") && !isWindowsAbsolutePath(path))
295 );
296}
297
298function looksLikePosixFilesystemPath(path: string): boolean {
299 if (!path.startsWith("/")) return false;
300 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
301 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
302 const basename = path.slice(path.lastIndexOf("/") + 1);
303 return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename);
304}
305
306/**
307 * Decides whether a decoded link destination is a file path rather than a route
308 * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L`
309 * anchor never turns `/chat/settings` into a file.
310 */
311function looksLikeFilePath(path: string, authoredPath: string): boolean {
312 if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
313 if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath);
314 if (EXTENSIONLESS_FILE_NAMES.has(path)) return true;
315 return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path);
316}
317
318function hasExternalScheme(path: string): boolean {
319 if (isWindowsAbsolutePath(path)) return false;
320 const match = path.match(EXTERNAL_SCHEME_PATTERN);
321 if (!match) return false;
322 const rest = match[2] ?? "";
323 if (rest.startsWith("//")) return true;
324 return !POSITION_ONLY_PATTERN.test(rest);
325}
326
327export function parseMarkdownFileLink(href: string): FilePathPosition | null {
328 const normalized = normalizeMarkdownLinkDestination(href);
329 if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) {
330 return null;
331 }
332
333 const source =
334 (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ??
335 splitMarkdownLinkSearchAndHash(normalized);
336 // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded.
337 const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim()));
338 const hash = safeDecodeURIComponent(source.hash.trim());
339 if (path.length === 0 || hasExternalScheme(path)) return null;
340
341 const position = splitFilePathPosition(path, hash);
342 return looksLikeFilePath(position.path, path) ? position : null;
343}
344
345export function fileBasename(path: string): string {
346 // A trailing separator is a valid way to write a directory. Trim it before
347 // taking the final segment so the label is never empty.
348 const trimmed = path.replace(/[/\\]+$/, "");
349 if (trimmed.length === 0) return path;
350 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
351 return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
352}
353
354export function workspaceRelativeFilePath(
355 path: string,
356 workspaceRoot: string | null | undefined,
357): string | null {
358 if (!workspaceRoot) return null;
359 const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/"));
360 const normalizedRoot = stripSlashPrefixedWindowsDrive(
361 workspaceRoot.replaceAll("\\", "/"),
362 ).replace(/\/+$/, "");
363 const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));
364 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
365 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
366 if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return ".";
367 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
368 return normalizedPath.slice(normalizedRoot.length + 1);
369}
packages/client-runtime/src/mediaReference.ts+1-1
......@@ -1,6 +1,6 @@
11import { isWindowsAbsolutePath } from "@t3tools/shared/path";
22
3import { safeDecodeURIComponent } from "./markdownLinks.ts";
3import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks";
44
55/** The authored media location, never the temporary URL used to load its bytes. */
66export type MediaReference =
packages/client-runtime/src/mediaSource.ts+3-5
......@@ -6,11 +6,9 @@ import {
66 markdownImageSourceFragment,
77 type MarkdownImageSource,
88} from "./markdownImages.ts";
9import {
10 fileBasename,
11 splitFilePathPosition,
12 splitMarkdownLinkSearchAndHash,
13} from "./markdownLinks.ts";
9import { fileBasename } from "@t3tools/shared/path";
10import { splitFilePathPosition } from "@t3tools/shared/fileLinks";
11import { splitMarkdownLinkSearchAndHash } from "@t3tools/shared/markdownLinks";
1412import {
1513 mediaFileReference,
1614 mediaReferenceFileName,
packages/client-runtime/src/providerSkills.test.ts+1-1
......@@ -4,11 +4,11 @@ import {
44 ProviderInstanceId,
55 type ServerProvider,
66} from "@t3tools/contracts";
7import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
78import { describe, expect, it } from "vite-plus/test";
89
910import {
1011 dedupeProviderSkillsByName,
11 formatProviderSkillDisplayName,
1212 getProviderSlashCommandsForSlashMenu,
1313 getProviderSkillsForSlashMenu,
1414 hasCompleteProviderWorkspaceSnapshot,
packages/client-runtime/src/providerSkills.ts-19
......@@ -7,29 +7,10 @@ import {
77
88export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
99
10function titleCaseWords(value: string): string {
11 const words: string[] = [];
12 for (const segment of value.split(/[\s:_-]+/)) {
13 if (segment.length === 0) continue;
14 words.push(segment.charAt(0).toUpperCase() + segment.slice(1));
15 }
16 return words.join(" ");
17}
18
1910function normalizePathSeparators(pathValue: string): string {
2011 return pathValue.replaceAll("\\", "/");
2112}
2213
23export function formatProviderSkillDisplayName(
24 skill: Pick<ServerProviderSkill, "name" | "displayName">,
25): string {
26 const displayName = skill.displayName?.trim();
27 if (displayName) {
28 return displayName;
29 }
30 return titleCaseWords(skill.name);
31}
32
3314export function dedupeProviderSkillsByName(
3415 skills: ReadonlyArray<ServerProviderSkill>,
3516): ServerProviderSkill[] {
packages/client-runtime/src/rpc/client.ts+1
......@@ -53,6 +53,7 @@ export type EnvironmentSubscriptionRpcTag =
5353 | typeof WS_METHODS.providerInstallSubscribe
5454 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell
5555 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread
56 | typeof ORCHESTRATION_V2_WS_METHODS.searchThreadStream
5657 | typeof WS_METHODS.subscribeAuthAccess
5758 | typeof WS_METHODS.subscribeServerConfig
5859 | typeof WS_METHODS.subscribeServerLifecycle
packages/client-runtime/src/state/orchestration.ts+12
......@@ -55,6 +55,18 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
5555 label: "environment-data:orchestration:full-thread-diff",
5656 tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,
5757 }),
58 threadFind: createEnvironmentRpcQueryAtomFamily(runtime, {
59 label: "environment-data:orchestration:thread-find",
60 tag: ORCHESTRATION_V2_WS_METHODS.searchThread,
61 staleTimeMs: 0,
62 idleTtlMs: 0,
63 }),
64 threadFindProgressive: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
65 label: "environment-data:orchestration:thread-find-progressive",
66 tag: ORCHESTRATION_V2_WS_METHODS.searchThreadStream,
67 completeWhen: (result) => result.complete !== false,
68 idleTtlMs: 0,
69 }),
5870 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {
5971 label: "environment-data:orchestration:thread-search",
6072 tag: ORCHESTRATION_V2_WS_METHODS.searchThreads,
packages/client-runtime/src/state/runtime.test.ts+70
......@@ -30,6 +30,7 @@ import {
3030 environmentRpcKey,
3131 createAtomCommandScheduler,
3232 createEnvironmentQueryAtomFamily,
33 createEnvironmentSubscriptionAtomFamily,
3334 createRuntimeCommand,
3435 scheduleAtomCommandEffect,
3536 executeAtomCommand,
......@@ -291,6 +292,75 @@ describe("environmentRpcKey", () => {
291292 });
292293});
293294
295describe("finite environment subscription lifecycle", () => {
296 it.effect(
297 "exposes early values and settles on the terminal value without waiting for a reconnect",
298 () =>
299 Effect.scoped(
300 Effect.gen(function* () {
301 const releaseFinal = yield* Deferred.make<void>();
302 const sawEarly = yield* Deferred.make<void>();
303 const sawFinal = yield* Deferred.make<void>();
304 const closed = yield* Deferred.make<void>();
305 const stream = Stream.concat(
306 Stream.succeed({ complete: false, count: 1 }),
307 Stream.fromEffect(
308 Deferred.await(releaseFinal).pipe(Effect.as({ complete: true, count: 123 })),
309 ),
310 ).pipe(Stream.ensuring(Deferred.succeed(closed, undefined)));
311 // Following a registered environment normally remains open for replacements.
312 const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({
313 followStream: (_environmentId: EnvironmentId, source: typeof stream) =>
314 Stream.concat(source, Stream.never),
315 } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]);
316 const runtime = Atom.runtime(
317 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry),
318 );
319 const family = createEnvironmentSubscriptionAtomFamily(runtime, {
320 label: "test.finite-subscription",
321 subscribe: () => stream,
322 completeWhen: (value) => value.complete,
323 idleTtlMs: 0,
324 });
325 const atom = family({ environmentId: QUERY_ENVIRONMENT.environmentId, input: undefined });
326 const registry = AtomRegistry.make();
327 const observed: Array<
328 AsyncResult.AsyncResult<{ complete: boolean; count: number }, unknown>
329 > = [];
330 const unsubscribe = registry.subscribe(
331 atom,
332 (result) => {
333 observed.push(result);
334 if (AsyncResult.isSuccess(result)) {
335 if (!result.value.complete) Deferred.doneUnsafe(sawEarly, Exit.void);
336 if (result.value.complete && !result.waiting)
337 Deferred.doneUnsafe(sawFinal, Exit.void);
338 }
339 },
340 { immediate: true },
341 );
342 yield* Effect.addFinalizer(() =>
343 Effect.sync(() => {
344 unsubscribe();
345 registry.dispose();
346 }),
347 );
348 yield* Deferred.await(sawEarly);
349 const early = registry.get(atom);
350 expect(AsyncResult.isSuccess(early) && early.value.count).toBe(1);
351 expect(early.waiting).toBe(true);
352 yield* Deferred.succeed(releaseFinal, undefined);
353 yield* Deferred.await(sawFinal);
354 yield* Deferred.await(closed);
355 const final = registry.get(atom);
356 expect(AsyncResult.isSuccess(final) && final.value.count).toBe(123);
357 expect(final.waiting).toBe(false);
358 expect(observed.some((result) => AsyncResult.isFailure(result))).toBe(false);
359 }),
360 ),
361 );
362});
363
294364describe("environment query lifecycle", () => {
295365 it.effect(
296366 "retries an interrupted query without exposing a failure during session replacement",
packages/client-runtime/src/state/runtime.ts+8-1
......@@ -78,6 +78,7 @@ interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {
7878 readonly sensitiveInput?: boolean;
7979 readonly label: string;
8080 readonly subscribe: (input: Input) => Stream.Stream<A, E, R>;
81 readonly completeWhen?: (value: A) => boolean;
8182 readonly idleTtlMs?: number;
8283}
8384
......@@ -618,7 +619,11 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(
618619 const family = Atom.family((key: string) => {
619620 const target = parseEnvironmentRpcKey<Input>(key);
620621 return runtime
621 .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input)))
622 .atom(
623 followStreamInEnvironment(target.environmentId, options.subscribe(target.input)).pipe(
624 options.completeWhen ? Stream.takeUntil(options.completeWhen) : (stream) => stream,
625 ),
626 )
622627 .pipe(
623628 Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000),
624629 Atom.withLabel(
......@@ -705,6 +710,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
705710 readonly label: string;
706711 readonly tag: TTag;
707712 readonly idleTtlMs?: number;
713 readonly completeWhen?: (value: B) => boolean;
708714 readonly transform?: (
709715 stream: Stream.Stream<
710716 EnvironmentRpcStreamValue<TTag>,
......@@ -720,6 +726,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
720726) {
721727 return createEnvironmentSubscriptionAtomFamily(runtime, {
722728 label: options.label,
729 ...(options.completeWhen === undefined ? {} : { completeWhen: options.completeWhen }),
723730 ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }),
724731 subscribe: (input: EnvironmentRpcInput<TTag>) => {
725732 const stream = subscribe(options.tag, input);
packages/client-runtime/src/state/subagentDisplay.ts+1-1
......@@ -5,7 +5,7 @@ import type {
55 ServerProvider,
66} from "@t3tools/contracts";
77import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
8import { fileBasename } from "../markdownLinks.ts";
8import { fileBasename } from "@t3tools/shared/path";
99import { isTerminalSubagentStatus } from "./subagentRuntime.ts";
1010
1111/** Summarizes one adjacent group, without changing its member identities or order. */
packages/client-runtime/src/state/threadCommands.ts+2
......@@ -97,6 +97,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts";
9797
9898export type LoadEarlierThreadHistoryInput = {
9999 readonly threadId: ThreadId;
100 readonly throughEntryId?: string;
100101};
101102
102103export type {
......@@ -387,6 +388,7 @@ export function createThreadEnvironmentAtoms<R, E>(
387388 return yield* controller.value.loadEarlier(
388389 supervisor.target.environmentId,
389390 input.threadId,
391 input.throughEntryId,
390392 );
391393 }),
392394 scheduler,
packages/client-runtime/src/state/threadHistoryController.ts+4-3
......@@ -13,7 +13,7 @@ export type ThreadHistoryLoadEarlierResult =
1313 | { readonly _tag: "error"; readonly message: string };
1414
1515export type ThreadHistoryHandler = {
16 readonly loadEarlier: () => Effect.Effect<ThreadHistoryLoadEarlierResult>;
16 readonly loadEarlier: (throughEntryId?: string) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
1717};
1818
1919/**
......@@ -42,6 +42,7 @@ export class ThreadHistoryController extends Context.Service<
4242 readonly loadEarlier: (
4343 environmentId: EnvironmentId,
4444 threadId: ThreadId,
45 throughEntryId?: string,
4546 ) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
4647 }
4748>()("@t3tools/client-runtime/state/threadHistoryController") {}
......@@ -78,14 +79,14 @@ export const layer: Layer.Layer<ThreadHistoryController> = Layer.effect(
7879 next.delete(key);
7980 return next;
8081 }),
81 loadEarlier: (environmentId, threadId) =>
82 loadEarlier: (environmentId, threadId, throughEntryId) =>
8283 Ref.get(handlers).pipe(
8384 Effect.flatMap((current) => {
8485 const handler = current.get(threadKey({ environmentId, threadId }));
8586 if (handler === undefined) {
8687 return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult);
8788 }
88 return handler.loadEarlier();
89 return handler.loadEarlier(throughEntryId);
8990 }),
9091 ),
9192 });
packages/client-runtime/src/state/threadHistoryHttp.ts+7-1
......@@ -19,6 +19,8 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(
1919 readonly prepared: PreparedConnection;
2020 readonly threadId: ThreadId;
2121 readonly cursor: string;
22 readonly throughEntryId?: string | undefined;
23 readonly view?: "conversation" | "activity" | undefined;
2224 readonly signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>;
2325 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
2426 readonly timeoutMs?: number;
......@@ -33,7 +35,11 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(
3335 request: ({ client, headers }) =>
3436 client.threadHistoryPage({
3537 params: { threadId: input.threadId },
36 query: { cursor: input.cursor },
38 query: {
39 cursor: input.cursor,
40 ...(input.view === undefined ? {} : { view: input.view }),
41 ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }),
42 },
3743 headers: withOrchestrationProtocolHeader(headers),
3844 }),
3945 });
packages/client-runtime/src/state/threadHistoryMerge.test.ts+71
......@@ -45,6 +45,51 @@ function row(index: number): OrchestrationV2ProjectedTurnItem {
4545}
4646
4747describe("threadHistoryMerge", () => {
48 it("fills activity around partial messages in canonical order and preserves live edits", () => {
49 const initial = { ...v2Projection, turnItems: [row(4).item], visibleTurnItems: [row(4)] };
50 const preview = [row(1), row(3)];
51 const partial = mergeOlderHistoryIntoProjection(initial, preview);
52 const updated = { ...row(1), item: { ...row(1).item, title: "newer live title" } };
53 const live = {
54 ...partial,
55 visibleTurnItems: partial.visibleTurnItems.map((entry) =>
56 entry.sourceItemId === updated.sourceItemId ? updated : entry,
57 ),
58 turnItems: partial.turnItems.map((item) =>
59 item.id === updated.item.id ? updated.item : item,
60 ),
61 };
62 const complete = mergeOlderHistoryIntoProjection(live, [row(0), row(1), row(2), row(3)]);
63 expect(complete.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
64 "item-0",
65 "item-1",
66 "item-2",
67 "item-3",
68 "item-4",
69 ]);
70 expect(complete.visibleTurnItems[1]?.item).toBe(updated.item);
71 expect(complete.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3, 4]);
72 expect(new Set(complete.turnItems.map((item) => item.id)).size).toBe(5);
73 const hidden = {
74 ...partial,
75 visibleTurnItems: partial.visibleTurnItems.filter(
76 (entry) => entry.sourceItemId !== preview[0]!.sourceItemId,
77 ),
78 };
79 const hiddenComplete = mergeOlderHistoryIntoProjection(hidden, [
80 row(0),
81 row(1),
82 row(2),
83 row(3),
84 ]);
85 expect(hiddenComplete.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([
86 "item-0",
87 "item-2",
88 "item-3",
89 "item-4",
90 ]);
91 });
92
4893 it("prepends older rows and dedupes by source identity", () => {
4994 const recent = [row(2), row(3)];
5095 const projection = {
......@@ -69,6 +114,32 @@ describe("threadHistoryMerge", () => {
69114 ]);
70115 });
71116
117 it("fills inherited activity without confusing identical item IDs from different source threads", () => {
118 const inherited = (index: number) => ({
119 ...row(index),
120 visibility: "inherited" as const,
121 sourceThreadId: "fork-source" as never,
122 });
123 const local = row(1);
124 const preview = { ...inherited(1), item: { ...row(1).item, title: "newer inherited title" } };
125 const projection = {
126 ...v2Projection,
127 turnItems: [local.item],
128 visibleTurnItems: [preview, local],
129 };
130 const merged = mergeOlderHistoryIntoProjection(projection, [inherited(0), inherited(1)]);
131 expect(
132 merged.visibleTurnItems.map((entry) => [entry.sourceThreadId, entry.sourceItemId]),
133 ).toEqual([
134 ["fork-source", "item-0"],
135 ["fork-source", "item-1"],
136 [v2ThreadId, "item-1"],
137 ]);
138 expect(merged.visibleTurnItems[1]?.item).toBe(preview.item);
139 expect(merged.turnItems).toEqual([local.item]);
140 expect(mergeOlderHistoryIntoProjection(merged, [inherited(0), inherited(1)])).toBe(merged);
141 });
142
72143 it("retains live rows that arrived after the older page was fetched", () => {
73144 const live = [row(5), row(6)];
74145 const projection = {
packages/client-runtime/src/state/threadHistoryMerge.ts+36-38
......@@ -1,7 +1,6 @@
11import type {
22 OrchestrationV2ProjectedTurnItem,
33 OrchestrationV2ThreadProjection,
4 OrchestrationV2TurnItem,
54} from "@t3tools/contracts";
65import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
76
......@@ -73,62 +72,61 @@ export function mergeOlderHistoryIntoProjection(
7372 return projection;
7473 }
7574
76 const existingKeys = new Set(projection.visibleTurnItems.map(projectedItemKey));
77 const turnItemById = new Map<string, OrchestrationV2TurnItem>();
78 for (const item of projection.turnItems) {
79 turnItemById.set(String(item.id), item);
80 }
81 const prepended: OrchestrationV2ProjectedTurnItem[] = [];
75 const currentByKey = new Map(
76 projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]),
77 );
78 const turnItemById = new Map(projection.turnItems.map((item) => [String(item.id), item]));
79 const pageKeys = new Set<string>();
80 const page: OrchestrationV2ProjectedTurnItem[] = [];
8281 for (const pageRow of olderItems) {
83 let row = pageRow;
84 const key = projectedItemKey(row);
85 if (existingKeys.has(key)) {
86 continue;
87 }
88 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {
82 const key = projectedItemKey(pageRow);
83 if (pageKeys.has(key)) continue;
84 let row = currentByKey.get(key) ?? pageRow;
85 if (
86 !currentByKey.has(key) &&
87 (row.visibility === "local" || row.sourceThreadId === projection.thread.id)
88 ) {
8989 const currentItem = turnItemById.get(String(row.sourceItemId));
90 // Hidden live items stay hidden. Cold snapshots also retain interrupt
91 // requests outside the visible window; paging may reveal those requests.
9092 if (currentItem !== undefined) {
91 // A live event may have hidden this item while the page was in flight.
92 // Do not resurrect the stale page copy. Retained interrupt requests are
93 // the exception: they intentionally exist outside the bounded visible
94 // window and should become visible when their history page arrives.
95 if (currentItem.type !== "run_interrupt_request") {
96 continue;
97 }
9893 if (
94 currentItem.type !== "run_interrupt_request" ||
9995 !isOrchestrationV2TurnItemVisible({
10096 item: currentItem,
10197 runs: projection.runs,
10298 attempts: projection.attempts,
10399 items: projection.turnItems,
104100 })
105 ) {
101 )
106102 continue;
107 }
108103 row = { ...row, item: currentItem };
109104 }
110105 }
111 existingKeys.add(key);
112 prepended.push(row);
106 pageKeys.add(key);
107 page.push(row);
108 if (
109 (row.visibility === "local" || row.sourceThreadId === projection.thread.id) &&
110 !turnItemById.has(String(row.sourceItemId))
111 )
112 turnItemById.set(String(row.sourceItemId), row.item);
113113 }
114 if (prepended.length === 0) {
114 if (page.length === 0) return projection;
115 // Server page order fills gaps between rows from a partial conversation load.
116 // Existing rows supply live values; rows outside this range keep their order.
117 const visible = [
118 ...page,
119 ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))),
120 ];
121 if (
122 visible.length === projection.visibleTurnItems.length &&
123 visible.every((row, index) => row === projection.visibleTurnItems[index])
124 )
115125 return projection;
116 }
117
118 const visibleTurnItems = renumberVisible([...prepended, ...projection.visibleTurnItems]);
119
120 for (const row of prepended) {
121 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {
122 if (!turnItemById.has(String(row.sourceItemId))) {
123 turnItemById.set(String(row.sourceItemId), row.item);
124 }
125 }
126 }
127
128126 return {
129127 ...projection,
130128 turnItems: [...turnItemById.values()],
131 visibleTurnItems,
129 visibleTurnItems: renumberVisible(visible),
132130 };
133131}
134132
packages/client-runtime/src/state/threads-sync.test.ts+137-2
......@@ -254,7 +254,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
254254
255255 return {
256256 threadState,
257 loadEarlier: () => historyController.loadEarlier(TARGET.environmentId, THREAD_ID),
257 loadEarlier: (throughEntryId?: string) =>
258 historyController.loadEarlier(TARGET.environmentId, THREAD_ID, throughEntryId),
258259 inputs,
259260 observed,
260261 latest,
......@@ -780,6 +781,7 @@ describe("EnvironmentThreads", () => {
780781 const harness = yield* makeHarness({
781782 historyHttpClient: HttpClient.make((request, url) => {
782783 expect(url.searchParams.get("cursor")).toBe("socket-history-cursor");
784 expect(url.searchParams.get("throughEntryId")).toBe(olderItem.id);
783785 return Effect.succeed(
784786 HttpClientResponse.fromWeb(
785787 request,
......@@ -837,7 +839,7 @@ describe("EnvironmentThreads", () => {
837839 hasMoreHistory: true,
838840 latestLocalTurnOrdinal: 47,
839841 });
840 expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" });
842 expect(yield* harness.loadEarlier(olderItem.id)).toEqual({ _tag: "loaded" });
841843 const expanded = yield* SubscriptionRef.get(harness.threadState);
842844 expect(
843845 Option.getOrThrow(expanded.data).turnItems.some((item) => item.id === olderItem.id),
......@@ -846,6 +848,139 @@ describe("EnvironmentThreads", () => {
846848 }),
847849 );
848850
851 it.effect.each(["complete", "replacement snapshot", "retry"] as const)(
852 "progressively reveals conversation history before activity, then handles %s",
853 (mode) =>
854 Effect.gen(function* () {
855 const common = {
856 threadId: THREAD_ID,
857 runId: null,
858 nodeId: null,
859 providerThreadId: null,
860 providerTurnId: null,
861 nativeItemRef: null,
862 parentItemId: null,
863 status: "completed",
864 title: null,
865 startedAt: "2026-06-20T00:00:00.000Z",
866 completedAt: "2026-06-20T00:00:00.000Z",
867 updatedAt: "2026-06-20T00:00:00.000Z",
868 };
869 const message = {
870 ...common,
871 id: "item:older-message",
872 ordinal: 1,
873 type: "assistant_message",
874 messageId: "message:older",
875 text: "COD4 result",
876 streaming: false,
877 };
878 const activity = {
879 ...common,
880 id: "item:older-command",
881 ordinal: 2,
882 type: "command_execution",
883 input: "pwd",
884 output: "",
885 exitCode: 0,
886 };
887 const projected = (item: typeof message | typeof activity, position: number) => ({
888 position,
889 visibility: "local",
890 sourceThreadId: THREAD_ID,
891 sourceItemId: item.id,
892 item,
893 });
894 const activityStarted = yield* Deferred.make<void>();
895 const releaseActivity = yield* Deferred.make<void>();
896 let activityRequests = 0;
897 const harness = yield* makeHarness({
898 historyHttpClient: HttpClient.make((request, url) =>
899 Effect.gen(function* () {
900 expect(url.searchParams.get("cursor")).toBe("progressive-cursor");
901 const conversationOnly = url.searchParams.get("view") === "conversation";
902 if (!conversationOnly && activityRequests++ === 0) {
903 yield* Deferred.succeed(activityStarted, undefined);
904 yield* Deferred.await(releaseActivity);
905 if (mode === "retry")
906 return HttpClientResponse.fromWeb(
907 request,
908 new Response("Activity unavailable", { status: 503 }),
909 );
910 }
911 return HttpClientResponse.fromWeb(
912 request,
913 Response.json({
914 snapshotSequence: 14,
915 items: conversationOnly
916 ? [projected(message, 0)]
917 : [projected(message, 0), projected(activity, 1)],
918 nextCursor: "next-cursor",
919 hasMoreHistory: true,
920 }),
921 );
922 }),
923 ),
924 });
925 yield* Queue.offer(harness.inputs, {
926 ...snapshot(BASE_PROJECTION, 14),
927 historyCursor: "progressive-cursor",
928 hasMoreHistory: true,
929 latestLocalTurnOrdinal: 47,
930 payloadBudgetExceeded: false,
931 });
932 yield* awaitThreadState(
933 harness.observed,
934 (state) => state.history.historyCursor === "progressive-cursor",
935 );
936 const loading = yield* harness.loadEarlier(message.messageId).pipe(Effect.forkScoped);
937 yield* Deferred.await(activityStarted);
938 const preview = yield* SubscriptionRef.get(harness.threadState);
939 expect(preview.history).toMatchObject({
940 loading: true,
941 historyCursor: "progressive-cursor",
942 expanded: true,
943 });
944 expect(
945 Option.getOrThrow(preview.data).visibleTurnItems.map((row) => row.sourceItemId),
946 ).toEqual([message.id]);
947 if (mode === "replacement snapshot") {
948 yield* Queue.offer(harness.inputs, snapshot(BASE_PROJECTION, 15));
949 yield* awaitThreadState(
950 harness.observed,
951 (state) => state.history.historyCursor === null,
952 );
953 }
954 yield* Deferred.succeed(releaseActivity, undefined);
955 const result = yield* Fiber.join(loading);
956 if (mode === "replacement snapshot") {
957 expect(result).toEqual({ _tag: "noop" });
958 const state = yield* SubscriptionRef.get(harness.threadState);
959 expect(Option.getOrThrow(state.data).visibleTurnItems).toEqual(
960 BASE_PROJECTION.visibleTurnItems,
961 );
962 expect(state.history.historyCursor).toBeNull();
963 return;
964 }
965 if (mode === "retry") {
966 expect(result._tag).toBe("error");
967 // The ordinary load-earlier button must also finish a failed search load.
968 expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" });
969 } else expect(result).toEqual({ _tag: "loaded" });
970 const complete = yield* SubscriptionRef.get(harness.threadState);
971 expect(complete.history).toMatchObject({
972 loading: false,
973 historyCursor: "next-cursor",
974 error: null,
975 latestLocalTurnOrdinal: 47,
976 });
977 expect(
978 Option.getOrThrow(complete.data).visibleTurnItems.map((row) => row.sourceItemId),
979 ).toEqual([message.id, activity.id]);
980 expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(2);
981 }),
982 );
983
849984 it.effect("restores compact socket snapshot turnItems before reducing and caching", () =>
850985 Effect.gen(function* () {
851986 const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
packages/client-runtime/src/state/threads.ts+73-106
......@@ -634,7 +634,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
634634 );
635635 });
636636
637 const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* () {
637 const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* (
638 throughEntryId?: string,
639 ) {
638640 const current = yield* SubscriptionRef.get(state);
639641 if (
640642 current.status === "deleted" ||
......@@ -657,117 +659,81 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
657659 : history,
658660 );
659661
662 const failLoad = (message: string) =>
663 SubscriptionRef.modify(
664 state,
665 (
666 latest,
667 ): readonly [
668 ThreadHistoryController.ThreadHistoryLoadEarlierResult,
669 EnvironmentThreadState,
670 ] =>
671 isActiveHistoryRequestCursor(requestCursor, latest.history)
672 ? [
673 { _tag: "error", message },
674 {
675 ...latest,
676 history: { ...latest.history, loading: false, error: message },
677 },
678 ]
679 : [{ _tag: "noop" }, latest],
680 );
681
660682 const runLoad = Effect.gen(function* () {
661683 const preparedOption = yield* SubscriptionRef.get(supervisor.prepared);
662 if (Option.isNone(preparedOption) || Option.isNone(httpClient)) {
663 const message = "Environment is not connected.";
664 const stillCurrent = yield* SubscriptionRef.modify(
665 state,
666 (latest): readonly [boolean, EnvironmentThreadState] => {
667 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
668 return [false, latest];
669 }
670 return [
671 true,
672 {
673 ...latest,
674 history: { ...latest.history, loading: false, error: message },
675 },
676 ];
677 },
678 );
679 if (!stillCurrent) {
680 return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;
681 }
682 return {
683 _tag: "error",
684 message,
685 } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;
686 }
687
688 const pageResult = yield* fetchEnvironmentThreadHistoryPage({
689 prepared: preparedOption.value,
690 threadId,
691 cursor: requestCursor,
692 signer: dpopSigner,
693 remoteAuthorization,
694 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result);
695
696 if (Result.isFailure(pageResult)) {
697 const message = formatHistoryError(pageResult.failure);
698 // Only mark error when this request's cursor is still active. Leave
699 // stream/status/error alone so concurrent live updates stay intact.
700 const stillCurrent = yield* SubscriptionRef.modify(
701 state,
702 (latest): readonly [boolean, EnvironmentThreadState] => {
703 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
704 return [false, latest];
705 }
706 return [
707 true,
708 {
709 ...latest,
710 history: { ...latest.history, loading: false, error: message },
711 },
712 ];
713 },
714 );
715 if (!stillCurrent) {
716 return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;
717 }
718 return {
719 _tag: "error",
720 message,
721 } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;
722 }
723
724 const page = pageResult.success;
725 const waiting = yield* Ref.get(awaitingCompletion);
726 // Single atomic merge against whatever is current after the await so a
727 // concurrent applyItem cannot be clobbered by a stale get/set pair.
728 return yield* applyLock.withPermits(1)(
729 SubscriptionRef.modify(
730 state,
731 (
732 latest,
733 ): readonly [
734 ThreadHistoryController.ThreadHistoryLoadEarlierResult,
735 EnvironmentThreadState,
736 ] => {
737 // Stale page: socket/full snapshot or newer bounded install changed the
738 // progressive cursor while this request was in flight. Never mutate the
739 // replacement meta (including deleted/empty installs).
740 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {
741 return [{ _tag: "noop" }, latest];
742 }
743 if (Option.isNone(latest.data) || latest.status === "deleted") {
684 if (Option.isNone(preparedOption) || Option.isNone(httpClient))
685 return yield* failLoad("Environment is not connected.");
686
687 const views =
688 throughEntryId === undefined ? [undefined] : ["conversation" as const, undefined];
689 for (const view of views) {
690 const pageResult = yield* fetchEnvironmentThreadHistoryPage({
691 prepared: preparedOption.value,
692 threadId,
693 cursor: requestCursor,
694 throughEntryId,
695 view,
696 signer: dpopSigner,
697 remoteAuthorization,
698 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result);
699 if (Result.isFailure(pageResult))
700 return yield* failLoad(formatHistoryError(pageResult.failure));
701 const page = pageResult.success;
702 const complete = view === undefined;
703 const waiting = yield* Ref.get(awaitingCompletion);
704 // Both stages merge against live state under the same lock. Only the
705 // complete page advances the cursor and releases the loading indicator.
706 const result = yield* applyLock.withPermits(1)(
707 SubscriptionRef.modify(
708 state,
709 (
710 latest,
711 ): readonly [
712 ThreadHistoryController.ThreadHistoryLoadEarlierResult,
713 EnvironmentThreadState,
714 ] => {
715 if (!isActiveHistoryRequestCursor(requestCursor, latest.history))
716 return [{ _tag: "noop" }, latest];
717 if (Option.isNone(latest.data) || latest.status === "deleted")
718 return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }];
744719 return [
745 { _tag: "noop" },
720 { _tag: "loaded" },
746721 {
747722 ...latest,
748 history: EMPTY_THREAD_HISTORY_META,
723 data: Option.some(mergeOlderHistoryIntoProjection(latest.data.value, page.items)),
724 status: complete && waiting ? "synchronizing" : latest.status,
725 history: complete
726 ? applyHistoryPageMeta(latest.history, page)
727 : { ...latest.history, expanded: true },
749728 },
750729 ];
751 }
752
753 const merged = mergeOlderHistoryIntoProjection(latest.data.value, page.items);
754 const history = applyHistoryPageMeta(latest.history, page);
755 return [
756 { _tag: "loaded" },
757 {
758 ...latest,
759 data: Option.some(merged),
760 status: waiting
761 ? ("synchronizing" as const)
762 : latest.status === "live"
763 ? ("live" as const)
764 : latest.status,
765 history,
766 },
767 ];
768 },
769 ).pipe(Effect.tap(() => remember)),
770 );
730 },
731 ),
732 );
733 if (result._tag !== "loaded") return result;
734 if (complete) yield* remember;
735 }
736 return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;
771737 });
772738
773739 // On Effect interruption only: clear loading when this request cursor is
......@@ -785,7 +751,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
785751 if (Option.isSome(historyController)) {
786752 const scope = yield* Scope.Scope;
787753 const registration = yield* historyController.value.register(environmentId, threadId, {
788 loadEarlier: () => loadEarlier().pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)),
754 loadEarlier: (throughEntryId) =>
755 loadEarlier(throughEntryId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)),
789756 });
790757 yield* Effect.addFinalizer(() => historyController.value.unregister(registration));
791758 }
packages/contracts/src/environmentHttp.ts+2
......@@ -594,6 +594,8 @@ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({
594594
595595const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({
596596 cursor: TrimmedNonEmptyString,
597 throughEntryId: Schema.optional(TrimmedNonEmptyString),
598 view: Schema.optional(Schema.Literals(["conversation", "activity"])),
597599});
598600
599601const EnvironmentOrchestrationThreadHistoryErrors = [
packages/contracts/src/keybindings.test.ts+6
......@@ -101,6 +101,12 @@ it.effect("parses keybinding rules", () =>
101101 });
102102 assert.strictEqual(parsedLocal.command, "chat.newLocal");
103103
104 const parsedFind = yield* decode(KeybindingRule, {
105 key: "mod+f",
106 command: "chat.find",
107 });
108 assert.strictEqual(parsedFind.command, "chat.find");
109
104110 const parsedModelPickerToggle = yield* decode(KeybindingRule, {
105111 key: "mod+shift+m",
106112 command: "modelPicker.toggle",
packages/contracts/src/keybindings.ts+1
......@@ -100,6 +100,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
100100 "chat.new",
101101 "chat.newLocal",
102102 "chat.newWithoutProject",
103 "chat.find",
103104 "editor.openFavorite",
104105 "usage.cost",
105106 "usage.tokens",
packages/contracts/src/orchestrationV2.ts+62
......@@ -3138,6 +3138,8 @@ export const ORCHESTRATION_V2_WS_METHODS = {
31383138 getTurnDiff: "orchestration.getTurnDiff",
31393139 getFullThreadDiff: "orchestration.getFullThreadDiff",
31403140 searchThreads: "orchestration.searchThreads",
3141 searchThread: "orchestration.searchThread",
3142 searchThreadStream: "orchestration.searchThreadStream",
31413143 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
31423144 getThreadProjection: "orchestration.getThreadProjection",
31433145 getWorkflowScript: "orchestration.getWorkflowScript",
......@@ -3498,7 +3500,67 @@ export class OrchestrationGetWorkflowScriptError extends Schema.TaggedError<Orch
34983500 }
34993501}
35003502
3503export const OrchestrationV2SearchThreadInput = Schema.Struct({
3504 threadId: ThreadId,
3505 // Match the skill labels displayed by this client, including custom display names.
3506 skills: Schema.optionalKey(
3507 Schema.Array(
3508 Schema.Struct({
3509 name: Schema.String.check(Schema.isMaxLength(200)),
3510 displayName: Schema.optional(Schema.String.check(Schema.isMaxLength(200))),
3511 }),
3512 ).check(Schema.isMaxLength(1_000)),
3513 ),
3514 query: TrimmedNonEmptyString.check(Schema.isMaxLength(200)),
3515 index: Schema.optionalKey(NonNegativeInt),
3516 // Select relative to an entry identity so updates before it do not shift navigation.
3517 offset: Schema.optionalKey(Schema.Int),
3518 start: Schema.optionalKey(
3519 Schema.Struct({ entryId: TrimmedNonEmptyString, occurrence: NonNegativeInt }),
3520 ),
3521});
3522export type OrchestrationV2SearchThreadInput = typeof OrchestrationV2SearchThreadInput.Type;
3523
3524export const OrchestrationV2ThreadFindMatch = Schema.Struct({
3525 entryId: TrimmedNonEmptyString,
3526 runId: Schema.NullOr(RunId),
3527 occurrence: NonNegativeInt,
3528});
3529export const OrchestrationV2SearchThreadResult = Schema.Struct({
3530 // Omitted by older servers. An early match has no final ordinal or total yet.
3531 complete: Schema.optionalKey(Schema.Boolean),
3532 snapshotSequence: NonNegativeInt,
3533 totalMatches: NonNegativeInt,
3534 activeIndex: NonNegativeInt,
3535 match: Schema.NullOr(OrchestrationV2ThreadFindMatch),
3536 // Counts and identities around the selection let clients step without another round trip.
3537 navigation: Schema.optionalKey(
3538 Schema.Array(
3539 Schema.Struct({
3540 entryId: TrimmedNonEmptyString,
3541 runId: Schema.NullOr(RunId),
3542 startIndex: NonNegativeInt,
3543 count: NonNegativeInt,
3544 }),
3545 ).check(Schema.isMaxLength(17)),
3546 ),
3547});
3548export type OrchestrationV2SearchThreadResult = typeof OrchestrationV2SearchThreadResult.Type;
3549
3550export class OrchestrationV2SearchThreadError extends Schema.TaggedError<OrchestrationV2SearchThreadError>()(
3551 "OrchestrationV2SearchThreadError",
3552 { cause: Schema.Defect() },
3553) {
3554 override get message(): string {
3555 return "Could not search this thread. Please retry.";
3556 }
3557}
3558
35013559export const OrchestrationV2RpcSchemas = {
3560 searchThread: {
3561 input: OrchestrationV2SearchThreadInput,
3562 output: OrchestrationV2SearchThreadResult,
3563 },
35023564 dispatchCommand: {
35033565 input: OrchestrationV2Command,
35043566 output: OrchestrationV2DispatchCommandResult,
packages/contracts/src/rpc.ts+23
......@@ -1,3 +1,8 @@
1import {
2 OrchestrationV2SearchThreadError,
3 OrchestrationV2SearchThreadInput,
4 OrchestrationV2SearchThreadResult,
5} from "./orchestrationV2.ts";
16import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts";
27import {
38 McpAppCallToolInput,
......@@ -1565,6 +1570,22 @@ const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make(
15651570 },
15661571);
15671572
1573const WsOrchestrationV2SearchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThread, {
1574 payload: OrchestrationV2SearchThreadInput,
1575 success: OrchestrationV2SearchThreadResult,
1576 error: Schema.Union([OrchestrationV2SearchThreadError, EnvironmentAuthorizationError]),
1577});
1578
1579const WsOrchestrationV2SearchThreadStreamRpc = Rpc.make(
1580 ORCHESTRATION_V2_WS_METHODS.searchThreadStream,
1581 {
1582 payload: OrchestrationV2SearchThreadInput,
1583 success: OrchestrationV2SearchThreadResult,
1584 error: Schema.Union([OrchestrationV2SearchThreadError, EnvironmentAuthorizationError]),
1585 stream: true,
1586 },
1587);
1588
15681589const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThreads, {
15691590 payload: OrchestrationSearchThreadsInput,
15701591 success: OrchestrationSearchThreadsResult,
......@@ -1957,6 +1978,8 @@ export const WsRpcGroup = RpcGroup.make(
19571978 WsOrchestrationV2GetTurnDiffRpc,
19581979 WsOrchestrationV2GetFullThreadDiffRpc,
19591980 WsOrchestrationV2SearchThreadsRpc,
1981 WsOrchestrationV2SearchThreadRpc,
1982 WsOrchestrationV2SearchThreadStreamRpc,
19601983 WsOrchestrationV2GetArchivedShellSnapshotRpc,
19611984 WsOrchestrationV2GetThreadProjectionRpc,
19621985 WsOrchestrationV2LaunchThreadRpc,
packages/contracts/src/server.ts+2
......@@ -669,6 +669,8 @@ export const ServerConfig = Schema.Struct({
669669 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
670670 /** Whether thread reads accept the reasoningMessages opt-in. */
671671 reasoningMessages: Schema.optionalKey(Schema.Boolean),
672 threadFind: Schema.optionalKey(Schema.Boolean),
673 threadFindProgressive: Schema.optionalKey(Schema.Boolean),
672674 /**
673675 * Folder behind this environment's Scratch project, for threads that need
674676 * no repository. Present only on servers that answer projects.ensureScratch
packages/shared/package.json+59
......@@ -407,6 +407,54 @@
407407 "types": "./src/codexAuthHandoff.ts",
408408 "import": "./src/codexAuthHandoff.ts"
409409 },
410 "./codexArtifactTemplates": {
411 "types": "./src/codexArtifactTemplates.ts",
412 "import": "./src/codexArtifactTemplates.ts"
413 },
414 "./codexFileCitations": {
415 "types": "./src/codexFileCitations.ts",
416 "import": "./src/codexFileCitations.ts"
417 },
418 "./codexMarkdownDirectives": {
419 "types": "./src/codexMarkdownDirectives.ts",
420 "import": "./src/codexMarkdownDirectives.ts"
421 },
422 "./markdownGithubAlerts": {
423 "types": "./src/markdownGithubAlerts.ts",
424 "import": "./src/markdownGithubAlerts.ts"
425 },
426 "./markdownListIndentation": {
427 "types": "./src/markdownListIndentation.ts",
428 "import": "./src/markdownListIndentation.ts"
429 },
430 "./proposedPlanText": {
431 "types": "./src/proposedPlanText.ts",
432 "import": "./src/proposedPlanText.ts"
433 },
434 "./markdownPipeline": {
435 "types": "./src/markdownPipeline.ts",
436 "import": "./src/markdownPipeline.ts"
437 },
438 "./threadFindText": {
439 "types": "./src/threadFindText.ts",
440 "import": "./src/threadFindText.ts"
441 },
442 "./threadSearch": {
443 "types": "./src/threadSearch.ts",
444 "import": "./src/threadSearch.ts"
445 },
446 "./markdownLinks": {
447 "types": "./src/markdownLinks.ts",
448 "import": "./src/markdownLinks.ts"
449 },
450 "./fileLinks": {
451 "types": "./src/fileLinks.ts",
452 "import": "./src/fileLinks.ts"
453 },
454 "./inlineSkills": {
455 "types": "./src/inlineSkills.ts",
456 "import": "./src/inlineSkills.ts"
457 },
410458 "./mcpApp": {
411459 "types": "./src/mcpApp.ts",
412460 "import": "./src/mcpApp.ts"
......@@ -422,12 +470,23 @@
422470 "@t3tools/contracts": "workspace:*",
423471 "effect": "catalog:",
424472 "jose": "catalog:",
473 "mdast-util-directive": "catalog:",
474 "micromark-extension-directive": "catalog:",
475 "micromark-util-character": "catalog:",
476 "rehype-raw": "catalog:",
477 "rehype-sanitize": "catalog:",
478 "remark-breaks": "catalog:",
479 "remark-gfm": "catalog:",
480 "remark-parse": "catalog:",
481 "remark-rehype": "catalog:",
482 "unified": "catalog:",
425483 "yaml": "catalog:"
426484 },
427485 "devDependencies": {
428486 "@effect/platform-node": "catalog:",
429487 "@effect/vitest": "catalog:",
430488 "@types/node": "catalog:",
489 "micromark-util-types": "catalog:",
431490 "vite-plus": "catalog:"
432491 }
433492}
packages/shared/src/assistantCitations.ts+1
......@@ -98,6 +98,7 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu
9898 }
9999}
100100
101/** Visible chip text: the comment, else the quoted text, collapsed and capped. */
101102export function assistantCitationLabel(citation: AssistantCitation): string {
102103 const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " ");
103104 return preview.length > 64 ? `${preview.slice(0, 64)}…` : preview;
packages/shared/src/codexArtifactTemplates.test.ts created+119
......@@ -0,0 +1,119 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 appendCodexArtifactTemplateUsePrompt,
5 codexArtifactTemplatePresentationLabel,
6 codexArtifactTemplateUsePrompt,
7 resolveCodexArtifactTemplate,
8 type CodexArtifactTemplate,
9} from "./codexArtifactTemplates.ts";
10
11const HELLO_WORLD_TEMPLATE: CodexArtifactTemplate = {
12 artifactKind: "document",
13 displayName: "Hello World",
14 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
15 skillName: "artifact-template-hello-world",
16};
17
18describe("artifact template presentation", () => {
19 it("shares labels and copy text across clients", () => {
20 expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template");
21 });
22});
23
24describe("resolveCodexArtifactTemplate", () => {
25 it("accepts the template metadata emitted by Codex", () => {
26 expect(
27 resolveCodexArtifactTemplate({
28 artifact_kind: "document",
29 display_name: " Hello World ",
30 skill_directory: "/Users/test/.codex/skills/artifact-template-hello-world",
31 skill_name: "artifact-template-hello-world",
32 }),
33 ).toEqual(HELLO_WORLD_TEMPLATE);
34 });
35
36 it.each([
37 String.raw`C:\Users\test\.codex\skills\artifact-template-hello-world`,
38 String.raw`\\server\share\artifact-template-hello-world`,
39 "//server/share/artifact-template-hello-world",
40 ])("accepts absolute Windows skill directories: %s", (skillDirectory) => {
41 expect(
42 resolveCodexArtifactTemplate({
43 artifact_kind: "image",
44 display_name: "Reference image",
45 gallery_kind: "product-design",
46 skill_directory: skillDirectory,
47 skill_name: "artifact-template-reference-image",
48 }),
49 ).toMatchObject({
50 artifactKind: "image",
51 galleryKind: "product-design",
52 skillDirectory,
53 });
54 });
55
56 it.each([
57 { artifact_kind: "unknown" },
58 { display_name: " " },
59 { skill_directory: "relative/template" },
60 { skill_name: "hello-world" },
61 { gallery_kind: null },
62 { gallery_kind: "unknown" },
63 ])("rejects malformed template metadata: %o", (override) => {
64 expect(
65 resolveCodexArtifactTemplate({
66 artifact_kind: "document",
67 display_name: "Hello World",
68 skill_directory: "/templates/hello-world",
69 skill_name: "artifact-template-hello-world",
70 ...override,
71 }),
72 ).toBeNull();
73 });
74});
75
76describe("codexArtifactTemplateUsePrompt", () => {
77 it("builds the same document follow-up shape as Codex", () => {
78 expect(codexArtifactTemplateUsePrompt(HELLO_WORLD_TEMPLATE)).toBe(
79 "Create a document using this $artifact-template-hello-world about…",
80 );
81 });
82
83 it("uses the artifact-specific image wording", () => {
84 expect(
85 codexArtifactTemplateUsePrompt({
86 ...HELLO_WORLD_TEMPLATE,
87 artifactKind: "image",
88 }),
89 ).toBe("Create an image using this $artifact-template-hello-world of…");
90 });
91});
92
93describe("appendCodexArtifactTemplateUsePrompt", () => {
94 const prompt = "Create a document using this $artifact-template-hello-world about…";
95
96 it("adds the prompt to an empty draft", () => {
97 expect(appendCodexArtifactTemplateUsePrompt("", HELLO_WORLD_TEMPLATE)).toBe(prompt);
98 });
99
100 it("preserves existing draft text", () => {
101 expect(appendCodexArtifactTemplateUsePrompt("Write about otters", HELLO_WORLD_TEMPLATE)).toBe(
102 `Write about otters ${prompt}`,
103 );
104 });
105
106 it.each([prompt, `${prompt}\n`, `Notes\n\n${prompt}`])(
107 "does not append the same final prompt twice: %s",
108 (draft) => {
109 expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(draft);
110 },
111 );
112
113 it("does not mistake text containing the prompt for a final prompt", () => {
114 const draft = `${prompt}\nAdditional instructions`;
115 expect(appendCodexArtifactTemplateUsePrompt(draft, HELLO_WORLD_TEMPLATE)).toBe(
116 `${draft} ${prompt}`,
117 );
118 });
119});
packages/shared/src/codexArtifactTemplates.ts created+136
......@@ -0,0 +1,136 @@
1const CODEX_ARTIFACT_TEMPLATE_KINDS = [
2 "document",
3 "presentation",
4 "spreadsheet",
5 "site",
6 "google-docs",
7 "google-slides",
8 "google-sheets",
9 "image",
10 "email",
11 "slack",
12] as const;
13
14export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number];
15
16const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const;
17
18export type CodexArtifactTemplateGalleryKind =
19 (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number];
20
21export interface CodexArtifactTemplate {
22 readonly artifactKind: CodexArtifactTemplateKind;
23 readonly displayName: string;
24 readonly galleryKind?: CodexArtifactTemplateGalleryKind;
25 readonly skillDirectory: string;
26 readonly skillName: string;
27}
28
29const CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND = {
30 document: "Document template",
31 presentation: "Presentation template",
32 spreadsheet: "Spreadsheet template",
33 site: "Site template",
34 "google-docs": "Google Doc template",
35 "google-slides": "Google Slides template",
36 "google-sheets": "Google Sheet template",
37 image: "Image template",
38 email: "Email template",
39 slack: "Slack template",
40} satisfies Record<CodexArtifactTemplateKind, string>;
41
42export type CodexArtifactTemplateAttributes = Readonly<Record<string, string | null | undefined>>;
43
44const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/;
45const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/;
46
47function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind {
48 return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value);
49}
50
51function isCodexArtifactTemplateGalleryKind(
52 value: unknown,
53): value is CodexArtifactTemplateGalleryKind {
54 return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value);
55}
56
57function isAbsoluteSkillDirectory(value: string): boolean {
58 return (
59 (value.startsWith("/") && !value.startsWith("//")) ||
60 WINDOWS_DRIVE_PATH_REGEX.test(value) ||
61 WINDOWS_UNC_PATH_REGEX.test(value)
62 );
63}
64
65/** Mirrors the Codex result-card schema so malformed directives remain literal Markdown. */
66export function resolveCodexArtifactTemplate(
67 attributes: CodexArtifactTemplateAttributes | null | undefined,
68): CodexArtifactTemplate | null {
69 const artifactKind = attributes?.artifact_kind;
70 const displayNameValue = attributes?.display_name;
71 const displayName = typeof displayNameValue === "string" ? displayNameValue.trim() : undefined;
72 const galleryKind = attributes?.gallery_kind;
73 const skillDirectory = attributes?.skill_directory;
74 const skillName = attributes?.skill_name;
75
76 if (
77 !isCodexArtifactTemplateKind(artifactKind) ||
78 !displayName ||
79 typeof skillDirectory !== "string" ||
80 !isAbsoluteSkillDirectory(skillDirectory) ||
81 typeof skillName !== "string" ||
82 !skillName.startsWith("artifact-template-") ||
83 (galleryKind !== undefined && !isCodexArtifactTemplateGalleryKind(galleryKind))
84 ) {
85 return null;
86 }
87
88 return {
89 artifactKind,
90 displayName,
91 ...(galleryKind === undefined ? {} : { galleryKind }),
92 skillDirectory,
93 skillName,
94 };
95}
96
97const USE_PROMPT_BY_KIND: Record<CodexArtifactTemplateKind, (skill: string) => string> = {
98 document: (skill) => `Create a document using this ${skill} about…`,
99 presentation: (skill) => `Create a presentation using the ${skill} template about…`,
100 spreadsheet: (skill) => `Create a spreadsheet using this ${skill} about…`,
101 site: (skill) => `Create a Site using this ${skill} about…`,
102 "google-docs": (skill) => `Create a Google Doc using this ${skill} about…`,
103 "google-slides": (skill) => `Create a Google Slides presentation using this ${skill} about…`,
104 "google-sheets": (skill) => `Create a Google Sheet using this ${skill} about…`,
105 image: (skill) => `Create an image using this ${skill} of…`,
106 email: (skill) => `Draft an email using this ${skill} about…`,
107 slack: (skill) => `Draft a Slack message using this ${skill} about…`,
108};
109
110export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string {
111 return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`);
112}
113
114export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string {
115 return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind];
116}
117
118export function appendCodexArtifactTemplateUsePrompt(
119 draft: string,
120 template: CodexArtifactTemplate,
121): string {
122 const prompt = codexArtifactTemplateUsePrompt(template);
123 const trimmedDraft = draft.trimEnd();
124 const promptStart = trimmedDraft.length - prompt.length;
125 const alreadyEndsWithPrompt =
126 promptStart >= 0 &&
127 trimmedDraft.slice(promptStart) === prompt &&
128 (promptStart === 0 || /\s/.test(trimmedDraft[promptStart - 1] ?? ""));
129
130 if (alreadyEndsWithPrompt) {
131 return draft;
132 }
133
134 const needsLeadingSpace = draft.length > 0 && !/\s$/.test(draft);
135 return `${draft}${needsLeadingSpace ? " " : ""}${prompt}`;
136}
packages/shared/src/codexFileCitations.test.ts created+77
......@@ -0,0 +1,77 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts";
4
5describe("resolveCodexFileCitationLink", () => {
6 it("resolves the attributes emitted by Codex", () => {
7 expect(
8 resolveCodexFileCitationLink({
9 path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
10 purpose: "output",
11 }),
12 ).toEqual({
13 path: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
14 href: "/workspace/outputs/issue-2387-sparse-diagonal.xlsx",
15 label: "issue-2387-sparse-diagonal.xlsx",
16 });
17 });
18
19 it("carries the first cited line into the file href", () => {
20 expect(
21 resolveCodexFileCitationLink({
22 path: "src/main.ts",
23 line_range_start: "42",
24 line_range_end: "48",
25 git_url: "https://example.com/main.ts",
26 }),
27 ).toEqual({
28 path: "src/main.ts",
29 href: "src/main.ts#L42",
30 label: "main.ts",
31 lineRangeStart: 42,
32 });
33 });
34
35 it("rejects missing paths and invalid line numbers", () => {
36 expect(resolveCodexFileCitationLink({ purpose: "output" })).toBeNull();
37 expect(
38 resolveCodexFileCitationLink({ path: "src/main.ts", line_range_start: "not-a-line" }),
39 ).toEqual({
40 path: "src/main.ts",
41 href: "src/main.ts",
42 label: "main.ts",
43 });
44 });
45
46 it("preserves URL syntax characters in file paths", () => {
47 expect(
48 resolveCodexFileCitationLink({
49 path: "reports/100% #1? draft.md",
50 line_range_start: "7",
51 }),
52 ).toEqual({
53 path: "reports/100% #1? draft.md",
54 href: "reports/100%25 %231%3F draft.md#L7",
55 label: "100% #1? draft.md",
56 lineRangeStart: 7,
57 });
58 });
59});
60
61describe("codexFileCitationMarkdown", () => {
62 it("produces a portable Markdown link", () => {
63 const citation = resolveCodexFileCitationLink({ path: "reports/profit and loss.xlsx" });
64 expect(citation && codexFileCitationMarkdown(citation)).toBe(
65 "[profit and loss.xlsx](<reports/profit and loss.xlsx>)",
66 );
67 });
68
69 it("escapes Markdown syntax in the visible filename", () => {
70 const citation = resolveCodexFileCitationLink({
71 path: "reports/*draft*_[copy]`<&.txt",
72 });
73 expect(citation && codexFileCitationMarkdown(citation)).toBe(
74 "[\\*draft\\*\\_\\[copy\\]\\`\\<\\&.txt](<reports/*draft*_[copy]`%3C&.txt>)",
75 );
76 });
77});
packages/shared/src/codexFileCitations.ts created+56
......@@ -0,0 +1,56 @@
1export interface CodexFileCitationLink {
2 readonly path: string;
3 readonly href: string;
4 readonly label: string;
5 readonly lineRangeStart?: number;
6}
7
8export type CodexFileCitationAttributes = Readonly<Record<string, string | null | undefined>>;
9
10function positiveInteger(value: string | null | undefined): number | undefined {
11 if (value === null || value === undefined || value.trim().length === 0) return undefined;
12 const parsed = Number(value);
13 return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
14}
15
16function citationLabel(path: string): string {
17 const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
18 return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File";
19}
20
21function markdownDestinationPath(path: string): string {
22 return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F");
23}
24
25export function resolveCodexFileCitationLink(
26 attributes: CodexFileCitationAttributes | null | undefined,
27): CodexFileCitationLink | null {
28 const path = attributes?.path?.trim();
29 if (!path) return null;
30
31 const lineRangeStart = positiveInteger(attributes?.line_range_start);
32 const destinationPath = markdownDestinationPath(path);
33 return {
34 path,
35 href: lineRangeStart === undefined ? destinationPath : `${destinationPath}#L${lineRangeStart}`,
36 label: citationLabel(path),
37 ...(lineRangeStart === undefined ? {} : { lineRangeStart }),
38 };
39}
40
41function markdownLabel(value: string): string {
42 return value.replace(/[\\[\]*_`<&]/g, "\\$&");
43}
44
45function markdownDestination(value: string): string {
46 return value
47 .replaceAll("\\", "%5C")
48 .replaceAll("<", "%3C")
49 .replaceAll(">", "%3E")
50 .replaceAll("\r", "%0D")
51 .replaceAll("\n", "%0A");
52}
53
54export function codexFileCitationMarkdown(citation: CodexFileCitationLink): string {
55 return `[${markdownLabel(citation.label)}](<${markdownDestination(citation.href)}>)`;
56}
packages/shared/src/codexMarkdownDirectives.test.ts created+175
......@@ -0,0 +1,175 @@
1import { describe, expect, it } from "vite-plus/test";
2import remarkParse from "remark-parse";
3import { unified } from "unified";
4
5import {
6 remarkCodexDirectives,
7 renderCodexDirectivesForCopy,
8 renderCodexFileCitationsAsMarkdown,
9 splitCodexArtifactTemplateMarkdown,
10} from "./codexMarkdownDirectives.ts";
11import { parseMarkdownFileLink } from "./markdownLinks.ts";
12
13interface TestNode {
14 readonly type: string;
15 readonly value?: string;
16 readonly url?: string;
17 readonly position?: {
18 readonly start: { readonly offset?: number };
19 readonly end: { readonly offset?: number };
20 };
21 readonly data?: {
22 readonly hName?: string;
23 readonly hProperties?: Readonly<Record<string, unknown>>;
24 };
25 readonly children?: readonly TestNode[];
26}
27
28const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}';
29const ARTIFACT_TEMPLATE =
30 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}';
31
32function parse(markdown: string): TestNode {
33 const processor = unified().use(remarkParse).use(remarkCodexDirectives);
34 return processor.runSync(processor.parse(markdown), { value: markdown }) as TestNode;
35}
36
37function parseOrdinaryMarkdown(markdown: string): TestNode {
38 return unified().use(remarkParse).parse(markdown) as TestNode;
39}
40
41describe("remarkCodexDirectives", () => {
42 it("renders a file citation as a link without changing its source position", () => {
43 const markdown = `Created ${FILE_CITATION}.`;
44 const link = parse(markdown).children?.[0]?.children?.[1];
45
46 expect(link).toMatchObject({
47 type: "link",
48 url: "outputs/report.xlsx",
49 children: [{ type: "text", value: "report.xlsx" }],
50 position: {
51 start: { offset: markdown.indexOf(FILE_CITATION) },
52 end: { offset: markdown.indexOf(FILE_CITATION) + FILE_CITATION.length },
53 },
54 });
55 });
56
57 it("renders an artifact template as semantic block metadata", () => {
58 expect(parse(ARTIFACT_TEMPLATE).children?.[0]).toMatchObject({
59 type: "paragraph",
60 children: [],
61 data: {
62 hName: "div",
63 hProperties: {
64 dataCodexArtifactTemplate: "true",
65 dataArtifactKind: "document",
66 dataDisplayName: "Hello World",
67 dataSkillName: "artifact-template-hello-world",
68 },
69 },
70 });
71 });
72
73 it.each([
74 "Meeting at 10:30",
75 "Open src/main.ts:42",
76 "Use :hover and :tada:",
77 "::note",
78 ":::note\ncontent\n:::",
79 ':codex-file-citation-extra{path="outputs/report.xlsx"}',
80 "::artifact-template-extra",
81 ])("does not change unrelated colon syntax: %s", (markdown) => {
82 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
83 });
84
85 it.each([
86 ':codex-file-citation{purpose="output"}',
87 '::artifact-template{skill_name="artifact-template-hello-world"}',
88 ])("keeps malformed supported directives literal: %s", (markdown) => {
89 expect(parse(markdown)).toEqual(parseOrdinaryMarkdown(markdown));
90 });
91});
92
93describe.each([
94 { name: "renderCodexDirectivesForCopy", render: renderCodexDirectivesForCopy },
95 { name: "renderCodexFileCitationsAsMarkdown", render: renderCodexFileCitationsAsMarkdown },
96])("$name file citation round trips", ({ render }) => {
97 it.each([
98 "C:\\Users\\test\\[draft]\\report.md",
99 "\\\\server\\share\\report.md",
100 "outputs/report.md",
101 "/tmp/report%5C.md",
102 ])("preserves the literal path and line: %s", (path) => {
103 const markdown = render(`:codex-file-citation{path="${path}" line_range_start="7"}`);
104 const link = parseOrdinaryMarkdown(markdown).children?.[0]?.children?.[0];
105
106 expect(link?.type).toBe("link");
107 expect(parseMarkdownFileLink(link?.url ?? "")).toEqual({ path, line: 7 });
108 });
109});
110
111describe("native Markdown adapters", () => {
112 it("uses the same parser to render file citations as portable links", () => {
113 expect(renderCodexFileCitationsAsMarkdown(`Created ${FILE_CITATION}.`)).toBe(
114 "Created [report.xlsx](<outputs/report.xlsx>).",
115 );
116 });
117
118 it.each([
119 `\\${FILE_CITATION}`,
120 `\`${FILE_CITATION}\``,
121 `\`\`\`text\n${FILE_CITATION}\n\`\`\``,
122 `[See ${FILE_CITATION}](https://example.com)`,
123 ])("does not render excluded citation syntax: %s", (markdown) => {
124 expect(renderCodexFileCitationsAsMarkdown(markdown)).toBe(markdown);
125 });
126
127 it("splits artifact cards from surrounding native Markdown", () => {
128 expect(splitCodexArtifactTemplateMarkdown(`Before\n\n${ARTIFACT_TEMPLATE}\n\nAfter`)).toEqual([
129 { kind: "markdown", markdown: "Before\n\n", sourceOffset: 0 },
130 {
131 kind: "artifact-template",
132 sourceOffset: 8,
133 template: {
134 artifactKind: "document",
135 displayName: "Hello World",
136 skillDirectory: "/Users/test/.codex/skills/artifact-template-hello-world",
137 skillName: "artifact-template-hello-world",
138 },
139 },
140 {
141 kind: "markdown",
142 markdown: "\n\nAfter",
143 sourceOffset: 8 + ARTIFACT_TEMPLATE.length,
144 },
145 ]);
146 });
147
148 it("leaves malformed and code artifact-template examples in Markdown", () => {
149 const malformed = '::artifact-template{display_name="Hello World"}';
150 const code = `\`${ARTIFACT_TEMPLATE}\``;
151 expect(splitCodexArtifactTemplateMarkdown(malformed)).toEqual([
152 { kind: "markdown", markdown: malformed, sourceOffset: 0 },
153 ]);
154 expect(splitCodexArtifactTemplateMarkdown(code)).toEqual([
155 { kind: "markdown", markdown: code, sourceOffset: 0 },
156 ]);
157 });
158});
159
160describe("directive copy adapter", () => {
161 it("copies the Markdown representations shown by citation chips and template cards", () => {
162 expect(renderCodexDirectivesForCopy(`Created ${FILE_CITATION}.\n\n${ARTIFACT_TEMPLATE}`)).toBe(
163 "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)",
164 );
165 });
166
167 it("leaves excluded and malformed directive source unchanged", () => {
168 const markdown = [
169 `\`${FILE_CITATION}\``,
170 '::artifact-template{display_name="Hello World"}',
171 ].join("\n\n");
172
173 expect(renderCodexDirectivesForCopy(markdown)).toBe(markdown);
174 });
175});
packages/shared/src/codexMarkdownDirectives.ts created+388
......@@ -0,0 +1,388 @@
1import { directiveFromMarkdown } from "mdast-util-directive";
2import { directive } from "micromark-extension-directive";
3import {
4 markdownLineEnding,
5 unicodePunctuation,
6 unicodeWhitespace,
7} from "micromark-util-character";
8import type { Construct, Extension, Tokenizer } from "micromark-util-types";
9import remarkParse from "remark-parse";
10import { unified, type Processor } from "unified";
11
12import {
13 codexArtifactTemplatePresentationLabel,
14 resolveCodexArtifactTemplate,
15 type CodexArtifactTemplate,
16} from "./codexArtifactTemplates.ts";
17import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts";
18
19const COLON = 58;
20const DASH = 45;
21const UNDERSCORE = 95;
22const CODEX_FILE_CITATION_NAME = "codex-file-citation";
23const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template";
24
25export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [
26 "dataCodexArtifactTemplate",
27 "dataArtifactKind",
28 "dataDisplayName",
29 "dataGalleryKind",
30 "dataSkillDirectory",
31 "dataSkillName",
32] as const;
33
34interface MarkdownPosition {
35 readonly start: { readonly offset?: number };
36 readonly end: { readonly offset?: number };
37}
38
39interface MarkdownAstNode {
40 type?: string;
41 name?: string;
42 value?: string;
43 url?: string;
44 attributes?: Readonly<Record<string, string | null>>;
45 position?: MarkdownPosition;
46 data?: {
47 codexArtifactTemplate?: CodexArtifactTemplate;
48 codexFileCitationMarkdown?: string;
49 hName?: string;
50 hProperties?: Record<string, unknown>;
51 };
52 children?: MarkdownAstNode[];
53}
54
55interface MarkdownFile {
56 readonly value: unknown;
57}
58
59export type CodexArtifactTemplateMarkdownSegment =
60 | { readonly kind: "markdown"; readonly markdown: string; readonly sourceOffset: number }
61 | {
62 readonly kind: "artifact-template";
63 readonly sourceOffset: number;
64 readonly template: CodexArtifactTemplate;
65 };
66
67function asConstruct(value: Construct | Construct[] | undefined, label: string): Construct {
68 const construct = Array.isArray(value) ? value[0] : value;
69 if (!construct) throw new Error(`Missing ${label} directive construct`);
70 return construct;
71}
72
73function directiveNameEnds(code: number | null): boolean {
74 return (
75 code === null ||
76 markdownLineEnding(code) ||
77 unicodeWhitespace(code) ||
78 (unicodePunctuation(code) && code !== DASH && code !== UNDERSCORE)
79 );
80}
81
82function directiveNameGate(markerCount: number, name: string): Construct {
83 const tokenize: Tokenizer = (effects, ok, nok) => {
84 let markerIndex = 0;
85 let nameIndex = 0;
86
87 return marker;
88
89 function marker(code: number | null) {
90 if (code !== COLON) return nok(code);
91 if (markerIndex === 0) effects.enter("data");
92 effects.consume(code);
93 markerIndex += 1;
94 return markerIndex === markerCount ? nameCharacter : marker;
95 }
96
97 function nameCharacter(code: number | null) {
98 if (code !== name.charCodeAt(nameIndex)) return nok(code);
99 effects.consume(code);
100 nameIndex += 1;
101 return nameIndex === name.length ? afterName : nameCharacter;
102 }
103
104 function afterName(code: number | null) {
105 effects.exit("data");
106 return directiveNameEnds(code) ? ok(code) : nok(code);
107 }
108 };
109
110 return { partial: true, tokenize };
111}
112
113function restrictedDirective(construct: Construct, markerCount: number, name: string): Construct {
114 const gate = directiveNameGate(markerCount, name);
115 return {
116 ...construct,
117 tokenize(effects, ok, nok) {
118 return effects.check(gate, construct.tokenize.call(this, effects, ok, nok), nok);
119 },
120 };
121}
122
123function codexDirectiveSyntax(): Extension {
124 const genericSyntax = directive();
125 const textDirective = asConstruct(genericSyntax.text?.[COLON], CODEX_FILE_CITATION_NAME);
126 const flowDirectives = genericSyntax.flow?.[COLON];
127 const leafDirective = Array.isArray(flowDirectives)
128 ? flowDirectives.find((construct) => construct.concrete !== true)
129 : flowDirectives;
130 if (!leafDirective)
131 throw new Error(`Missing ${CODEX_ARTIFACT_TEMPLATE_NAME} directive construct`);
132
133 return {
134 text: {
135 [COLON]: restrictedDirective(textDirective, 1, CODEX_FILE_CITATION_NAME),
136 },
137 flow: {
138 [COLON]: restrictedDirective(leafDirective, 2, CODEX_ARTIFACT_TEMPLATE_NAME),
139 },
140 };
141}
142
143const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax();
144const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown();
145
146function sourceForNode(node: MarkdownAstNode, source: string): string {
147 const start = node.position?.start.offset;
148 const end = node.position?.end.offset;
149 return start === undefined || end === undefined ? "" : source.slice(start, end);
150}
151
152function sourceForDirective(node: MarkdownAstNode, source: string, marker: ":" | "::"): string {
153 const prefix = `${marker}${node.name ?? ""}`;
154 const slicedSource = sourceForNode(node, source);
155 if (slicedSource.startsWith(prefix)) return slicedSource;
156
157 const attributes = Object.entries(node.attributes ?? {}).map(([name, value]) =>
158 value === null ? name : `${name}=${JSON.stringify(value)}`,
159 );
160 return `${prefix}${attributes.length === 0 ? "" : `{${attributes.join(" ")}}`}`;
161}
162
163function restoreTextDirective(node: MarkdownAstNode, source: string): void {
164 node.type = "text";
165 node.value = sourceForDirective(node, source, ":");
166 delete node.name;
167 delete node.attributes;
168 delete node.url;
169 delete node.data;
170 delete node.children;
171}
172
173function restoreLeafDirective(node: MarkdownAstNode, source: string): void {
174 const value = sourceForDirective(node, source, "::");
175 node.type = "paragraph";
176 node.children = [
177 { type: "text", value, ...(node.position === undefined ? {} : { position: node.position }) },
178 ];
179 delete node.name;
180 delete node.attributes;
181 delete node.value;
182 delete node.url;
183 delete node.data;
184}
185
186function renderFileCitation(node: MarkdownAstNode, source: string, insideLink: boolean): void {
187 const citation = resolveCodexFileCitationLink(node.attributes);
188 if (!citation || insideLink) {
189 restoreTextDirective(node, source);
190 return;
191 }
192
193 node.type = "link";
194 node.url = citation.href;
195 node.children = [{ type: "text", value: citation.label }];
196 node.data = { codexFileCitationMarkdown: codexFileCitationMarkdown(citation) };
197 delete node.name;
198 delete node.attributes;
199 delete node.value;
200}
201
202function renderArtifactTemplate(node: MarkdownAstNode, source: string): void {
203 const template = resolveCodexArtifactTemplate(node.attributes);
204 if (!template) {
205 restoreLeafDirective(node, source);
206 return;
207 }
208
209 node.type = "paragraph";
210 node.children = [];
211 node.data = {
212 codexArtifactTemplate: template,
213 hName: "div",
214 hProperties: {
215 dataCodexArtifactTemplate: "true",
216 dataArtifactKind: template.artifactKind,
217 dataDisplayName: template.displayName,
218 ...(template.galleryKind === undefined ? {} : { dataGalleryKind: template.galleryKind }),
219 dataSkillDirectory: template.skillDirectory,
220 dataSkillName: template.skillName,
221 },
222 };
223 delete node.name;
224 delete node.attributes;
225 delete node.value;
226 delete node.url;
227}
228
229function transformCodexDirectives(node: MarkdownAstNode, source: string, insideLink = false): void {
230 if (node.type === "textDirective" && node.name === CODEX_FILE_CITATION_NAME) {
231 renderFileCitation(node, source, insideLink);
232 return;
233 }
234 if (node.type === "leafDirective" && node.name === CODEX_ARTIFACT_TEMPLATE_NAME) {
235 renderArtifactTemplate(node, source);
236 return;
237 }
238
239 const childrenInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
240 for (const child of node.children ?? []) {
241 transformCodexDirectives(child, source, childrenInsideLink);
242 }
243}
244
245/** Adds grammar only for the two directives emitted by Codex, then renders them as mdast. */
246function attachCodexDirectives(this: Processor) {
247 const data = this.data();
248 const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []);
249 const fromMarkdownExtensions = data.fromMarkdownExtensions ?? (data.fromMarkdownExtensions = []);
250 micromarkExtensions.push(CODEX_DIRECTIVE_SYNTAX);
251 fromMarkdownExtensions.push(CODEX_DIRECTIVE_FROM_MARKDOWN);
252
253 return (tree: unknown, file: MarkdownFile) => {
254 transformCodexDirectives(tree as MarkdownAstNode, String(file.value));
255 };
256}
257
258export const remarkCodexDirectives = attachCodexDirectives;
259
260const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze();
261
262function parseCodexMarkdown(markdown: string): MarkdownAstNode {
263 return directiveParser.runSync(directiveParser.parse(markdown), {
264 value: markdown,
265 }) as MarkdownAstNode;
266}
267
268interface DirectiveMatch {
269 readonly start: number;
270 readonly end: number;
271 readonly markdown?: string;
272 readonly template?: CodexArtifactTemplate;
273}
274
275function collectDirectiveMatches(node: MarkdownAstNode, matches: DirectiveMatch[]): void {
276 const start = node.position?.start.offset;
277 const end = node.position?.end.offset;
278 if (start !== undefined && end !== undefined) {
279 if (node.data?.codexFileCitationMarkdown !== undefined) {
280 matches.push({ start, end, markdown: node.data.codexFileCitationMarkdown });
281 return;
282 }
283 if (node.data?.codexArtifactTemplate !== undefined) {
284 matches.push({ start, end, template: node.data.codexArtifactTemplate });
285 return;
286 }
287 }
288 for (const child of node.children ?? []) collectDirectiveMatches(child, matches);
289}
290
291function renderDirectiveMatches(
292 markdown: string,
293 replacementFor: (match: DirectiveMatch) => string | undefined,
294): string {
295 const matches: DirectiveMatch[] = [];
296 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
297 let rendered = markdown;
298 for (const match of matches.sort((left, right) => right.start - left.start)) {
299 const replacement = replacementFor(match);
300 if (replacement !== undefined) {
301 rendered = rendered.slice(0, match.start) + replacement + rendered.slice(match.end);
302 }
303 }
304 return rendered;
305}
306
307/** Native Markdown renderers use this adapter because they cannot consume a Remark tree. */
308export function renderCodexFileCitationsAsMarkdown(markdown: string): string {
309 if (!markdown.includes(`:${CODEX_FILE_CITATION_NAME}`)) return markdown;
310
311 return renderDirectiveMatches(markdown, (match) => match.markdown);
312}
313
314/** Matches the Markdown emitted when users copy rendered Codex directive UI. */
315export function renderCodexDirectivesForCopy(markdown: string): string {
316 if (
317 !markdown.includes(`:${CODEX_FILE_CITATION_NAME}`) &&
318 !markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)
319 ) {
320 return markdown;
321 }
322
323 return renderDirectiveMatches(markdown, (match) => {
324 if (match.markdown !== undefined) return match.markdown;
325 if (match.template === undefined) return undefined;
326 return `${match.template.displayName} (${codexArtifactTemplatePresentationLabel(match.template.artifactKind)})`;
327 });
328}
329
330/** Native renderers split cards out because they cannot host a view inside Markdown text. */
331export function splitCodexArtifactTemplateMarkdown(
332 markdown: string,
333): ReadonlyArray<CodexArtifactTemplateMarkdownSegment> {
334 if (!markdown.includes(`::${CODEX_ARTIFACT_TEMPLATE_NAME}`)) {
335 return [{ kind: "markdown", markdown, sourceOffset: 0 }];
336 }
337
338 const matches: DirectiveMatch[] = [];
339 collectDirectiveMatches(parseCodexMarkdown(markdown), matches);
340 const templates = matches
341 .filter(
342 (match): match is DirectiveMatch & { readonly template: CodexArtifactTemplate } =>
343 match.template !== undefined,
344 )
345 .sort((left, right) => left.start - right.start);
346 if (templates.length === 0) {
347 return [{ kind: "markdown", markdown, sourceOffset: 0 }];
348 }
349
350 const segments: CodexArtifactTemplateMarkdownSegment[] = [];
351 let cursor = 0;
352 for (const match of templates) {
353 if (match.start > cursor) {
354 segments.push({
355 kind: "markdown",
356 markdown: markdown.slice(cursor, match.start),
357 sourceOffset: cursor,
358 });
359 }
360 segments.push({
361 kind: "artifact-template",
362 sourceOffset: match.start,
363 template: match.template,
364 });
365 cursor = match.end;
366 }
367 if (cursor < markdown.length) {
368 segments.push({ kind: "markdown", markdown: markdown.slice(cursor), sourceOffset: cursor });
369 }
370 return segments;
371}
372
373export function artifactTemplateFromHastProperties(
374 properties: Readonly<Record<string, unknown>> | null | undefined,
375): CodexArtifactTemplate | null {
376 if (properties?.dataCodexArtifactTemplate !== "true") return null;
377 const stringProperty = (name: string) => {
378 const value = properties[name];
379 return typeof value === "string" ? value : undefined;
380 };
381 return resolveCodexArtifactTemplate({
382 artifact_kind: stringProperty("dataArtifactKind"),
383 display_name: stringProperty("dataDisplayName"),
384 gallery_kind: stringProperty("dataGalleryKind"),
385 skill_directory: stringProperty("dataSkillDirectory"),
386 skill_name: stringProperty("dataSkillName"),
387 });
388}
packages/shared/src/composerContextLegacy.ts+4-2
......@@ -24,8 +24,10 @@ export interface UpgradedLegacyContext {
2424}
2525
2626const PLACEHOLDER = "";
27const TRAILING_TERMINAL = /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/;
28const TRAILING_ELEMENT = /\n*<element_context>\n([\s\S]*?)\n<\/element_context>\s*$/;
27const TRAILING_TERMINAL =
28 /\n*<terminal_context>\n((?:(?!\n<\/terminal_context>)[\s\S])*)\n<\/terminal_context>\s*$/;
29const TRAILING_ELEMENT =
30 /\n*<element_context>\n((?:(?!\n<\/element_context>)[\s\S])*)\n<\/element_context>\s*$/;
2931const TRAILING_PREVIEW =
3032 /\n*<preview_annotation>\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/;
3133const REVIEW_OR_CONTEXT_BLOCK =
packages/shared/src/fileLinks.test.ts created+72
......@@ -0,0 +1,72 @@
1import { describe, expect, it } from "vite-plus/test";
2import { parseFileUrlHref, splitFilePathPosition, resolvePathLinkTarget } from "./fileLinks.ts";
3
4describe("parseFileUrlHref", () => {
5 it.each([
6 ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"],
7 [
8 "file:///D:/Programme/t3code/OpenInPicker.tsx#L69",
9 "D:/Programme/t3code/OpenInPicker.tsx",
10 "#L69",
11 ],
12 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""],
13 ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""],
14 ])("parses %s", (href, path, hash) => {
15 expect(parseFileUrlHref(href)).toEqual({ path, hash });
16 });
17
18 it("keeps percent-encoding so the caller decodes once", () => {
19 expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe(
20 "/Users/julius/project/file%2520name.md",
21 );
22 expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png");
23 });
24
25 it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => {
26 expect(parseFileUrlHref(href)).toBeNull();
27 });
28});
29
30describe("splitFilePathPosition", () => {
31 it.each([
32 ["src/main.ts", "", { path: "src/main.ts" }],
33 ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }],
34 ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }],
35 ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }],
36 ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }],
37 ["src/main.ts:0", "", { path: "src/main.ts" }],
38 ["src/main.ts", "#section", { path: "src/main.ts" }],
39 ])("splits %s%s", (path, hash, expected) => {
40 expect(splitFilePathPosition(path, hash)).toEqual(expected);
41 });
42});
43
44describe("resolvePathLinkTarget", () => {
45 it("resolves relative paths against cwd", () => {
46 expect(
47 resolvePathLinkTarget(
48 "src/components/ThreadTerminalDrawer.tsx:42:7",
49 "/Users/julius/project",
50 ),
51 ).toBe("/Users/julius/project/src/components/ThreadTerminalDrawer.tsx:42:7");
52 });
53
54 it("keeps absolute paths unchanged", () => {
55 expect(
56 resolvePathLinkTarget("/Users/julius/project/src/main.ts:12", "/Users/julius/project"),
57 ).toBe("/Users/julius/project/src/main.ts:12");
58 });
59
60 it("keeps Windows absolute paths with forward slashes unchanged", () => {
61 expect(
62 resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"),
63 ).toBe("C:/Users/julius/project/src/main.ts:12");
64 });
65
66 it.each([
67 ["C:\\Users\\julius\\project", "C:\\Users\\julius\\notes.md:3"],
68 ["C:/Users/julius/project", "C:/Users/julius\\notes.md:3"],
69 ])("resolves home paths against the Windows cwd %s", (cwd, expected) => {
70 expect(resolvePathLinkTarget("~/notes.md:3", cwd)).toBe(expected);
71 });
72});
packages/shared/src/fileLinks.ts created+183
......@@ -0,0 +1,183 @@
1import {
2 fileBasename,
3 isAbsolutePath,
4 isWindowsAbsolutePath,
5 stripSlashPrefixedWindowsDrive,
6} from "./path.ts";
7
8const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/;
9
10const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i;
11
12/**
13 * Turns a `file:` URL into a host path, still percent-encoded so callers that
14 * decode every destination in one place do not decode file URLs twice. A
15 * non-localhost authority becomes a UNC share.
16 */
17export function parseFileUrlHref(
18 href: string,
19): { readonly path: string; readonly hash: string } | null {
20 try {
21 const parsed = new URL(href);
22 if (parsed.protocol.toLowerCase() !== "file:") return null;
23
24 const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname;
25 const path = uncHostname
26 ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}`
27 : parsed.pathname;
28 if (path.length === 0) return null;
29 return { path: stripSlashPrefixedWindowsDrive(path), hash: parsed.hash };
30 } catch {
31 return null;
32 }
33}
34
35export interface FilePathPosition {
36 readonly path: string;
37 readonly line?: number;
38 readonly column?: number;
39}
40
41export function splitFilePathPosition(path: string, hash = ""): FilePathPosition {
42 const suffixMatch = path.match(POSITION_SUFFIX_CAPTURE_PATTERN);
43 const match = suffixMatch ?? hash.match(POSITION_HASH_PATTERN);
44 if (!match?.[1]) return { path };
45
46 const line = Number.parseInt(match[1], 10);
47 const column = match[2] === undefined ? undefined : Number.parseInt(match[2], 10);
48 return {
49 path: suffixMatch ? path.slice(0, -suffixMatch[0].length) : path,
50 ...(line > 0 ? { line } : {}),
51 ...(column !== undefined && column > 0 ? { column } : {}),
52 };
53}
54
55export function formatFilePathPosition(position: FilePathPosition): string {
56 if (!position.line) return position.path;
57 return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`;
58}
59
60function isWindowsPathStyle(value: string): boolean {
61 return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value);
62}
63
64function joinPath(base: string, next: string, separator: "/" | "\\"): string {
65 const cleanBase = base.replace(/[\\/]+$/, "");
66 if (separator === "\\") {
67 return `${cleanBase}\\${next.replaceAll("/", "\\")}`;
68 }
69 return `${cleanBase}/${next.replace(/^\/+/, "")}`;
70}
71
72function inferHomeFromCwd(cwd: string): string | undefined {
73 const posixUser = cwd.match(/^\/Users\/([^/]+)/);
74 if (posixUser?.[1]) {
75 return `/Users/${posixUser[1]}`;
76 }
77
78 const posixHome = cwd.match(/^\/home\/([^/]+)/);
79 if (posixHome?.[1]) {
80 return `/home/${posixHome[1]}`;
81 }
82
83 const windowsUser = cwd.match(/^([A-Za-z]:[\\/]Users[\\/][^\\/]+)/);
84 if (windowsUser?.[1]) {
85 return windowsUser[1];
86 }
87
88 return undefined;
89}
90
91export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
92 const position = splitFilePathPosition(rawPath);
93 const { path } = position;
94
95 let resolvedPath = path;
96 if (path.startsWith("~/")) {
97 const home = inferHomeFromCwd(cwd);
98 if (home) {
99 const separator: "/" | "\\" = isWindowsPathStyle(home) ? "\\" : "/";
100 resolvedPath = joinPath(home, path.slice(2), separator);
101 }
102 } else if (!isAbsolutePath(path)) {
103 const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/";
104 resolvedPath = joinPath(cwd, path, separator);
105 }
106
107 return formatFilePathPosition({ ...position, path: resolvedPath });
108}
109
110function pathParentSegments(path: string): string[] {
111 const normalized = path.replaceAll("\\", "/");
112 const segments = normalized.split("/").filter((segment) => segment.length > 0);
113 return segments.slice(0, -1);
114}
115
116export function buildFileLinkParentSuffixByPath(
117 filePaths: ReadonlyArray<string>,
118): Map<string, string> {
119 const groups = new Map<string, Set<string>>();
120 for (const filePath of filePaths) {
121 const normalizedPath = filePath.replaceAll("\\", "/");
122 const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0);
123 const basename = pathSegments[pathSegments.length - 1];
124 if (!basename) continue;
125 const group = groups.get(basename) ?? new Set<string>();
126 group.add(normalizedPath);
127 groups.set(basename, group);
128 }
129
130 const suffixByPath = new Map<string, string>();
131 for (const group of groups.values()) {
132 const uniquePaths = [...group];
133 if (uniquePaths.length < 2) continue;
134
135 const parentSegmentsByPath = new Map(
136 uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]),
137 );
138 const minUniqueDepthByPath = new Map<string, number>();
139
140 for (const filePath of uniquePaths) {
141 const segments = parentSegmentsByPath.get(filePath) ?? [];
142 let resolvedDepth = segments.length;
143 for (let depth = 1; depth <= segments.length; depth += 1) {
144 const candidate = segments.slice(-depth).join("/");
145 const collision = uniquePaths.some((otherPath) => {
146 if (otherPath === filePath) return false;
147 const otherSegments = parentSegmentsByPath.get(otherPath) ?? [];
148 return otherSegments.slice(-depth).join("/") === candidate;
149 });
150 if (!collision) {
151 resolvedDepth = depth;
152 break;
153 }
154 }
155 minUniqueDepthByPath.set(filePath, resolvedDepth);
156 }
157
158 for (const filePath of uniquePaths) {
159 const segments = parentSegmentsByPath.get(filePath) ?? [];
160 if (segments.length === 0) continue;
161 const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1;
162 const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2));
163 suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/"));
164 }
165 }
166
167 return suffixByPath;
168}
169
170/** Visible text of a file chip, shared by Markdown rendering and thread search. */
171export function fileLinkLabel(
172 file: FilePathPosition,
173 parentSuffixByPath: ReadonlyMap<string, string>,
174): string {
175 const suffix = parentSuffixByPath.get(file.path.replaceAll("\\", "/"));
176 return [
177 fileBasename(file.path),
178 suffix,
179 file.line ? `L${file.line}${file.column ? `:C${file.column}` : ""}` : undefined,
180 ]
181 .filter(Boolean)
182 .join(" · ");
183}
packages/shared/src/inlineSkills.ts created+35
......@@ -0,0 +1,35 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2
3export type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
4
5function titleCaseWords(value: string): string {
6 const words: string[] = [];
7 for (const segment of value.split(/[\s:_-]+/)) {
8 if (segment.length === 0) continue;
9 words.push(segment.charAt(0).toUpperCase() + segment.slice(1));
10 }
11 return words.join(" ");
12}
13
14export function formatProviderSkillDisplayName(
15 skill: Pick<ServerProviderSkill, "name" | "displayName">,
16): string {
17 const displayName = skill.displayName?.trim();
18 if (displayName) {
19 return displayName;
20 }
21 return titleCaseWords(skill.name);
22}
23
24const SKILL_TOKEN_REGEX =
25 /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;
26
27export function* matchInlineSkills(text: string, skills: readonly InlineSkill[]) {
28 for (const match of text.matchAll(SKILL_TOKEN_REGEX)) {
29 const name = match[2] ?? "";
30 const skill = skills.find((candidate) => candidate.name === name);
31 if (!skill) continue;
32 const start = match.index + (match[1]?.length ?? 0);
33 yield { start, end: match.index + match[0].length, skill, rawText: `$${name}` };
34 }
35}
packages/shared/src/keybindings.ts+1
......@@ -171,6 +171,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
171171 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
172172 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
173173 { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" },
174 { key: "mod+f", command: "chat.find", when: "!terminalFocus && !previewFocus" },
174175 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
175176 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },
176177 { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },
packages/shared/src/markdownGithubAlerts.ts created+72
......@@ -0,0 +1,72 @@
1/**
2 * GitHub's blockquote alerts: a quote whose first line is `[!NOTE]` — or TIP, IMPORTANT,
3 * WARNING, CAUTION — renders as a titled callout. They are GitHub's own extension rather than
4 * GFM, so remark-gfm leaves the marker as literal text in the quote. This lifts the marker off
5 * the mdast into a `data-alert` attribute for the blockquote renderer to style, and removes
6 * the marker line itself.
7 *
8 * Only a marker with nothing after it on its own line counts, which is GitHub's rule:
9 * `> [!NOTE] aside` is an ordinary quote.
10 */
11
12interface MarkdownAstNode {
13 type?: string;
14 value?: unknown;
15 data?: {
16 hProperties?: Record<string, unknown>;
17 };
18 children?: MarkdownAstNode[];
19}
20
21const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i;
22
23function readGithubAlert(node: MarkdownAstNode): void {
24 if (node.type !== "blockquote") return;
25 const paragraph = node.children?.[0];
26 const text = paragraph?.children?.[0];
27 if (paragraph?.type !== "paragraph" || text?.type !== "text" || typeof text.value !== "string") {
28 return;
29 }
30 const match = GITHUB_ALERT_MARKER.exec(text.value);
31 if (!match?.[1]) return;
32
33 const remainder = text.value.slice(match[0].length);
34 // Whether the marker's own line ended inside this text node. Without it, an empty remainder
35 // is ambiguous: `[!NOTE]\n**bold**` parses as [text "[!NOTE]\n", strong] — next-line content
36 // in a sibling — while `[!NOTE]*aside*` parses the same way minus the newline.
37 const markerEndsItsLine = match[0].endsWith("\n");
38 if (remainder.length > 0) {
39 // The quote continues on the next line inside the same text node: keep the paragraph,
40 // shed the marker line.
41 text.value = remainder;
42 } else if (markerEndsItsLine || paragraph.children?.length === 1) {
43 // The marker was the whole text node — a marker-only paragraph, or a marker line whose
44 // next line starts as a sibling inline. Drop the node, and the paragraph if it empties.
45 paragraph.children?.shift();
46 if (paragraph.children?.length === 0) {
47 node.children?.shift();
48 }
49 } else {
50 // Something else shares the marker's line — `[!NOTE]*aside*` — which GitHub does not
51 // read as an alert.
52 return;
53 }
54
55 node.data = {
56 ...node.data,
57 hProperties: {
58 ...node.data?.hProperties,
59 dataAlert: match[1].toLowerCase(),
60 },
61 };
62}
63
64export function remarkGithubAlerts() {
65 return (tree: MarkdownAstNode) => {
66 const visit = (node: MarkdownAstNode) => {
67 node.children?.forEach(visit);
68 readGithubAlert(node);
69 };
70 visit(tree);
71 };
72}
packages/shared/src/markdownLinks.test.ts created+328
......@@ -0,0 +1,328 @@
1import { splitFilePathPosition } from "./fileLinks.ts";
2import { parseFileUrlHref } from "./fileLinks.ts";
3import { workspaceRelativeFilePath, fileBasename } from "./path.ts";
4import { describe, expect, it } from "vite-plus/test";
5import {
6 extractMarkdownLinkHrefs,
7 resolveMarkdownFileLinkTarget,
8 isWindowsDrivePathHref,
9} from "./markdownLinks.ts";
10
11import {
12 inlineCodeFilePathCandidate,
13 isMarkdownFileLinkLabel,
14 parseMarkdownFileLink,
15} from "./markdownLinks.ts";
16
17describe("isMarkdownFileLinkLabel", () => {
18 it.each([
19 ["validates the input", "/repo/src/example.ts:12", false],
20 ["read src/example.ts", "/repo/src/example.ts:12", false],
21 ["example.ts?why this matters", "/repo/src/example.ts", false],
22 ["example.ts", "/repo/src/example.ts:12", true],
23 ["example.ts:12", "/repo/src/example.ts:12", true],
24 ["example.ts:99", "/repo/src/example.ts:12", false],
25 ["example.ts:12:2", "/repo/src/example.ts:12:2", true],
26 ["example.ts:12:3", "/repo/src/example.ts:12:2", false],
27 ["example.ts:12", "/repo/src/example.ts", false],
28 ["src/example.ts:12", "/repo/src/example.ts:12", true],
29 ["./src/example.ts", "/repo/src/example.ts", true],
30 ["/repo/src/example.ts", "/repo/src/example.ts", true],
31 ["src/", "/home/me/project/src/", true],
32 ["EXAMPLE.TS", "C:/repo/src/example.ts:12", true],
33 ["file name.ts", "file:///repo/file%20name.ts", true],
34 ["", "/repo/src/example.ts", true],
35 ])("classifies %s for %s", (label, href, expected) => {
36 expect(isMarkdownFileLinkLabel(label, href)).toBe(expected);
37 });
38});
39
40describe("inlineCodeFilePathCandidate", () => {
41 it.each([
42 ["src\\main.ts", "src/main.ts"],
43 ["C:\\Users\\demo\\image.png", "C:\\Users\\demo\\image.png"],
44 ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
45 ["conf.d/nginx.conf", "conf.d/nginx.conf"],
46 ["script.pl:10", "script.pl:10"],
47 ["node.meta", null],
48 ["Recorded evidence here: /tmp/image.png", null],
49 ["origin/main", null],
50 ["127.0.0.1:3000", null],
51 ["example.com/index.html", null],
52 ["example.pl/index.html", null],
53 ["z-ai/glm-5.3", null],
54 ["z-ai/glm-5.3:12", null],
55 ["python/3.12", null],
56 ["Qwen/Qwen2.5-Coder", null],
57 ["meta-llama/Llama-3.1-8B", null],
58 ["share/man/ls.1", "share/man/ls.1"],
59 ["usr/lib/libfoo.so.1", "usr/lib/libfoo.so.1"],
60 ["vendor/jquery-3.6.0.min.js", "vendor/jquery-3.6.0.min.js"],
61 ["./models/glm-5.3", "./models/glm-5.3"],
62 ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => {
63 expect(inlineCodeFilePathCandidate(source)).toBe(candidate);
64 });
65});
66
67describe("parseFileUrlHref", () => {
68 it.each([
69 ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"],
70 [
71 "file:///D:/Programme/t3code/OpenInPicker.tsx#L69",
72 "D:/Programme/t3code/OpenInPicker.tsx",
73 "#L69",
74 ],
75 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg", ""],
76 ["file://localhost/home/me/notes.md", "/home/me/notes.md", ""],
77 ])("parses %s", (href, path, hash) => {
78 expect(parseFileUrlHref(href)).toEqual({ path, hash });
79 });
80
81 it("keeps percent-encoding so the caller decodes once", () => {
82 expect(parseFileUrlHref("file:///Users/julius/project/file%2520name.md")?.path).toBe(
83 "/Users/julius/project/file%2520name.md",
84 );
85 expect(parseFileUrlHref("file:///c%3A/Users/x/shot.png")?.path).toBe("/c%3A/Users/x/shot.png");
86 });
87
88 it.each(["https://example.com/a.ts", "file://%", "/Users/julius/a.ts"])("rejects %s", (href) => {
89 expect(parseFileUrlHref(href)).toBeNull();
90 });
91});
92
93describe("splitFilePathPosition", () => {
94 it.each([
95 ["src/main.ts", "", { path: "src/main.ts" }],
96 ["src/main.ts:12", "", { path: "src/main.ts", line: 12 }],
97 ["src/main.ts:12:5", "", { path: "src/main.ts", line: 12, column: 5 }],
98 ["src/main.ts", "#L18C2", { path: "src/main.ts", line: 18, column: 2 }],
99 ["src/main.ts:3", "#L18C2", { path: "src/main.ts", line: 3 }],
100 ["src/main.ts:0", "", { path: "src/main.ts" }],
101 ["src/main.ts", "#section", { path: "src/main.ts" }],
102 ])("splits %s%s", (path, hash, expected) => {
103 expect(splitFilePathPosition(path, hash)).toEqual(expected);
104 });
105});
106
107describe("parseMarkdownFileLink", () => {
108 // Both clients consume this table, so a path the web app recognizes is one
109 // the mobile app recognizes too.
110 it.each([
111 ["/Users/julius/project/AGENTS.md", "/Users/julius/project/AGENTS.md"],
112 ["/home/me/notes.md", "/home/me/notes.md"],
113 ["/usr/local/bin/tool", "/usr/local/bin/tool"],
114 ["/workspace/Makefile", "/workspace/Makefile"],
115 ["/tmp/favicons/", "/tmp/favicons/"],
116 ["C:\\Users\\mike\\project\\src\\main.ts", "C:\\Users\\mike\\project\\src\\main.ts"],
117 ["C:%5Crepo%5Cimage.png", "C:\\repo\\image.png"],
118 ["\\\\server\\share\\image.png", "\\\\server\\share\\image.png"],
119 ["/D:/Programme/t3code/OpenInPicker.tsx", "D:/Programme/t3code/OpenInPicker.tsx"],
120 ["</D:/Programme/t3code/ChatMarkdown.tsx:1>", "D:/Programme/t3code/ChatMarkdown.tsx"],
121 ["file:///Users/julius/project/file%2520name.md", "/Users/julius/project/file%20name.md"],
122 ["file://server/share/workspace-image.svg", "\\\\server\\share\\workspace-image.svg"],
123 ["file://localhost/home/me/notes.md", "/home/me/notes.md"],
124 ["apps/mobile/src/index.ts:10", "apps/mobile/src/index.ts"],
125 ["docs/My%20Folder/checklist.xml", "docs/My Folder/checklist.xml"],
126 ["Updated%20cutover%20checklist.md", "Updated cutover checklist.md"],
127 ["./scripts/deploy", "./scripts/deploy"],
128 ["~/notes/today.md", "~/notes/today.md"],
129 ["AGENTS.md", "AGENTS.md"],
130 ["script.ts:10", "script.ts"],
131 ["/tmp/clip%23one.mp4#t=2", "/tmp/clip#one.mp4"],
132 ])("recognizes %s as a file", (href, path) => {
133 expect(parseMarkdownFileLink(href)?.path).toBe(path);
134 });
135
136 it.each([
137 "",
138 "#anchor",
139 "//cdn.example.com/clip.mp4",
140 "https://example.com/docs",
141 "mailto:someone@example.com",
142 "javascript:alert(1)",
143 "/chat/settings",
144 "/chat/settings#L3",
145 "/app#L1",
146 "readme",
147 "TODO:12",
148 ])("does not treat %s as a file", (href) => {
149 expect(parseMarkdownFileLink(href)).toBeNull();
150 });
151
152 it("accepts conventional extensionless names with or without a position", () => {
153 expect(parseMarkdownFileLink("Makefile")).toEqual({ path: "Makefile" });
154 expect(parseMarkdownFileLink("Dockerfile:8")).toEqual({ path: "Dockerfile", line: 8 });
155 expect(parseMarkdownFileLink("/srv/app/Makefile")).toEqual({ path: "/srv/app/Makefile" });
156 });
157
158 it("reads positions from suffixes and line anchors", () => {
159 expect(parseMarkdownFileLink("/Users/julius/project/src/main.ts#L42C7")).toEqual({
160 path: "/Users/julius/project/src/main.ts",
161 line: 42,
162 column: 7,
163 });
164 expect(parseMarkdownFileLink("file://server/share/src/main.ts#L42C7")).toMatchObject({
165 path: "\\\\server\\share\\src\\main.ts",
166 line: 42,
167 column: 7,
168 });
169 });
170});
171
172describe("fileBasename", () => {
173 it.each([
174 ["/tmp/favicons/", "favicons"],
175 ["C:\\Users\\kelchm\\.claude\\", ".claude"],
176 ["/tmp/", "tmp"],
177 ["AGENTS.md", "AGENTS.md"],
178 ["/", "/"],
179 ])("labels %s as %s", (path, basename) => {
180 expect(fileBasename(path)).toBe(basename);
181 });
182});
183
184describe("workspaceRelativeFilePath", () => {
185 it.each([
186 ["/repo/project", "/repo/project", "."],
187 ["/repo/project/", "/repo/project/", "."],
188 ["/", "/", "."],
189 ["C:/USERS/mike/project", "c:/users/MIKE/project", "."],
190 ["C:/", "c:/", "."],
191 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],
192 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],
193 ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
194 ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
195 ["/Repo/Project/src/main.ts", "/repo/project", null],
196 ["/tmp/case/project/probe.txt", "/tmp/case/Project", null],
197 ["//tmp/case/project/probe.txt", "//tmp/case/Project", null],
198 ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"],
199 ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"],
200 ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"],
201 ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"],
202 ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"],
203 ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"],
204 ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"],
205 ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "],
206 ["/tmp/report.ts", "/repo/project", null],
207 ["/repo/project-two/a.ts", "/repo/project", null],
208 ["/repo/project/a.ts", undefined, null],
209 ])("relates %s to %s", (path, workspaceRoot, relativePath) => {
210 expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
211 });
212});
213
214describe("isWindowsDrivePathHref", () => {
215 it.each([
216 ["C:\\repo\\image.png", true],
217 ["C:%5Crepo%5Cimage.png", true],
218 ["https://example.com/image.png", false],
219 ])("classifies %s as %s", (href, expected) => {
220 expect(isWindowsDrivePathHref(href)).toBe(expected);
221 });
222});
223
224describe("extractMarkdownLinkHrefs", () => {
225 it("extracts angle-bracketed paths containing spaces", () => {
226 expect(
227 extractMarkdownLinkHrefs(
228 "[Open the Bike Receipts folder](</Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts>)",
229 ),
230 ).toEqual(["/Users/dara/Downloads/Lime Ride Artifacts/Bike Receipts"]);
231 });
232
233 it("preserves ordinary destinations and ignores link titles", () => {
234 expect(
235 extractMarkdownLinkHrefs(
236 '[source](apps/web/src/markdown-links.ts "implementation") and [docs](https://example.com)',
237 ),
238 ).toEqual(["apps/web/src/markdown-links.ts", "https://example.com"]);
239 });
240});
241
242describe("resolveMarkdownFileLinkTarget", () => {
243 it("resolves absolute posix file paths", () => {
244 expect(resolveMarkdownFileLinkTarget("/Users/julius/project/AGENTS.md")).toBe(
245 "/Users/julius/project/AGENTS.md",
246 );
247 });
248
249 it("resolves relative file paths against cwd", () => {
250 expect(resolveMarkdownFileLinkTarget("src/processRunner.ts:71", "/Users/julius/project")).toBe(
251 "/Users/julius/project/src/processRunner.ts:71",
252 );
253 });
254
255 it("does not treat filename line references as external schemes", () => {
256 expect(resolveMarkdownFileLinkTarget("script.ts:10", "/Users/julius/project")).toBe(
257 "/Users/julius/project/script.ts:10",
258 );
259 });
260
261 it("resolves bare file names against cwd", () => {
262 expect(resolveMarkdownFileLinkTarget("AGENTS.md", "/Users/julius/project")).toBe(
263 "/Users/julius/project/AGENTS.md",
264 );
265 });
266
267 it("maps #L line anchors to editor line suffixes", () => {
268 expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe(
269 "/Users/julius/project/src/main.ts:42:7",
270 );
271 });
272
273 it("ignores external urls", () => {
274 expect(resolveMarkdownFileLinkTarget("https://example.com/docs")).toBeNull();
275 expect(resolveMarkdownFileLinkTarget("//cdn.example.com/clip.mp4", "/workspace")).toBeNull();
276 });
277
278 it("does not double-decode file URLs", () => {
279 expect(resolveMarkdownFileLinkTarget("file:///Users/julius/project/file%2520name.md")).toBe(
280 "/Users/julius/project/file%20name.md",
281 );
282 });
283
284 it("resolves file uri authorities as windows UNC paths", () => {
285 expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe(
286 "\\\\server\\share\\workspace-image.svg",
287 );
288 });
289
290 it("resolves a localhost file uri as a local path", () => {
291 expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe(
292 "/home/me/notes.md",
293 );
294 });
295
296 it("keeps an encoded final space in the absolute target", () => {
297 expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe(
298 "/tmp/repo/file.ts ",
299 );
300 });
301
302 it("normalizes slash-prefixed windows drive paths before resolving", () => {
303 expect(
304 resolveMarkdownFileLinkTarget(
305 "/D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69",
306 ),
307 ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69");
308 });
309
310 it("resolves angle-bracketed windows drive paths", () => {
311 expect(
312 resolveMarkdownFileLinkTarget(
313 "</D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1>",
314 ),
315 ).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1");
316 });
317
318 it("does not treat app routes as file links, even with a line anchor", () => {
319 expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
320 expect(resolveMarkdownFileLinkTarget("/chat/settings#L3", "/repo")).toBeNull();
321 });
322
323 it("decodes an encoded drive colon in a file uri before dropping its slash", () => {
324 expect(resolveMarkdownFileLinkTarget("file:///c%3A/Users/x/shot.png")).toBe(
325 "c:/Users/x/shot.png",
326 );
327 });
328});
packages/shared/src/markdownLinks.ts created+359
......@@ -0,0 +1,359 @@
1import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "./path.ts";
2import {
3 type FilePathPosition,
4 formatFilePathPosition,
5 parseFileUrlHref,
6 resolvePathLinkTarget,
7 splitFilePathPosition,
8} from "./fileLinks.ts";
9
10const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
11
12const RELATIVE_FILE_PATH_PATTERN =
13 /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/;
14
15const RELATIVE_FILE_NAME_PATTERN =
16 /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;
17
18const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
19
20const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
21
22const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
23
24const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
25
26const PATH_SEPARATOR_PATTERN = /[\\/]/;
27
28const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
29// A final dot between digits marks a version or model id (`glm-5.3`,
30// `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files.
31const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/;
32const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
33
34// Standard OS and dev-container roots; deliberately excludes app-route-ish
35// prefixes like /app/ or /chat/ so SPA routes never read as files.
36const POSIX_FILE_ROOT_PREFIXES = [
37 "/Users/",
38 "/home/",
39 "/tmp/",
40 "/var/",
41 "/etc/",
42 "/opt/",
43 "/mnt/",
44 "/Volumes/",
45 "/private/",
46 "/root/",
47 "/usr/",
48 "/bin/",
49 "/sbin/",
50 "/lib/",
51 "/lib64/",
52 "/srv/",
53 "/dev/",
54 "/proc/",
55 "/sys/",
56 "/run/",
57 "/boot/",
58 "/media/",
59 "/workspace/",
60 "/workspaces/",
61] as const;
62
63// `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`.
64const EXTENSIONLESS_FILE_NAMES = new Set([
65 "Makefile",
66 "makefile",
67 "GNUmakefile",
68 "Dockerfile",
69 "Containerfile",
70 "Justfile",
71 "justfile",
72 "Rakefile",
73 "Gemfile",
74 "Procfile",
75 "Brewfile",
76 "Caddyfile",
77 "Vagrantfile",
78 "Jenkinsfile",
79 "Podfile",
80 "Fastfile",
81 "BUILD",
82 "WORKSPACE",
83 "LICENSE",
84 "LICENCE",
85 "COPYING",
86 "NOTICE",
87 "AUTHORS",
88 "CONTRIBUTORS",
89 "CHANGELOG",
90 "README",
91 "CODEOWNERS",
92]);
93
94const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
95
96// These allowlists avoid classifying dotted directories such as `conf.d/`
97// or filenames such as `Makefile.in:12` as hosts.
98const GENERIC_HOSTNAME_TLDS = new Set([
99 "com",
100 "net",
101 "org",
102 "io",
103 "dev",
104 "app",
105 "ai",
106 "co",
107 "edu",
108 "gov",
109 "mil",
110 "info",
111 "biz",
112 "xyz",
113 "me",
114 "tv",
115 "cc",
116 "gg",
117 "chat",
118 "cloud",
119 "site",
120 "online",
121 "tech",
122 "store",
123 "link",
124]);
125
126// Country codes also name file extensions. A :line suffix makes `.pl`
127// and `.pt` files more likely than hostnames.
128const COUNTRY_HOSTNAME_TLDS = new Set([
129 "uk",
130 "de",
131 "fr",
132 "nl",
133 "se",
134 "no",
135 "fi",
136 "dk",
137 "pl",
138 "ch",
139 "at",
140 "be",
141 "es",
142 "it",
143 "pt",
144 "eu",
145 "us",
146 "ca",
147 "au",
148 "nz",
149 "jp",
150 "kr",
151 "cn",
152 "br",
153 "ru",
154 "mx",
155 "ie",
156 "cz",
157 "tr",
158 "sg",
159 "hk",
160]);
161
162function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
163 if (segment.startsWith(".")) return false;
164 const lowered = segment.toLowerCase();
165 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
166 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
167 const labels = lowered.split(".");
168 const lastLabel = labels.at(-1);
169 if (labels.length < 2 || lastLabel === undefined) return false;
170 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
171 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
172}
173
174/**
175 * Picks path-shaped inline code for the client's markdown file-link resolver.
176 * It does not resolve paths or turn plain prose and fenced code into links.
177 */
178export function inlineCodeFilePathCandidate(codeText: string): string | null {
179 const trimmed = codeText.trim();
180 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
181
182 const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/");
183 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
184 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
185
186 const hasExplicitPathShape =
187 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
188 candidate.startsWith("/") ||
189 isWindowsAbsolutePath(candidate);
190 if (!hasExplicitPathShape) {
191 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
192 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
193 if (looksLikeHostname(firstSegment, hasPosition)) return null;
194 const basename =
195 withoutPosition
196 .replace(/[/\\]+$/, "")
197 .split(/[\\/]/)
198 .at(-1) ?? "";
199 if (VERSION_SUFFIX_PATTERN.test(basename)) return null;
200 if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null;
201 }
202 return candidate;
203}
204
205export function safeDecodeURIComponent(value: string): string {
206 try {
207 return decodeURIComponent(value);
208 } catch {
209 return value;
210 }
211}
212
213export function normalizeMarkdownLinkDestination(value: string): string {
214 const trimmed = value.trim();
215 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
216}
217
218export function splitMarkdownLinkSearchAndHash(value: string): {
219 readonly path: string;
220 readonly hash: string;
221} {
222 const hashIndex = value.indexOf("#");
223 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
224 const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
225 const queryIndex = pathWithSearch.indexOf("?");
226 return {
227 path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
228 hash,
229 };
230}
231
232/** Keeps filename and destination-path labels compact without discarding prose. */
233export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
234 const destination = parseMarkdownFileLink(href);
235 if (!destination) return false;
236 const normalize = (path: string) =>
237 path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
238 const labelPosition = splitFilePathPosition(label.trim());
239 if (
240 (labelPosition.line !== undefined && labelPosition.line !== destination.line) ||
241 (labelPosition.column !== undefined && labelPosition.column !== destination.column)
242 ) {
243 return false;
244 }
245 let labelPath = normalize(labelPosition.path);
246 let destinationPath = normalize(destination.path);
247 if (labelPath.length === 0) return true;
248 if (isWindowsAbsolutePath(destination.path)) {
249 labelPath = labelPath.toLowerCase();
250 destinationPath = destinationPath.toLowerCase();
251 }
252 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
253}
254
255function isRelativeFilePath(path: string): boolean {
256 return (
257 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
258 (!path.startsWith("/") && !isWindowsAbsolutePath(path))
259 );
260}
261
262function looksLikePosixFilesystemPath(path: string): boolean {
263 if (!path.startsWith("/")) return false;
264 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
265 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
266 const basename = path.slice(path.lastIndexOf("/") + 1);
267 return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename);
268}
269
270/**
271 * Decides whether a decoded link destination is a file path rather than a route
272 * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L`
273 * anchor never turns `/chat/settings` into a file.
274 */
275function looksLikeFilePath(path: string, authoredPath: string): boolean {
276 if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
277 if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath);
278 if (EXTENSIONLESS_FILE_NAMES.has(path)) return true;
279 return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path);
280}
281
282function hasExternalScheme(path: string): boolean {
283 if (isWindowsAbsolutePath(path)) return false;
284 const match = path.match(EXTERNAL_SCHEME_PATTERN);
285 if (!match) return false;
286 const rest = match[2] ?? "";
287 if (rest.startsWith("//")) return true;
288 return !POSITION_ONLY_PATTERN.test(rest);
289}
290
291export function parseMarkdownFileLink(href: string): FilePathPosition | null {
292 const normalized = normalizeMarkdownLinkDestination(href);
293 if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) {
294 return null;
295 }
296
297 const source =
298 (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ??
299 splitMarkdownLinkSearchAndHash(normalized);
300 // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded.
301 const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim()));
302 const hash = safeDecodeURIComponent(source.hash.trim());
303 if (path.length === 0 || hasExternalScheme(path)) return null;
304
305 const position = splitFilePathPosition(path, hash);
306 return looksLikeFilePath(position.path, path) ? position : null;
307}
308
309const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;
310
311const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g;
312
313export function extractInlineCodeSpans(text: string): string[] {
314 const spans: string[] = [];
315 const segments = text.split(FENCED_CODE_SEGMENT_PATTERN);
316 for (let index = 0; index < segments.length; index += 2) {
317 for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) {
318 const span = match[1]?.trim();
319 if (span) spans.push(span);
320 }
321 }
322 return spans;
323}
324
325const MARKDOWN_LINK_HREF_PATTERN =
326 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
327
328export function extractMarkdownLinkHrefs(markdown: string): string[] {
329 const hrefs: string[] = [];
330 for (const match of markdown.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
331 const href = (match[1] ?? match[2])?.trim();
332 if (href) hrefs.push(href);
333 }
334 return hrefs;
335}
336
337/**
338 * `baseDir` anchors relative links; it defaults to the workspace root and is the
339 * file's own directory when rendering a markdown file. `cwd` stays the workspace
340 * root so the result still knows whether the target is inside it.
341 */
342export function resolveMarkdownFileLinkTarget(
343 href: string | undefined,
344 cwd?: string,
345 baseDir: string | undefined = cwd,
346): string | null {
347 if (!href) return null;
348 const target = parseMarkdownFileLink(href);
349 if (!target) return null;
350
351 const pathWithPosition = formatFilePathPosition(target);
352 if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition;
353 if (!baseDir) return null;
354 return resolvePathLinkTarget(pathWithPosition, baseDir);
355}
356
357export function isWindowsDrivePathHref(href: string): boolean {
358 return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
359}
packages/shared/src/markdownListIndentation.ts created+143
......@@ -0,0 +1,143 @@
1interface MarkdownPosition {
2 readonly start?: {
3 readonly line?: number;
4 readonly offset?: number;
5 };
6}
7
8interface MarkdownAstNode {
9 readonly type: string;
10 readonly value?: unknown;
11 readonly position?: MarkdownPosition;
12 children?: MarkdownAstNode[];
13}
14
15interface MarkdownFile {
16 readonly value?: unknown;
17}
18
19interface MarkdownParser {
20 parse(markdown: string): unknown;
21}
22
23interface RecoveredMarkdown {
24 readonly blocks: MarkdownAstNode[];
25 readonly source: string;
26}
27
28const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:";
29
30function isSameLineOverIndentedCode(
31 node: MarkdownAstNode,
32 parent: MarkdownAstNode | undefined,
33 markdown: string,
34): boolean {
35 if (
36 node.type !== "code" ||
37 parent?.type !== "listItem" ||
38 typeof node.value !== "string" ||
39 !/^[\t ]/.test(node.value)
40 ) {
41 return false;
42 }
43
44 const nodeStart = node.position?.start;
45 const parentStart = parent.position?.start;
46 if (
47 nodeStart?.line === undefined ||
48 nodeStart.offset === undefined ||
49 parentStart?.line === undefined ||
50 nodeStart.line !== parentStart.line
51 ) {
52 return false;
53 }
54
55 const sourceCharacter = markdown[nodeStart.offset];
56 return sourceCharacter !== "`" && sourceCharacter !== "~";
57}
58
59function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {
60 // A text prefix forces block-looking input into a paragraph while preserving
61 // the processor's configured inline extensions (for example, GFM syntax).
62 // Later root children are kept as blocks so blank-line-separated content is
63 // never discarded.
64 const source = `${INLINE_PARSE_PREFIX}${value}`;
65 const document = parser.parse(source) as MarkdownAstNode;
66 const blocks = document.children;
67 const paragraph = blocks?.[0];
68 const children = paragraph?.type === "paragraph" ? paragraph.children : undefined;
69 const first = children?.[0];
70 if (
71 !blocks ||
72 !children ||
73 first?.type !== "text" ||
74 typeof first.value !== "string" ||
75 !first.value.startsWith(INLINE_PARSE_PREFIX)
76 ) {
77 return { blocks: [{ type: "text", value }], source };
78 }
79
80 const firstValue = first.value.slice(INLINE_PARSE_PREFIX.length);
81 return {
82 blocks: [
83 {
84 ...paragraph,
85 type: "paragraph",
86 children: [...(firstValue ? [{ ...first, value: firstValue }] : []), ...children.slice(1)],
87 },
88 ...blocks.slice(1),
89 ],
90 source,
91 };
92}
93
94function blocksFromIndentedCode(node: MarkdownAstNode, parser: MarkdownParser): RecoveredMarkdown {
95 const value = typeof node.value === "string" ? node.value.trim() : "";
96 const recovered = parseRecoveredMarkdown(value, parser);
97 const first = recovered.blocks[0];
98 return {
99 ...recovered,
100 blocks:
101 first && node.position
102 ? [{ ...first, position: node.position }, ...recovered.blocks.slice(1)]
103 : recovered.blocks,
104 };
105}
106
107/**
108 * CommonMark treats four or more spaces after a list marker as an indented
109 * code block. In chat output, excessive spacing is commonly accidental
110 * alignment such as `- text`, which otherwise produces a full code card
111 * for every bullet. Only normalize blocks that retain excess indentation and
112 * start on the marker's own line; explicit fences and conventional indented
113 * blocks remain code.
114 */
115function attachListItemIndentationNormalizer(this: MarkdownParser) {
116 return (tree: MarkdownAstNode, file: MarkdownFile) => {
117 if (typeof file.value !== "string") {
118 return;
119 }
120 const markdown = file.value;
121
122 const visit = (node: MarkdownAstNode, source: string) => {
123 if (!node.children) {
124 return;
125 }
126 node.children = node.children.flatMap((child) => {
127 if (isSameLineOverIndentedCode(child, node, source)) {
128 const recovered = blocksFromIndentedCode(child, this);
129 for (const block of recovered.blocks) {
130 visit(block, recovered.source);
131 }
132 return recovered.blocks;
133 }
134 visit(child, source);
135 return [child];
136 });
137 };
138
139 visit(tree, markdown);
140 };
141}
142
143export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer;
packages/shared/src/markdownPipeline.test.ts created+29
......@@ -0,0 +1,29 @@
1import { expect, it } from "vite-plus/test";
2import { unified } from "unified";
3import remarkParse from "remark-parse";
4import remarkRehype from "remark-rehype";
5import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS } from "./markdownPipeline.ts";
6
7it("preserves in-app thread links through the shared Markdown sanitizer", () => {
8 const processor = unified()
9 .use(remarkParse)
10 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
11 .use(remarkRehype, { allowDangerousHtml: true })
12 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
13 const tree = processor.runSync(
14 processor.parse(
15 '[Open thread](t3-thread://v1/environment/thread)\n\n<a href="javascript:alert(1)">Unsafe</a>',
16 ),
17 );
18 expect(tree.children[0]).toMatchObject({
19 tagName: "p",
20 children: [
21 {
22 tagName: "a",
23 properties: { href: "t3-thread://v1/environment/thread" },
24 children: [{ type: "text", value: "Open thread" }],
25 },
26 ],
27 });
28 expect(JSON.stringify(tree)).not.toContain("javascript:");
29});
packages/shared/src/markdownPipeline.ts created+293
......@@ -0,0 +1,293 @@
1import type { Processor } from "unified";
2import { isWindowsAbsolutePath } from "./path.ts";
3import type { PluggableList } from "unified";
4import rehypeRaw from "rehype-raw";
5import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6import remarkBreaks from "remark-breaks";
7import remarkGfm from "remark-gfm";
8import { remarkGithubAlerts } from "./markdownGithubAlerts.ts";
9import { remarkNormalizeListItemIndentation } from "./markdownListIndentation.ts";
10import {
11 CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES,
12 remarkCodexDirectives,
13} from "./codexMarkdownDirectives.ts";
14import { isWindowsDrivePathHref } from "./markdownLinks.ts";
15import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts";
16
17type MarkdownImageHastNode = {
18 type?: string;
19 value?: string;
20 tagName?: string;
21 properties?: Record<string, unknown>;
22 children?: MarkdownImageHastNode[];
23};
24
25function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] {
26 return (node.children ?? []).filter(
27 (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""),
28 );
29}
30
31/**
32 * An image that is the only content of its block (optionally wrapped in a
33 * link) is almost always a screenshot or figure, so it gets a reserved slot
34 * while it loads. Images mixed with text or other images — badge rows, icons
35 * in a sentence — stay inline at their natural size, since a placeholder taller
36 * than the image would move the page more than the image itself does.
37 */
38/** Containers whose sole child image reads as a figure rather than part of a sentence. */
39const STANDALONE_IMAGE_BLOCKS = new Set([
40 "p",
41 "div",
42 "li",
43 "td",
44 "th",
45 "figure",
46 "center",
47 "blockquote",
48]);
49
50function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined {
51 const children = meaningfulHastChildren(node);
52 if (children.length !== 1) return undefined;
53 const only = children[0];
54 if (only?.type !== "element") return undefined;
55 if (only.tagName === "img") return only;
56 // A link, emphasis, or similar inline wrapper around the image still counts
57 // as long as nothing else shares the block.
58 return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em"
59 ? soleImageDescendant(only)
60 : undefined;
61}
62
63function markStandaloneImages(node: MarkdownImageHastNode) {
64 // A raw `<img>` on its own line reaches the root without a paragraph.
65 if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) {
66 const image = soleImageDescendant(node);
67 if (image) image.properties = { ...image.properties, dataStandalone: true };
68 }
69 node.children?.forEach((child) => {
70 if (child.type === "element") markStandaloneImages(child);
71 });
72}
73
74/** Keep unmatched inline `<A>` placeholders from opening an HTML link over later blocks. */
75function rehypePreserveBareAnchorPlaceholders() {
76 return (tree: MarkdownImageHastNode) => {
77 const anchors: Array<MarkdownImageHastNode | null> = [];
78 let rawTextTag: string | undefined;
79 const visit = (node: MarkdownImageHastNode) => {
80 if (node.type === "raw" && typeof node.value === "string") {
81 // Raw blocks can contain several tags. Consume whole tags, quoted attributes,
82 // and comments so text resembling a closing anchor cannot pair a placeholder.
83 const tags = /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g;
84 let offset = 0;
85 while (rawTextTag !== "plaintext") {
86 // Raw text ends at its closing tag even inside comment-looking text.
87 const matcher = rawTextTag ? new RegExp(`</${rawTextTag}\\s*>`, "gi") : tags;
88 matcher.lastIndex = offset;
89 const match = matcher.exec(node.value);
90 if (!match) break;
91 const [tag] = match;
92 offset = matcher.lastIndex;
93 if (rawTextTag) {
94 rawTextTag = undefined;
95 continue;
96 }
97 if (tag.startsWith("<!--")) continue;
98 const closing = /^<\/([a-z]+)\s*>$/i.exec(tag)?.[1]?.toLowerCase();
99 const opening = /^<([a-z]+)(?:\s|\/?>)/i.exec(tag)?.[1]?.toLowerCase();
100 if (
101 opening &&
102 /^(?:script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext)$/.test(opening)
103 ) {
104 rawTextTag = opening;
105 } else if (opening === "a") {
106 anchors.push(node.value === tag && /^<a\s*\/?>$/i.test(tag) ? node : null);
107 } else if (closing === "a") {
108 anchors.pop();
109 }
110 }
111 }
112 node.children?.forEach(visit);
113 };
114
115 visit(tree);
116 for (const anchor of anchors) {
117 if (anchor) anchor.type = "text";
118 }
119 };
120}
121
122/** Carries authored image source metadata through the sanitizer to the image renderer. */
123function rehypePreserveImageSourceMeta() {
124 return (tree: MarkdownImageHastNode) => {
125 const visit = (node: MarkdownImageHastNode) => {
126 const src = node.properties?.src;
127 const title = node.properties?.title;
128 if (node.type === "element" && node.tagName === "img") {
129 node.properties = {
130 ...node.properties,
131 ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}),
132 ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}),
133 };
134 }
135 node.children?.forEach(visit);
136 };
137
138 visit(tree);
139 markStandaloneImages(tree);
140 };
141}
142
143const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
144 ...defaultSchema,
145 attributes: {
146 ...defaultSchema.attributes,
147 "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
148 code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
149 blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
150 div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
151 a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
152 img: [
153 ...(defaultSchema.attributes?.img ?? []),
154 "dataLocalSrc",
155 "dataMarkdownTitle",
156 "dataStandalone",
157 ],
158 },
159 protocols: {
160 ...defaultSchema.protocols,
161 href: [
162 ...(defaultSchema.protocols?.href ?? []),
163 "file",
164 "t3-citation",
165 "t3-context",
166 THREAD_LINK_PROTOCOL,
167 ],
168 src: [...(defaultSchema.protocols?.src ?? []), "file", "t3-context"],
169 },
170} satisfies Parameters<typeof rehypeSanitize>[0];
171
172export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [
173 remarkGfm,
174 remarkKeepWindowsPathDestinations,
175 remarkGithubAlerts,
176 remarkNormalizeListItemIndentation,
177 remarkCodexDirectives,
178 remarkPreserveCodeMeta,
179 remarkNormalizeLinksAndTagInlineCode,
180];
181
182export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [
183 remarkGfm,
184 remarkKeepWindowsPathDestinations,
185 remarkGithubAlerts,
186 remarkNormalizeListItemIndentation,
187 remarkCodexDirectives,
188 remarkBreaks,
189 remarkPreserveCodeMeta,
190 remarkNormalizeLinksAndTagInlineCode,
191];
192
193export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [
194 rehypePreserveBareAnchorPlaceholders,
195 rehypeRaw,
196 rehypePreserveImageSourceMeta,
197 [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA],
198];
199
200type MarkdownAstNode = {
201 type?: string;
202 meta?: unknown;
203 url?: string;
204 data?: {
205 hProperties?: Record<string, unknown>;
206 };
207 children?: MarkdownAstNode[];
208};
209
210function remarkPreserveCodeMeta() {
211 return (tree: MarkdownAstNode) => {
212 const visit = (node: MarkdownAstNode) => {
213 if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) {
214 node.data = {
215 ...node.data,
216 hProperties: {
217 ...node.data?.hProperties,
218 dataCodeMeta: node.meta.trim(),
219 },
220 };
221 }
222 node.children?.forEach(visit);
223 };
224
225 visit(tree);
226 };
227}
228
229/**
230 * Preserve Windows drive links as allowed `file:` URLs before sanitization.
231 * The same traversal tags inline code while it can still be distinguished
232 * from fenced code. Code inside links stays untagged to avoid nested anchors.
233 */
234function remarkNormalizeLinksAndTagInlineCode() {
235 return (tree: MarkdownAstNode) => {
236 const visit = (node: MarkdownAstNode, insideLink: boolean) => {
237 if (
238 (node.type === "link" || node.type === "definition") &&
239 typeof node.url === "string" &&
240 /^[A-Za-z]:[\\/]/.test(node.url)
241 ) {
242 node.url = `file:///${node.url.replaceAll("\\", "/")}`;
243 }
244 if (node.type === "inlineCode" && !insideLink) {
245 node.data = {
246 ...node.data,
247 hProperties: {
248 ...node.data?.hProperties,
249 dataInlineCode: "",
250 },
251 };
252 }
253 const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference";
254 node.children?.forEach((child) => visit(child, childInsideLink));
255 };
256
257 visit(tree, false);
258 };
259}
260
261interface DestinationCompileContext {
262 readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>;
263 resume(): string;
264 sliceSerialize(token: unknown): string;
265}
266
267function keepWindowsPathDestination(this: DestinationCompileContext, token: unknown) {
268 const decoded = this.resume();
269 const authored = this.sliceSerialize(token);
270 const node = this.stack.at(-1);
271 // Character references still need decoding, so those destinations keep the parsed URL.
272 if (node)
273 node.url = isWindowsAbsolutePath(authored) && !authored.includes("&") ? authored : decoded;
274}
275
276/**
277 * CommonMark reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link
278 * destination. Every backslash in a Windows path is a separator, so link, image, and
279 * definition destinations that are Windows paths keep the text as written.
280 */
281function remarkKeepWindowsPathDestinations(this: Processor) {
282 const data = this.data();
283 (data.fromMarkdownExtensions ??= []).push({
284 exit: {
285 resourceDestinationString: keepWindowsPathDestination,
286 definitionDestinationString: keepWindowsPathDestination,
287 },
288 });
289}
290
291export function shouldPreserveAssistantLineBreaks(text: string): boolean {
292 return /^★ Insight(?:\s|─)/mu.test(text);
293}
packages/shared/src/path.test.ts+39
......@@ -1,5 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22import {
3 fileBasename,
4 workspaceRelativeFilePath,
35 isExplicitRelativePath,
46 isUncPath,
57 isWindowsAbsolutePath,
......@@ -58,3 +60,40 @@ describe("path helpers", () => {
5860 expect(newProjectFolderName("console")).toBe("console");
5961 });
6062});
63
64describe("fileBasename", () => {
65 it.each([
66 ["/tmp/favicons/", "favicons"],
67 ["C:\\Users\\kelchm\\.claude\\", ".claude"],
68 ["/tmp/", "tmp"],
69 ["AGENTS.md", "AGENTS.md"],
70 ["/", "/"],
71 ])("labels %s as %s", (path, basename) => {
72 expect(fileBasename(path)).toBe(basename);
73 });
74});
75
76describe("workspaceRelativeFilePath", () => {
77 it.each([
78 ["/repo/project/src/main.ts", "/repo/project", "src/main.ts"],
79 ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"],
80 ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
81 ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"],
82 ["/Repo/Project/src/main.ts", "/repo/project", null],
83 ["/tmp/case/project/probe.txt", "/tmp/case/Project", null],
84 ["//tmp/case/project/probe.txt", "//tmp/case/Project", null],
85 ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"],
86 ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"],
87 ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"],
88 ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"],
89 ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"],
90 ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"],
91 ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"],
92 ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "],
93 ["/tmp/report.ts", "/repo/project", null],
94 ["/repo/project-two/a.ts", "/repo/project", null],
95 ["/repo/project/a.ts", undefined, null],
96 ])("relates %s to %s", (path, workspaceRoot, relativePath) => {
97 expect(workspaceRelativeFilePath(path, workspaceRoot)).toBe(relativePath);
98 });
99});
packages/shared/src/path.ts+37
......@@ -74,3 +74,40 @@ export function newProjectFolderName(name: string): string {
7474 if (slug.length === 0) return "project";
7575 return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug;
7676}
77
78const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/;
79
80/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */
81export function stripSlashPrefixedWindowsDrive(path: string): string {
82 return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path;
83}
84
85export function fileBasename(path: string): string {
86 // A trailing separator is a valid way to write a directory. Trim it before
87 // taking the final segment so the label is never empty.
88 const trimmed = path.replace(/[/\\]+$/, "");
89 if (trimmed.length === 0) return path;
90 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
91 return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
92}
93
94export function workspaceRelativeFilePath(
95 path: string,
96 workspaceRoot: string | null | undefined,
97): string | null {
98 if (!workspaceRoot) return null;
99 const normalizedPath = stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/"));
100 const normalizedRoot = stripSlashPrefixedWindowsDrive(
101 workspaceRoot.replaceAll("\\", "/"),
102 ).replace(/\/+$/, "");
103 const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot));
104 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
105 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
106 if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return ".";
107 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
108 return normalizedPath.slice(normalizedRoot.length + 1);
109}
110
111export function isAbsolutePath(value: string): boolean {
112 return value.startsWith("/") || isWindowsAbsolutePath(value);
113}
packages/shared/src/proposedPlanText.test.ts created+26
......@@ -0,0 +1,26 @@
1import { describe, expect, it } from "vite-plus/test";
2import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts";
3
4describe("proposedPlanTitle", () => {
5 it("reads the first markdown heading as the plan title", () => {
6 expect(proposedPlanTitle("# Integrate RPC\n\nBody")).toBe("Integrate RPC");
7 });
8
9 it("returns null when the plan has no heading", () => {
10 expect(proposedPlanTitle("- step 1")).toBeNull();
11 });
12});
13
14describe("stripDisplayedPlanMarkdown", () => {
15 it("drops the leading title heading from displayed plan markdown", () => {
16 expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Summary\n\n- step 1\n")).toBe(
17 "- step 1",
18 );
19 });
20
21 it("preserves non-summary headings after dropping the title heading", () => {
22 expect(stripDisplayedPlanMarkdown("# Integrate RPC\n\n## Scope\n\n- step 1\n")).toBe(
23 "## Scope\n\n- step 1",
24 );
25 });
26});
packages/shared/src/proposedPlanText.ts created+20
......@@ -0,0 +1,20 @@
1export function proposedPlanTitle(planMarkdown: string): string | null {
2 const heading = planMarkdown.match(/^\s{0,3}#{1,6}\s+(.+)$/m)?.[1]?.trim();
3 return heading && heading.length > 0 ? heading : null;
4}
5
6export function stripDisplayedPlanMarkdown(planMarkdown: string): string {
7 const lines = planMarkdown.trimEnd().split(/\r?\n/);
8 const sourceLines = lines[0] && /^\s{0,3}#{1,6}\s+/.test(lines[0]) ? lines.slice(1) : [...lines];
9 while (sourceLines[0]?.trim().length === 0) {
10 sourceLines.shift();
11 }
12 const firstHeadingMatch = sourceLines[0]?.match(/^\s{0,3}#{1,6}\s+(.+)$/);
13 if (firstHeadingMatch?.[1]?.trim().toLowerCase() === "summary") {
14 sourceLines.shift();
15 while (sourceLines[0]?.trim().length === 0) {
16 sourceLines.shift();
17 }
18 }
19 return sourceLines.join("\n");
20}
packages/shared/src/themePalettes.ts+52
......@@ -88,6 +88,10 @@ export const THEME_COLOR_ROLES = [
8888 "messageActionHover",
8989 "codeBackground",
9090 "codeForeground",
91 "searchMatchBackground",
92 "searchMatchForeground",
93 "searchMatchActiveBackground",
94 "searchMatchActiveForeground",
9195 "sidebar",
9296 "sidebarForeground",
9397 "sidebarMutedForeground",
......@@ -129,6 +133,10 @@ export type ThemeDefinition = Readonly<{
129133 * colors are stored as opaque OKLCH tokens.
130134 */
131135export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
136 searchMatchBackground: "#fce8c0",
137 searchMatchForeground: "#27272a",
138 searchMatchActiveBackground: "#eea52b",
139 searchMatchActiveForeground: "#21180a",
132140 canvas: "#fcfcfc",
133141 chrome: "#fcfcfc",
134142 toolbar: "#fcfcfc",
......@@ -189,6 +197,10 @@ export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
189197};
190198
191199export const T3_CODE_DARK_THEME_COLORS: ThemeColors = {
200 searchMatchBackground: "#533e16",
201 searchMatchForeground: "#f5f5f5",
202 searchMatchActiveBackground: "#eea52b",
203 searchMatchActiveForeground: "#21180a",
192204 canvas: "#0a0a0a",
193205 chrome: "#0a0a0a",
194206 toolbar: "#0a0a0a",
......@@ -296,6 +308,10 @@ export const T3_CHAT_THEME: ThemeDefinition = {
296308 messageActionHover: "oklch(0.539042 0.197866 0.305)",
297309 codeBackground: "oklch(0.953855 0.019695 315.668)",
298310 codeForeground: "oklch(0.445128 0.13005 307.026)",
311 searchMatchBackground: "oklch(0.869588 0.06751 334.899)",
312 searchMatchForeground: "oklch(0.325698 0.116116 325.037)",
313 searchMatchActiveBackground: "oklch(0.591646 0.217985 0.584)",
314 searchMatchActiveForeground: "oklch(1 0 0)",
299315 sidebar: "oklch(0.928886 0.031178 322.592)",
300316 sidebarForeground: "oklch(0.396296 0.025134 285.196)",
301317 sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)",
......@@ -356,6 +372,10 @@ export const T3_CHAT_THEME: ThemeDefinition = {
356372 messageActionHover: "oklch(0.458754 0.184639 3.857)",
357373 codeBackground: "oklch(0.22813 0.020366 307.469)",
358374 codeForeground: "oklch(0.848703 0.064239 306.645)",
375 searchMatchBackground: "oklch(0.313674 0.030572 310.061)",
376 searchMatchForeground: "oklch(0.980735 0.004092 301.426)",
377 searchMatchActiveBackground: "oklch(0.460685 0.185347 4.099)",
378 searchMatchActiveForeground: "oklch(0.901233 0.057189 343.694)",
359379 sidebar: "oklch(0.185778 0.019368 322.159)",
360380 sidebarForeground: "oklch(0.967434 0.001326 286.375)",
361381 sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)",
......@@ -423,6 +443,10 @@ export const GROVE_THEME: ThemeDefinition = {
423443 messageActionHover: "oklch(0.488753 0.096536 77.829)",
424444 codeBackground: "oklch(0.955888 0.004783 158.391)",
425445 codeForeground: "oklch(0.222003 0.03479 328.979)",
446 searchMatchBackground: "oklch(0.891377 0.026164 164.929)",
447 searchMatchForeground: "oklch(0.222003 0.03479 328.979)",
448 searchMatchActiveBackground: "oklch(0.523295 0.112292 158.089)",
449 searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)",
426450 sidebar: "oklch(0.936464 0.014601 163.554)",
427451 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
428452 sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)",
......@@ -483,6 +507,10 @@ export const GROVE_THEME: ThemeDefinition = {
483507 messageActionHover: "oklch(0.815227 0.117902 84.21)",
484508 codeBackground: "oklch(0.312979 0.018942 164.082)",
485509 codeForeground: "oklch(0.990339 0.008411 325.64)",
510 searchMatchBackground: "oklch(0.464636 0.066083 158.72)",
511 searchMatchForeground: "oklch(0.990339 0.008411 325.64)",
512 searchMatchActiveBackground: "oklch(0.796228 0.133058 157.319)",
513 searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)",
486514 sidebar: "oklch(0.309925 0.032827 160.944)",
487515 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
488516 sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)",
......@@ -550,6 +578,10 @@ export const OCEAN_THEME: ThemeDefinition = {
550578 messageActionHover: "oklch(0.45151 0.074407 201.516)",
551579 codeBackground: "oklch(0.957684 0.002906 253.68)",
552580 codeForeground: "oklch(0.222003 0.03479 328.979)",
581 searchMatchBackground: "oklch(0.895373 0.023469 241.913)",
582 searchMatchForeground: "oklch(0.222003 0.03479 328.979)",
583 searchMatchActiveBackground: "oklch(0.536684 0.120219 247.01)",
584 searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)",
553585 sidebar: "oklch(0.939254 0.01193 241.729)",
554586 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
555587 sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)",
......@@ -610,6 +642,10 @@ export const OCEAN_THEME: ThemeDefinition = {
610642 messageActionHover: "oklch(0.815308 0.096174 199.862)",
611643 codeBackground: "oklch(0.29661 0.021883 251.968)",
612644 codeForeground: "oklch(0.990339 0.008411 325.64)",
645 searchMatchBackground: "oklch(0.439946 0.0561 243.479)",
646 searchMatchForeground: "oklch(0.990339 0.008411 325.64)",
647 searchMatchActiveBackground: "oklch(0.758933 0.105833 241.548)",
648 searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)",
613649 sidebar: "oklch(0.290387 0.032043 247.274)",
614650 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
615651 sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)",
......@@ -677,6 +713,10 @@ export const EMBER_THEME: ThemeDefinition = {
677713 messageActionHover: "oklch(0.471223 0.145843 24.688)",
678714 codeBackground: "oklch(0.959965 0.002668 47.512)",
679715 codeForeground: "oklch(0.222003 0.03479 328.979)",
716 searchMatchBackground: "oklch(0.899296 0.022939 49.163)",
717 searchMatchForeground: "oklch(0.222003 0.03479 328.979)",
718 searchMatchActiveBackground: "oklch(0.552831 0.129438 44.656)",
719 searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)",
680720 sidebar: "oklch(0.942267 0.01151 50.785)",
681721 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
682722 sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)",
......@@ -737,6 +777,10 @@ export const EMBER_THEME: ThemeDefinition = {
737777 messageActionHover: "oklch(0.775116 0.117953 29.014)",
738778 codeBackground: "oklch(0.299662 0.017229 39.973)",
739779 codeForeground: "oklch(0.990339 0.008411 325.64)",
780 searchMatchBackground: "oklch(0.442681 0.0608 50.795)",
781 searchMatchForeground: "oklch(0.990339 0.008411 325.64)",
782 searchMatchActiveBackground: "oklch(0.762174 0.124117 52.082)",
783 searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)",
740784 sidebar: "oklch(0.293349 0.029554 46.882)",
741785 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
742786 sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)",
......@@ -804,6 +848,10 @@ export const IRIS_THEME: ThemeDefinition = {
804848 messageActionHover: "oklch(0.471003 0.16748 340.687)",
805849 codeBackground: "oklch(0.95997 0.004338 306.542)",
806850 codeForeground: "oklch(0.222003 0.03479 328.979)",
851 searchMatchBackground: "oklch(0.897143 0.028558 299.758)",
852 searchMatchForeground: "oklch(0.222003 0.03479 328.979)",
853 searchMatchActiveBackground: "oklch(0.525348 0.15373 294.176)",
854 searchMatchActiveForeground: "oklch(0.990339 0.008411 325.64)",
807855 sidebar: "oklch(0.941387 0.014687 300.474)",
808856 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
809857 sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)",
......@@ -864,6 +912,10 @@ export const IRIS_THEME: ThemeDefinition = {
864912 messageActionHover: "oklch(0.813537 0.114101 337.23)",
865913 codeBackground: "oklch(0.281873 0.028308 295.193)",
866914 codeForeground: "oklch(0.990339 0.008411 325.64)",
915 searchMatchBackground: "oklch(0.395417 0.085554 294.182)",
916 searchMatchForeground: "oklch(0.990339 0.008411 325.64)",
917 searchMatchActiveBackground: "oklch(0.671712 0.169136 293.929)",
918 searchMatchActiveForeground: "oklch(0.222003 0.03479 328.979)",
867919 sidebar: "oklch(0.266743 0.044689 294.138)",
868920 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
869921 sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)",
packages/shared/src/threadFindText.test.ts created+161
......@@ -0,0 +1,161 @@
1import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
2import { expect, it } from "vite-plus/test";
3import { serializeAssistantCitation } from "./assistantCitations.ts";
4import { searchableMessageSegments } from "./threadFindText.ts";
5
6it("excludes review attachments rendered as context chips", () => {
7 const text = [
8 "Before **review**",
9 '<review_comment sectionId="turn:2" sectionTitle="Turn 2" filePath="hidden.ts" startIndex="3" endIndex="14" rangeLabel="L4">',
10 "Keep **this literal** comment.",
11 "```diff",
12 "+ hidden patch content",
13 "```",
14 "</review_comment>",
15 "After review",
16 ].join("\n");
17 expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([
18 "Before review",
19 " After review",
20 ]);
21});
22
23it("keeps malformed review tags visible, matching the message renderer", () => {
24 const text = "<review_comment>not a valid attachment</review_comment>";
25 expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([text]);
26});
27
28it("excludes structured context chips without joining text across them", () => {
29 expect(
30 searchableMessageSegments({
31 role: "user",
32 streaming: false,
33 text: "before[hidden label](t3-context://v1/terminal/terminal_1)after",
34 context: { version: 1, records: [] },
35 }),
36 ).toEqual(["before", "after"]);
37});
38
39it("does not upgrade literal legacy tags in messages with structured context", () => {
40 const text =
41 "<terminal_context>\n- Terminal 1 line 12:\n 12 | visible output\n</terminal_context>";
42 expect(
43 searchableMessageSegments({
44 role: "user",
45 streaming: false,
46 text,
47 context: { version: 1, records: [] },
48 })?.join("\n"),
49 ).toContain("visible output");
50 expect(searchableMessageSegments({ role: "user", streaming: false, text })).toEqual([]);
51});
52
53it("keeps context reference syntax inside code searchable", () => {
54 expect(
55 searchableMessageSegments({
56 role: "user",
57 streaming: false,
58 text: "`[label](t3-context://v1/terminal/terminal_1)`",
59 }),
60 ).toEqual(["[label](t3-context://v1/terminal/terminal_1)"]);
61});
62
63it("excludes repeated legacy attachments containing literal context tags", () => {
64 const context =
65 "<terminal_context>\n- Terminal 1 line 12:\n 12 | <terminal_context>literal</terminal_context>\n</terminal_context>";
66 expect(
67 searchableMessageSegments({
68 role: "user",
69 streaming: false,
70 text: `Fix this\n\n${context}\n\n${context}`,
71 }),
72 ).toEqual(["Fix this"]);
73});
74
75const assistantSegments = (text: string, cwd?: string) =>
76 searchableMessageSegments({ role: "assistant", text, streaming: false }, cwd);
77
78it("searches displayed prose and file-chip labels without hidden paths", () => {
79 expect(
80 assistantSegments("[important description](/tmp/actual.ts). `/tmp/inline-example.ts:42`"),
81 ).toEqual(["important description actual.ts. inline-example.ts · L42"]);
82 expect(assistantSegments("[label](src/main.ts#L3C2)", "/workspace/repo")).toEqual([
83 "label main.ts · L3:C2",
84 ]);
85 expect(assistantSegments("[label](src/main.ts#L3C2)")).toEqual(["label"]);
86});
87
88it("includes the same parent suffixes for duplicate filenames as the renderer", () => {
89 expect(
90 assistantSegments(
91 "[first](src/main.ts) and `/workspace/repo/tests/main.ts:2`",
92 "/workspace/repo",
93 ),
94 ).toEqual(["first main.ts · repo/src and main.ts · repo/tests · L2"]);
95 expect(
96 assistantSegments(
97 "[first](src/main.ts) and `/workspace/repo/src/main.ts:2`",
98 "/workspace/repo",
99 ),
100 ).toEqual(["first main.ts and main.ts · L2"]);
101});
102
103it("keeps fence paths literal and indexes file-chip labels in user messages", () => {
104 expect(assistantSegments("```text\n/tmp/file.ts:42\n```")).toEqual(["/tmp/file.ts:42\n"]);
105 expect(
106 searchableMessageSegments({ role: "user", text: "`/tmp/file.ts:42`", streaming: false }),
107 ).toEqual(["file.ts · L42"]);
108});
109
110it("indexes nested disclosure summaries and bodies in rendered order", () => {
111 expect(
112 assistantSegments(
113 "<details><summary>Outer</summary><p>first</p><details><summary>Inner</summary><p>second</p></details></details>",
114 ),
115 ).toEqual(["Outer", "first", "Inner", "second"]);
116});
117
118it("uses Insight line breaks without splitting ordinary assistant prose", () => {
119 expect(assistantSegments("★ Insight ─────\nfirst line\nsecond line")).toEqual([
120 "★ Insight ─────",
121 " first line",
122 " second line",
123 ]);
124 expect(assistantSegments("first line\nsecond line")).toEqual(["first line second line"]);
125});
126
127it("indexes skill labels in prose but leaves links and code literal", () => {
128 expect(
129 searchableMessageSegments(
130 {
131 role: "assistant",
132 streaming: false,
133 text: "Use $test-t3-app now.\n\n`$test-t3-app`\n\n[$test-t3-app](https://example.com)",
134 },
135 undefined,
136 [{ name: "test-t3-app", displayName: "T3 App Testing" }],
137 ),
138 ).toEqual(["Use T3 App Testing now.", "$test-t3-app", "$test-t3-app"]);
139});
140
141it("indexes the citation chip label instead of its link text", () => {
142 const citation = {
143 version: 1 as const,
144 environmentId: EnvironmentId.make("environment"),
145 threadId: ThreadId.make("thread"),
146 messageId: MessageId.make("message"),
147 text: "cited needle",
148 start: 0,
149 end: 14,
150 prefix: "",
151 suffix: "",
152 };
153 const text = `Please fix ${serializeAssistantCitation(citation)} thanks`;
154 expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([
155 "Please fix cited needle thanks",
156 ]);
157 const commented = serializeAssistantCitation({ ...citation, comment: "my note" });
158 expect(searchableMessageSegments({ role: "user", text: commented, streaming: false })).toEqual([
159 "my note",
160 ]);
161});
packages/shared/src/threadFindText.ts created+233
......@@ -0,0 +1,233 @@
1import {
2 matchInlineSkills,
3 formatProviderSkillDisplayName,
4 type InlineSkill,
5} from "./inlineSkills.ts";
6import {
7 artifactTemplateFromHastProperties,
8 renderCodexFileCitationsAsMarkdown,
9} from "./codexMarkdownDirectives.ts";
10import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts";
11import {
12 buildFileLinkParentSuffixByPath,
13 splitFilePathPosition,
14 fileLinkLabel,
15} from "./fileLinks.ts";
16import {
17 isMarkdownFileLinkLabel,
18 extractMarkdownLinkHrefs,
19 extractInlineCodeSpans,
20 inlineCodeFilePathCandidate,
21 resolveMarkdownFileLinkTarget,
22} from "./markdownLinks.ts";
23import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts";
24import { parseComposerContextHref } from "./composerContextReferences.ts";
25import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts";
26import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts";
27import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts";
28import { unified } from "unified";
29import remarkParse from "remark-parse";
30import remarkRehype from "remark-rehype";
31import {
32 shouldPreserveAssistantLineBreaks,
33 CHAT_MARKDOWN_REHYPE_PLUGINS,
34 CHAT_MARKDOWN_REMARK_PLUGINS,
35 CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS,
36} from "./markdownPipeline.ts";
37
38// Inline wrappers (including Shiki token spans) must not split a search phrase.
39export const THREAD_FIND_BLOCK_TAGS = new Set([
40 "address",
41 "article",
42 "aside",
43 "blockquote",
44 "br",
45 "dd",
46 "details",
47 "div",
48 "dl",
49 "dt",
50 "figcaption",
51 "figure",
52 "footer",
53 "h1",
54 "h2",
55 "h3",
56 "h4",
57 "h5",
58 "h6",
59 "header",
60 "hr",
61 "li",
62 "main",
63 "nav",
64 "ol",
65 "p",
66 "pre",
67 "section",
68 "summary",
69 "table",
70 "tbody",
71 "td",
72 "th",
73 "thead",
74 "tr",
75 "ul",
76]);
77
78const assistantProcessor = unified()
79 .use(remarkParse)
80 .use(CHAT_MARKDOWN_REMARK_PLUGINS)
81 .use(remarkRehype, { allowDangerousHtml: true })
82 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
83const assistantBreaksProcessor = unified()
84 .use(remarkParse)
85 .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS)
86 .use(remarkRehype, { allowDangerousHtml: true })
87 .use(CHAT_MARKDOWN_REHYPE_PLUGINS);
88const userProcessor = unified()
89 .use(remarkParse)
90 .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS)
91 .use(remarkRehype, { allowDangerousHtml: true });
92
93interface TextTree {
94 readonly type: string;
95 readonly tagName?: string;
96 readonly value?: string;
97 readonly properties?: {
98 readonly href?: unknown;
99 readonly src?: unknown;
100 readonly dataInlineCode?: unknown;
101 };
102 readonly children?: ReadonlyArray<TextTree>;
103}
104
105/** Uses the renderer's Markdown transforms, without mounting folded/virtualized rows. */
106function markdownThreadFindText(
107 markdown: string,
108 userMessage = false,
109 cwd?: string,
110 skills: readonly InlineSkill[] = [],
111 lineBreaks = false,
112): string[] {
113 const processor = userMessage
114 ? userProcessor
115 : lineBreaks
116 ? assistantBreaksProcessor
117 : assistantProcessor;
118 const tree = processor.runSync(processor.parse(markdown));
119 const paths = [
120 ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)),
121 ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []),
122 ].flatMap((href) => {
123 const target = resolveMarkdownFileLinkTarget(href, cwd);
124 return target ? [splitFilePathPosition(target).path] : [];
125 });
126 const parentSuffixes = buildFileLinkParentSuffixByPath(paths);
127 const segments: string[] = [];
128 let text = "";
129 const flush = () => {
130 if (text.trim()) segments.push(text);
131 text = "";
132 };
133 const visit = (node: TextTree, inPre = false, inlineSkills = false) => {
134 const href = node.properties?.href ?? node.properties?.src;
135 if (userMessage && typeof href === "string" && parseComposerContextHref(href)) {
136 flush();
137 return;
138 }
139 const citation =
140 node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null;
141 if (citation) {
142 // Matches the citation chip, which shows its label instead of the link text.
143 text += assistantCitationLabel(citation);
144 return;
145 }
146 {
147 let candidate: string | null = null;
148 if (node.tagName === "a" && typeof href === "string") {
149 candidate = href;
150 } else if (
151 node.tagName === "code" &&
152 !inPre &&
153 node.properties?.dataInlineCode !== undefined
154 ) {
155 candidate = inlineCodeFilePathCandidate(
156 (node.children ?? []).map((child) => child.value ?? "").join(""),
157 );
158 }
159 const target = candidate ? resolveMarkdownFileLinkTarget(candidate, cwd) : null;
160 if (target) {
161 const labelText = (node: TextTree): string =>
162 node.value ?? (node.children ?? []).map(labelText).join("");
163 const label = labelText(node);
164 if (node.tagName === "a" && candidate && !isMarkdownFileLinkLabel(label, candidate))
165 text += `${label} `;
166 text += fileLinkLabel(splitFilePathPosition(target), parentSuffixes);
167 return;
168 }
169 }
170 const template = artifactTemplateFromHastProperties(node.properties);
171 if (template) {
172 // Matches the card's visible text; its action button is not indexed.
173 flush();
174 segments.push(
175 template.displayName,
176 codexArtifactTemplatePresentationLabel(template.artifactKind),
177 );
178 return;
179 }
180 const block = THREAD_FIND_BLOCK_TAGS.has(node.tagName ?? "");
181 if (block) flush();
182 if (node.type === "text" || (userMessage && node.type === "raw")) {
183 let value = node.value ?? "";
184 if (inlineSkills) {
185 let cursor = 0;
186 let rendered = "";
187 for (const { start, end, skill } of matchInlineSkills(value, skills)) {
188 rendered += value.slice(cursor, start) + formatProviderSkillDisplayName(skill);
189 cursor = end;
190 }
191 value = rendered + value.slice(cursor);
192 }
193 text += inPre ? value : value.replace(/\r?\n/g, " ");
194 }
195 const renderSkills =
196 node.tagName === "code" || node.tagName === "a"
197 ? false
198 : inlineSkills || node.tagName === "p" || node.tagName === "li";
199 for (const child of node.children ?? [])
200 visit(child, inPre || node.tagName === "pre", renderSkills);
201 if (block) flush();
202 };
203 visit(tree);
204 flush();
205 return segments;
206}
207
208/** Plans render `$skill` tokens literally (no skill chips), so they are indexed as written. */
209export function searchablePlanSegments(markdown: string, cwd?: string): readonly string[] {
210 return [
211 proposedPlanTitle(markdown) ?? "Proposed plan",
212 ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd, []),
213 ];
214}
215
216export function searchableMessageSegments(
217 message: Pick<OrchestrationV2ConversationMessage, "role" | "text" | "streaming" | "context">,
218 cwd?: string,
219 skills: readonly InlineSkill[] = [],
220): readonly string[] | null {
221 if (message.role === "user") {
222 const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text;
223 return markdownThreadFindText(text, true, cwd, skills);
224 }
225 if (message.role !== "assistant") return null;
226 return markdownThreadFindText(
227 message.text || (message.streaming ? "" : "(empty response)"),
228 false,
229 cwd,
230 skills,
231 shouldPreserveAssistantLineBreaks(message.text),
232 );
233}
packages/shared/src/threadSearch.test.ts created+23
......@@ -0,0 +1,23 @@
1import { expect, it } from "vite-plus/test";
2import { findThreadSearchOccurrences, splitThreadSearchText } from "./threadSearch.ts";
3
4it("finds non-overlapping occurrences without invalidating Unicode offsets", () => {
5 expect(findThreadSearchOccurrences("Deploy the Deployment", "deploy")).toEqual([0, 11]);
6 expect(findThreadSearchOccurrences("aaaa", "aa")).toEqual([0, 2]);
7 expect(findThreadSearchOccurrences("İİİ needle", "needle")).toEqual([4]);
8});
9
10it("splits search text into highlighted and unhighlighted parts", () => {
11 expect(splitThreadSearchText("one TWO three two", " two ")).toEqual([
12 { text: "one ", highlighted: false, start: 0 },
13 { text: "TWO", highlighted: true, start: 4 },
14 { text: " three ", highlighted: false, start: 7 },
15 { text: "two", highlighted: true, start: 14 },
16 ]);
17});
18
19it("matches both Greek sigma forms without changing source offsets", () => {
20 expect(findThreadSearchOccurrences("ΟΣ ος οσ", "Σ")).toEqual([1, 4, 7]);
21 expect(findThreadSearchOccurrences("ΟΣ ος οσ", "ς")).toEqual([1, 4, 7]);
22 expect(findThreadSearchOccurrences("İ ΟΣ", "Σ")).toEqual([3]);
23});
packages/shared/src/threadSearch.ts created+70
......@@ -0,0 +1,70 @@
1interface ThreadSearchTextPart {
2 readonly text: string;
3 readonly highlighted: boolean;
4 readonly start: number;
5}
6
7function foldThreadSearchCase(text: string): string {
8 const lowered = text.toLowerCase().replace(/ς/g, "σ");
9 if (lowered.length === text.length) return lowered;
10
11 let folded = "";
12 for (const character of text) {
13 const loweredCharacter = character.toLowerCase().replace(/ς/g, "σ");
14 folded += loweredCharacter.length === character.length ? loweredCharacter : character;
15 }
16 return folded;
17}
18
19function scanThreadSearchOccurrences(text: string, query: string, offsets?: number[]): number {
20 if (text.length === 0 || query.length === 0) return 0;
21
22 const foldedText = foldThreadSearchCase(text);
23 const foldedQuery = foldThreadSearchCase(query);
24 let count = 0;
25 let cursor = 0;
26
27 while (cursor <= foldedText.length - foldedQuery.length) {
28 const offset = foldedText.indexOf(foldedQuery, cursor);
29 if (offset === -1) break;
30 offsets?.push(offset);
31 count += 1;
32 cursor = offset + foldedQuery.length;
33 }
34 return count;
35}
36
37export function countThreadSearchOccurrences(text: string, query: string): number {
38 return scanThreadSearchOccurrences(text, query);
39}
40
41export function findThreadSearchOccurrences(text: string, query: string): number[] {
42 const offsets: number[] = [];
43 scanThreadSearchOccurrences(text, query, offsets);
44 return offsets;
45}
46
47export function splitThreadSearchText(text: string, query: string): ThreadSearchTextPart[] {
48 const normalizedQuery = query.trim();
49 if (normalizedQuery.length === 0) {
50 return [{ text, highlighted: false, start: 0 }];
51 }
52
53 const parts: ThreadSearchTextPart[] = [];
54 let cursor = 0;
55 for (const offset of findThreadSearchOccurrences(text, normalizedQuery)) {
56 if (offset > cursor) {
57 parts.push({ text: text.slice(cursor, offset), highlighted: false, start: cursor });
58 }
59 parts.push({
60 text: text.slice(offset, offset + normalizedQuery.length),
61 highlighted: true,
62 start: offset,
63 });
64 cursor = offset + normalizedQuery.length;
65 }
66 if (cursor < text.length) {
67 parts.push({ text: text.slice(cursor), highlighted: false, start: cursor });
68 }
69 return parts;
70}
pnpm-lock.yaml+73-34
......@@ -30,9 +30,42 @@ catalogs:
3030 jose:
3131 specifier: 6.2.2
3232 version: 6.2.2
33 mdast-util-directive:
34 specifier: 3.1.0
35 version: 3.1.0
36 micromark-extension-directive:
37 specifier: 4.0.0
38 version: 4.0.0
39 micromark-util-character:
40 specifier: 2.1.1
41 version: 2.1.1
42 micromark-util-types:
43 specifier: 2.0.2
44 version: 2.0.2
45 rehype-raw:
46 specifier: 7.0.0
47 version: 7.0.0
48 rehype-sanitize:
49 specifier: 6.0.0
50 version: 6.0.0
51 remark-breaks:
52 specifier: 4.0.0
53 version: 4.0.0
54 remark-gfm:
55 specifier: 4.0.1
56 version: 4.0.1
57 remark-parse:
58 specifier: 11.0.0
59 version: 11.0.0
60 remark-rehype:
61 specifier: 11.1.2
62 version: 11.1.2
3363 typescript:
3464 specifier: 7.0.2
3565 version: 7.0.2
66 unified:
67 specifier: 11.0.5
68 version: 11.0.5
3669
3770overrides:
3871 '@exodus/bytes>@noble/hashes': 1.8.0
......@@ -777,17 +810,8 @@ importers:
777810 react-markdown:
778811 specifier: ^10.1.0
779812 version: 10.1.0(@types/react@19.3.0)(react@19.2.6)
780 rehype-raw:
781 specifier: ^7.0.0
782 version: 7.0.0
783 rehype-sanitize:
784 specifier: ^6.0.0
785 version: 6.0.0
786 remark-breaks:
787 specifier: ^4.0.0
788 version: 4.0.0
789813 remark-gfm:
790 specifier: ^4.0.1
814 specifier: 'catalog:'
791815 version: 4.0.1
792816 tailwind-merge:
793817 specifier: ^3.4.0
......@@ -857,7 +881,7 @@ importers:
857881 specifier: 4.3.3
858882 version: 4.3.3
859883 unified:
860 specifier: ^11.0.5
884 specifier: 'catalog:'
861885 version: 11.0.5
862886 vite:
863887 specifier: npm:@voidzero-dev/vite-plus-core@1.0.0
......@@ -957,24 +981,9 @@ importers:
957981 effect:
958982 specifier: 4.0.1
959983 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)
960 mdast-util-directive:
961 specifier: ^3.1.0
962 version: 3.1.0
963 micromark-extension-directive:
964 specifier: ^4.0.0
965 version: 4.0.0
966 micromark-util-character:
967 specifier: ^2.1.1
968 version: 2.1.1
969 remark-parse:
970 specifier: ^11.0.0
971 version: 11.0.0
972984 three:
973985 specifier: 0.180.0
974986 version: 0.180.0
975 unified:
976 specifier: ^11.0.5
977 version: 11.0.5
978987 devDependencies:
979988 '@effect/vitest':
980989 specifier: 4.0.1
......@@ -982,9 +991,6 @@ importers:
982991 '@types/three':
983992 specifier: 0.180.0
984993 version: 0.180.0
985 micromark-util-types:
986 specifier: ^2.0.2
987 version: 2.0.2
988994 vite-plus:
989995 specifier: 1.0.0
990996 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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)
......@@ -1063,6 +1069,36 @@ importers:
10631069 jose:
10641070 specifier: 'catalog:'
10651071 version: 6.2.2
1072 mdast-util-directive:
1073 specifier: 'catalog:'
1074 version: 3.1.0
1075 micromark-extension-directive:
1076 specifier: 'catalog:'
1077 version: 4.0.0
1078 micromark-util-character:
1079 specifier: 'catalog:'
1080 version: 2.1.1
1081 rehype-raw:
1082 specifier: 'catalog:'
1083 version: 7.0.0
1084 rehype-sanitize:
1085 specifier: 'catalog:'
1086 version: 6.0.0
1087 remark-breaks:
1088 specifier: 'catalog:'
1089 version: 4.0.0
1090 remark-gfm:
1091 specifier: 'catalog:'
1092 version: 4.0.1
1093 remark-parse:
1094 specifier: 'catalog:'
1095 version: 11.0.0
1096 remark-rehype:
1097 specifier: 'catalog:'
1098 version: 11.1.2
1099 unified:
1100 specifier: 'catalog:'
1101 version: 11.0.5
10661102 yaml:
10671103 specifier: ^2.9.0
10681104 version: 2.9.0
......@@ -1076,6 +1112,9 @@ importers:
10761112 '@types/node':
10771113 specifier: 24.12.4
10781114 version: 24.12.4
1115 micromark-util-types:
1116 specifier: 'catalog:'
1117 version: 2.0.2
10791118 vite-plus:
10801119 specifier: 1.0.0
10811120 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.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)
......@@ -19004,7 +19043,7 @@ snapshots:
1900419043
1900519044 hast-util-raw@9.1.0:
1900619045 dependencies:
19007 '@types/hast': 3.0.4
19046 '@types/hast': 3.0.5
1900819047 '@types/unist': 3.0.3
1900919048 '@ungap/structured-clone': 1.3.1
1901019049 hast-util-from-parse5: 8.0.3
......@@ -19020,7 +19059,7 @@ snapshots:
1902019059
1902119060 hast-util-sanitize@5.0.2:
1902219061 dependencies:
19023 '@types/hast': 3.0.4
19062 '@types/hast': 3.0.5
1902419063 '@ungap/structured-clone': 1.3.1
1902519064 unist-util-position: 5.0.0
1902619065
......@@ -21445,13 +21484,13 @@ snapshots:
2144521484
2144621485 rehype-raw@7.0.0:
2144721486 dependencies:
21448 '@types/hast': 3.0.4
21487 '@types/hast': 3.0.5
2144921488 hast-util-raw: 9.1.0
2145021489 vfile: 6.0.3
2145121490
2145221491 rehype-sanitize@6.0.0:
2145321492 dependencies:
21454 '@types/hast': 3.0.4
21493 '@types/hast': 3.0.5
2145521494 hast-util-sanitize: 5.0.2
2145621495
2145721496 remark-breaks@4.0.0:
......@@ -21482,7 +21521,7 @@ snapshots:
2148221521
2148321522 remark-rehype@11.1.2:
2148421523 dependencies:
21485 '@types/hast': 3.0.4
21524 '@types/hast': 3.0.5
2148621525 '@types/mdast': 4.0.4
2148721526 mdast-util-to-hast: 13.2.1
2148821527 unified: 11.0.5
pnpm-workspace.yaml+13
......@@ -56,6 +56,19 @@ catalog:
5656 vite-plus: 1.0.0
5757 ws: 8.22.0
5858 yaml: ^2.9.0
59 # Markdown pipeline: the server counts find matches with the same parser the
60 # web renders with, so both must resolve to one version.
61 mdast-util-directive: 3.1.0
62 micromark-extension-directive: 4.0.0
63 micromark-util-character: 2.1.1
64 micromark-util-types: 2.0.2
65 rehype-raw: 7.0.0
66 rehype-sanitize: 6.0.0
67 remark-breaks: 4.0.0
68 remark-gfm: 4.0.1
69 remark-parse: 11.0.0
70 remark-rehype: 11.1.2
71 unified: 11.0.5
5972
6073minimumReleaseAgeExclude:
6174 - expo-glass-effect@58.0.3