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 @@...@@ -1,10 +1,10 @@
1import { fileBasename } from "@t3tools/shared/path";
2import { formatFilePathPosition } from "@t3tools/shared/fileLinks";
1import {3import {
2 fileBasename,
3 formatFilePathPosition,
4 inlineCodeFilePathCandidate,4 inlineCodeFilePathCandidate,
5 normalizeMarkdownLinkDestination,5 normalizeMarkdownLinkDestination,
6 parseMarkdownFileLink,6 parseMarkdownFileLink,
7} from "@t3tools/client-runtime/markdown-links";7} from "@t3tools/shared/markdownLinks";
8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";8import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
9import { videoMimeType } from "@t3tools/shared/video";9import { videoMimeType } from "@t3tools/shared/video";
1010
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { MarkdownNode } from "react-native-nitro-markdown/headless";1import type { MarkdownNode } from "react-native-nitro-markdown/headless";
2import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";2import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";3import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
4import { imageMimeType } from "@t3tools/shared/image";4import { imageMimeType } from "@t3tools/shared/image";
5import { isWindowsAbsolutePath } from "@t3tools/shared/path";5import { isWindowsAbsolutePath } from "@t3tools/shared/path";
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+1-1
...@@ -29,7 +29,7 @@ import type {...@@ -29,7 +29,7 @@ import type {
29import {29import {
30 appendCodexArtifactTemplateUsePrompt,30 appendCodexArtifactTemplateUsePrompt,
31 type CodexArtifactTemplate,31 type CodexArtifactTemplate,
32} from "@t3tools/client-runtime/codex-artifact-templates";32} from "@t3tools/shared/codexArtifactTemplates";
33import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";33import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
34import {34import {
35 presentPendingBackgroundWork,35 presentPendingBackgroundWork,
apps/mobile/src/features/threads/ThreadFeed.tsx+3-3
...@@ -36,9 +36,9 @@ import { writeComposerContextClipboard } from "../../lib/composerContextClipboar...@@ -36,9 +36,9 @@ import { writeComposerContextClipboard } from "../../lib/composerContextClipboar
36import {36import {
37 codexArtifactTemplatePresentationLabel,37 codexArtifactTemplatePresentationLabel,
38 type CodexArtifactTemplate,38 type CodexArtifactTemplate,
39} from "@t3tools/client-runtime/codex-artifact-templates";39} from "@t3tools/shared/codexArtifactTemplates";
40import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";40import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
41import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";41import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
42import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";42import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless";
43import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";43import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
44import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";44import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
...@@ -50,7 +50,7 @@ import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presen...@@ -50,7 +50,7 @@ import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presen
50import {50import {
51 renderCodexFileCitationsAsMarkdown,51 renderCodexFileCitationsAsMarkdown,
52 splitCodexArtifactTemplateMarkdown,52 splitCodexArtifactTemplateMarkdown,
53} from "@t3tools/client-runtime/codex-markdown-directives";53} from "@t3tools/shared/codexMarkdownDirectives";
54import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";54import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
55import { imageMimeType } from "@t3tools/shared/image";55import { imageMimeType } from "@t3tools/shared/image";
56import { videoMimeType } from "@t3tools/shared/video";56import { videoMimeType } from "@t3tools/shared/video";
apps/mobile/src/features/threads/fileChipMenu.ts+1-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { fileBasename } from "@t3tools/client-runtime/markdown-links";1import { fileBasename } from "@t3tools/shared/path";
2import type { ThreadId } from "@t3tools/contracts";2import type { ThreadId } from "@t3tools/contracts";
3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";3import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
4import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";4import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";
apps/mobile/src/features/threads/thread-search-match.tsx+2-41
...@@ -1,48 +1,9 @@...@@ -1,48 +1,9 @@
1import { splitThreadSearchText } from "@t3tools/shared/threadSearch";
1import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";2import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
23
3import { AppText as Text } from "../../components/AppText";4import { AppText as Text } from "../../components/AppText";
4import { cn } from "../../lib/cn";5import { 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
46export function ThreadSearchMatchExcerpt(props: {7export function ThreadSearchMatchExcerpt(props: {
47 readonly match: EnvironmentThreadSearchMatch;8 readonly match: EnvironmentThreadSearchMatch;
48 readonly query: string;9 readonly query: string;
...@@ -51,7 +12,7 @@ export function ThreadSearchMatchExcerpt(props: {...@@ -51,7 +12,7 @@ export function ThreadSearchMatchExcerpt(props: {
51 readonly sidebar?: boolean;12 readonly sidebar?: boolean;
52}) {13}) {
53 const isUser = props.match.source === "user";14 const isUser = props.match.source === "user";
54 const parts = splitHighlightParts(props.match.snippet, props.query);15 const parts = splitThreadSearchText(props.match.snippet, props.query);
55 return (16 return (
56 <Text17 <Text
57 className={cn(18 className={cn(
apps/server/src/auth/RpcAuthorization.ts+2
...@@ -47,6 +47,8 @@ export const RPC_REQUIRED_SCOPES = {...@@ -47,6 +47,8 @@ export const RPC_REQUIRED_SCOPES = {
47 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,47 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
48 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,48 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
49 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,49 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
50 [ORCHESTRATION_V2_WS_METHODS.searchThread]: AuthOrchestrationReadScope,
51 [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: AuthOrchestrationReadScope,
50 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,52 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
51 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,53 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,
52 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope,54 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope,
apps/server/src/observability/RpcInstrumentation.ts+2
...@@ -21,6 +21,8 @@ const RPC_AGGREGATES = {...@@ -21,6 +21,8 @@ const RPC_AGGREGATES = {
21 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",21 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",
22 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",22 [ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: "orchestration",
23 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: "orchestration",23 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: "orchestration",
24 [ORCHESTRATION_V2_WS_METHODS.searchThread]: "orchestration",
25 [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: "orchestration",
24 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration",26 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: "orchestration",
25 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2",27 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: "orchestrationV2",
26 [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration",28 [ORCHESTRATION_V2_WS_METHODS.launchThread]: "orchestration",
apps/server/src/orchestration-v2/Orchestrator.ts+42
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1import type {
2 OrchestrationV2SearchThreadInput,
3 OrchestrationV2SearchThreadResult,
4 OrchestrationV2ThreadHistoryPage,
5} from "@t3tools/contracts";
1import {6import {
2 latestExecutedRun,7 latestExecutedRun,
3 latestRootProviderFailure,8 latestRootProviderFailure,
...@@ -281,6 +286,18 @@ export interface OrchestratorV2Shape {...@@ -281,6 +286,18 @@ export interface OrchestratorV2Shape {
281 readonly dispatch: (286 readonly dispatch: (
282 command: OrchestrationV2ServerCommand,287 command: OrchestrationV2ServerCommand,
283 ) => Effect.Effect<OrchestratorV2DispatchResult, OrchestratorV2Error>;288 ) => 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>;
284 readonly getTimelinePage: (301 readonly getTimelinePage: (
285 threadId: ThreadId,302 threadId: ThreadId,
286 options: ProjectionTimelinePageOptions,303 options: ProjectionTimelinePageOptions,
...@@ -10817,6 +10834,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10817,6 +10834,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10817 recoverDelegatedTask,10834 recoverDelegatedTask,
10818 delegatedTaskResultPending,10835 delegatedTaskResultPending,
10819 dispatch: dispatchWithReceipt,10836 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 }))),
10820 getTimelinePage: (threadId, options) =>10857 getTimelinePage: (threadId, options) =>
10821 projectionStore10858 projectionStore
10822 .getTimelinePage(threadId, options)10859 .getTimelinePage(threadId, options)
...@@ -10951,6 +10988,11 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(...@@ -10951,6 +10988,11 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
10951 cause: "Orchestration V2 live runtime is not configured.",10988 cause: "Orchestration V2 live runtime is not configured.",
10952 }),10989 }),
10953 ),10990 ),
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 })),
10954 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),10996 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10955 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),10997 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10956 getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })),10998 getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })),
apps/server/src/orchestration-v2/ProjectionStore.ts+207-6
...@@ -1,3 +1,11 @@...@@ -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";
1import {9import {
2 latestRootProviderFailure,10 latestRootProviderFailure,
3 latestUnheldRun,11 latestUnheldRun,
...@@ -28,7 +36,6 @@ import type {...@@ -28,7 +36,6 @@ import type {
28 ProviderThreadId,36 ProviderThreadId,
29 ProviderTurnId,37 ProviderTurnId,
30 RunAttemptId,38 RunAttemptId,
31 MessageId,
32} from "@t3tools/contracts";39} from "@t3tools/contracts";
33import {40import {
34 OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema,41 OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema,
...@@ -49,6 +56,7 @@ import {...@@ -49,6 +56,7 @@ import {
49 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,56 OrchestrationV2TurnItemJson as OrchestrationV2TurnItemJsonSchema,
50 orchestrationV2RunWorkStartedAt,57 orchestrationV2RunWorkStartedAt,
51 RunId,58 RunId,
59 MessageId,
52 RuntimeRequestId,60 RuntimeRequestId,
53 CheckpointScopeId,61 CheckpointScopeId,
54 ThreadId,62 ThreadId,
...@@ -74,8 +82,13 @@ import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp";...@@ -74,8 +82,13 @@ import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp";
74import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts";82import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts";
75import {83import {
76 isThreadHistoryUserTurn,84 isThreadHistoryUserTurn,
85 isConversationHistoryItem,
77 isThreadHistoryTurnStart,86 isThreadHistoryTurnStart,
78 THREAD_HISTORY_MAX_RAW_TURNS,87 THREAD_HISTORY_MAX_RAW_TURNS,
88 selectHistoryPageFromCursor,
89 selectRecentTimelineWindow,
90 THREAD_HISTORY_PAGE_POLICY,
91 OLDER_THREAD_USER_TURN_LIMIT,
79} from "./threadHistoryPaging.ts";92} from "./threadHistoryPaging.ts";
8093
81export class ProjectionStoreApplyEventError extends Schema.TaggedError<ProjectionStoreApplyEventError>()(94export class ProjectionStoreApplyEventError extends Schema.TaggedError<ProjectionStoreApplyEventError>()(
...@@ -318,9 +331,21 @@ export interface ShellSnapshotOptions {...@@ -318,9 +331,21 @@ export interface ShellSnapshotOptions {
318}331}
319332
320export interface ProjectionStoreV2Shape {333export interface ProjectionStoreV2Shape {
334 readonly searchThreadStream: (
335 input: OrchestrationV2SearchThreadInput,
336 ) => Stream.Stream<OrchestrationV2SearchThreadResult, ProjectionStoreV2Error>;
337 readonly searchThread: (
338 input: OrchestrationV2SearchThreadInput,
339 ) => Effect.Effect<OrchestrationV2SearchThreadResult, ProjectionStoreV2Error>;
321 readonly getThreadAttachmentIds: (340 readonly getThreadAttachmentIds: (
322 threadId: ThreadId,341 threadId: ThreadId,
323 ) => Effect.Effect<ReadonlyArray<string>, ProjectionStoreV2Error>;342 ) => 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>;
324 readonly getTimelinePage: (349 readonly getTimelinePage: (
325 threadId: ThreadId,350 threadId: ThreadId,
326 options: ProjectionTimelinePageOptions,351 options: ProjectionTimelinePageOptions,
...@@ -969,6 +994,11 @@ type ShellRunItemCountRow = {...@@ -969,6 +994,11 @@ type ShellRunItemCountRow = {
969 readonly item_count: number;994 readonly item_count: number;
970};995};
971996
997const encodeTimelineSources = Schema.encodeEffect(
998 Schema.fromJsonString(
999 Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.String })),
1000 ),
1001);
972const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));1002const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
9731003
974const encodeThreadPayload = Schema.encodeEffect(1004const encodeThreadPayload = Schema.encodeEffect(
...@@ -4706,12 +4736,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -4706,12 +4736,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
4706 );4736 );
47074737
4708 // Only the timeline index is assembled across fork ancestry. Payloads are read4738 // 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.
4710 type TimelineIndexItem = Pick<4740 type TimelineIndexItem = Pick<
4711 OrchestrationV2TurnItem,4741 OrchestrationV2TurnItem,
4712 "id" | "threadId" | "runId" | "nodeId" | "type"4742 "id" | "threadId" | "runId" | "nodeId" | "type"
4713 > & {4743 > & {
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;
4715 };4750 };
4716 type TimelineIndexRow = {4751 type TimelineIndexRow = {
4717 readonly sourceThreadId: ThreadId;4752 readonly sourceThreadId: ThreadId;
...@@ -4738,9 +4773,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -4738,9 +4773,13 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
4738 run_id: string | null;4773 run_id: string | null;
4739 node_id: string | null;4774 node_id: string | null;
4740 type: OrchestrationV2TurnItem["type"];4775 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;
4742 }>`SELECT turn_item_id, run_id, node_id, type,4779 }>`SELECT turn_item_id, run_id, node_id, type,
4743 CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent4780 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
4744 FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}4783 FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId}
4745 ORDER BY ordinal ASC, turn_item_id ASC`;4784 ORDER BY ordinal ASC, turn_item_id ASC`;
4746 const items: Array<TimelineIndexItem> = rows.map((row) => ({4785 const items: Array<TimelineIndexItem> = rows.map((row) => ({
...@@ -4749,7 +4788,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -4749,7 +4788,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
4749 runId: row.run_id === null ? null : RunId.make(row.run_id),4788 runId: row.run_id === null ? null : RunId.make(row.run_id),
4750 nodeId: row.node_id === null ? null : NodeId.make(row.node_id),4789 nodeId: row.node_id === null ? null : NodeId.make(row.node_id),
4751 type: row.type,4790 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) }),
4753 }));4794 }));
4754 const local: Array<TimelineIndexRow> = items.map((item) => ({4795 const local: Array<TimelineIndexRow> = items.map((item) => ({
4755 sourceThreadId: threadId,4796 sourceThreadId: threadId,
...@@ -4819,6 +4860,116 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -4819,6 +4860,116 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
4819 ),4860 ),
4820 );4861 );
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
4822 const getTimelinePage: ProjectionStoreV2Shape["getTimelinePage"] = (threadId, options) =>4973 const getTimelinePage: ProjectionStoreV2Shape["getTimelinePage"] = (threadId, options) =>
4823 sql4974 sql
4824 .withTransaction(4975 .withTransaction(
...@@ -5756,6 +5907,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5756,6 +5907,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5756 getThreadSnapshot,5907 getThreadSnapshot,
5757 getThreadSnapshotWindow,5908 getThreadSnapshotWindow,
5758 getTimelinePage,5909 getTimelinePage,
5910 getThreadHistoryPage,
5911 searchThread,
5912 searchThreadStream,
5759 getThreadAttachmentIds,5913 getThreadAttachmentIds,
5760 } satisfies ProjectionStoreV2Shape;5914 } satisfies ProjectionStoreV2Shape;
5761 }),5915 }),
...@@ -6314,6 +6468,53 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(...@@ -6314,6 +6468,53 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
6314 ),6468 ),
6315 ),6469 ),
6316 ),6470 ),
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 ),
6317 getTimelinePage: (threadId, options) =>6518 getTimelinePage: (threadId, options) =>
6318 service.getThreadProjection(threadId).pipe(6519 service.getThreadProjection(threadId).pipe(
6319 Effect.map((projection) => {6520 Effect.map((projection) => {
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+3
...@@ -225,6 +225,9 @@ it.effect(...@@ -225,6 +225,9 @@ it.effect(
225 getPlan: () => Effect.die("unused"),225 getPlan: () => Effect.die("unused"),
226 hasUnpairedRunInterruptRequest: () => Effect.die("unused interrupt read"),226 hasUnpairedRunInterruptRequest: () => Effect.die("unused interrupt read"),
227 getThreadAttachmentIds: () => Effect.die("Unused attachment lookup"),227 getThreadAttachmentIds: () => Effect.die("Unused attachment lookup"),
228 searchThread: () => Effect.die("unused"),
229 searchThreadStream: () => Stream.empty,
230 getThreadHistoryPage: () => Effect.die("unused"),
228 getTimelinePage: () => Effect.die("Unused timeline read"),231 getTimelinePage: () => Effect.die("Unused timeline read"),
229 getMessageCount: () => Effect.die("unused message count"),232 getMessageCount: () => Effect.die("unused message count"),
230 getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"),233 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 @@...@@ -1,3 +1,7 @@
1import type {
2 OrchestrationV2SearchThreadInput,
3 OrchestrationV2SearchThreadResult,
4} from "@t3tools/contracts";
1import type {5import type {
2 ProjectionRecordField,6 ProjectionRecordField,
3 ProjectionRecordFilter,7 ProjectionRecordFilter,
...@@ -271,12 +275,19 @@ export type ThreadManagementError = typeof ThreadManagementError.Type;...@@ -271,12 +275,19 @@ export type ThreadManagementError = typeof ThreadManagementError.Type;
271type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error;275type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error;
272276
273export interface ThreadManagementServiceShape {277export 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>;
274 readonly ensureLegacyTranscript: (284 readonly ensureLegacyTranscript: (
275 threadId: ThreadId,285 threadId: ThreadId,
276 ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>;286 ) => Effect.Effect<void, LegacyV1ThreadImporter.LegacyV1ThreadImportError>;
277 readonly dispatch: (287 readonly dispatch: (
278 command: OrchestrationV2ServerCommand,288 command: OrchestrationV2ServerCommand,
279 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;289 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
290 readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"];
280 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];291 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
281 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];292 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];
282 /**293 /**
...@@ -859,7 +870,23 @@ const make = Effect.gen(function* () {...@@ -859,7 +870,23 @@ const make = Effect.gen(function* () {
859870
860 return ThreadManagementService.of({871 return ThreadManagementService.of({
861 ensureLegacyTranscript,872 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 ),
862 dispatch,883 dispatch,
884 getThreadHistoryPage: (threadId, cursor, throughEntryId, conversationOnly) =>
885 ensureProjectionTranscript(threadId).pipe(
886 Effect.andThen(
887 orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly),
888 ),
889 ),
863 getTimelinePage: (threadId, options) =>890 getTimelinePage: (threadId, options) =>
864 ensureProjectionTranscript(threadId).pipe(891 ensureProjectionTranscript(threadId).pipe(
865 Effect.andThen(orchestrator.getTimelinePage(threadId, options)),892 Effect.andThen(orchestrator.getTimelinePage(threadId, options)),
apps/server/src/orchestration-v2/http.ts+21-48
...@@ -1,8 +1,6 @@...@@ -1,8 +1,6 @@
1import {1import {
2 AuthOrchestrationReadScope,2 AuthOrchestrationReadScope,
3 EnvironmentHttpApi,3 EnvironmentHttpApi,
4 ThreadId,
5 TurnItemId,
6 type OrchestrationProjectShell,4 type OrchestrationProjectShell,
7} from "@t3tools/contracts";5} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
...@@ -24,7 +22,6 @@ import {...@@ -24,7 +22,6 @@ import {
24 buildBoundedThreadProjection,22 buildBoundedThreadProjection,
25 decodeThreadHistoryCursor,23 decodeThreadHistoryCursor,
26 InvalidThreadHistoryCursorError,24 InvalidThreadHistoryCursorError,
27 selectHistoryPageFromCursor,
28 THREAD_HISTORY_SNAPSHOT_ROW_LIMIT,25 THREAD_HISTORY_SNAPSHOT_ROW_LIMIT,
29 THREAD_HISTORY_PAGE_POLICY,26 THREAD_HISTORY_PAGE_POLICY,
30 OLDER_THREAD_USER_TURN_LIMIT,27 OLDER_THREAD_USER_TURN_LIMIT,
...@@ -43,22 +40,6 @@ function isThreadNotFound(error: unknown): boolean {...@@ -43,22 +40,6 @@ function isThreadNotFound(error: unknown): boolean {
43 );40 );
44}41}
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
62/**43/**
63 * Serves orchestration V2 snapshots over HTTP so clients can load the44 * Serves orchestration V2 snapshots over HTTP so clients can load the
64 * (potentially large) shell and thread projections off the socket; gzip45 * (potentially large) shell and thread projections off the socket; gzip
...@@ -222,41 +203,33 @@ export const layer = HttpApiBuilder.group(...@@ -222,41 +203,33 @@ export const layer = HttpApiBuilder.group(
222 Effect.fn("environment.orchestration.threadHistoryPage")(function* (args) {203 Effect.fn("environment.orchestration.threadHistoryPage")(function* (args) {
223 yield* annotateEnvironmentRequest(args.endpoint.name);204 yield* annotateEnvironmentRequest(args.endpoint.name);
224 yield* requireEnvironmentScope(AuthOrchestrationReadScope);205 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
225 let anchorItemId;
226 try {206 try {
227 anchorItemId = TurnItemId.make(decodeThreadHistoryCursor(args.query.cursor).si);207 decodeThreadHistoryCursor(args.query.cursor);
228 } catch (cause) {208 } catch (cause) {
229 if (cause instanceof InvalidThreadHistoryCursorError) {209 if (cause instanceof InvalidThreadHistoryCursorError)
230 return yield* failEnvironmentInvalidRequest("invalid_history_cursor");210 return yield* failEnvironmentInvalidRequest("invalid_history_cursor");
231 }
232 return yield* failEnvironmentInternal("orchestration_thread_history_failed", cause);211 return yield* failEnvironmentInternal("orchestration_thread_history_failed", cause);
233 }212 }
234 const decodedCursor = decodeThreadHistoryCursor(args.query.cursor);213 return yield* threadManagement
235 const snapshot = yield* loadThreadSnapshotWindow(214 .getThreadHistoryPage(
236 args.params.threadId,215 args.params.threadId,
237 anchorItemId,216 args.query.cursor,
238 ThreadId.make(decodedCursor.st),217 args.query.throughEntryId,
239 ).pipe(traceLocalHandlerWork);218 args.query.view === "conversation",
240 const pageOrError = selectHistoryPageFromCursorOrError({219 )
241 items: snapshot.projection.visibleTurnItems,220 .pipe(
242 cursor: args.query.cursor,221 traceLocalHandlerWork,
243 snapshotSequence: snapshot.snapshotSequence,222 Effect.catch(
244 });223 Effect.fnUntraced(function* (cause) {
245 if (pageOrError._tag === "invalid_cursor") {224 if (isThreadNotFound(cause))
246 return yield* failEnvironmentInvalidRequest("invalid_history_cursor");225 return yield* failEnvironmentNotFound("thread_not_found");
247 }226 return yield* failEnvironmentInternal(
248 if (pageOrError._tag === "error") {227 "orchestration_thread_history_failed",
249 return yield* failEnvironmentInternal(228 cause,
250 "orchestration_thread_history_failed",229 );
251 pageOrError.cause,230 }),
231 ),
252 );232 );
253 }
254 return {
255 snapshotSequence: snapshot.snapshotSequence,
256 items: pageOrError.page.items,
257 nextCursor: pageOrError.page.nextCursor,
258 hasMoreHistory: pageOrError.page.hasMoreHistory,
259 };
260 }),233 }),
261 );234 );
262 }),235 }),
apps/server/src/orchestration-v2/threadHistoryPaging.ts+61-21
...@@ -17,6 +17,11 @@ export const THREAD_HISTORY_PAGE_POLICY = {...@@ -17,6 +17,11 @@ export const THREAD_HISTORY_PAGE_POLICY = {
17} as const;17} as const;
1818
19export const OLDER_THREAD_USER_TURN_LIMIT = 20;19export 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}
20export const THREAD_HISTORY_MAX_RAW_TURNS = 150;25export const THREAD_HISTORY_MAX_RAW_TURNS = 150;
2126
22/** Extra rows let the projection query retain an inclusive cursor and prove another page exists. */27/** Extra rows let the projection query retain an inclusive cursor and prove another page exists. */
...@@ -39,8 +44,19 @@ export type ThreadHistoryCursorPayload = {...@@ -39,8 +44,19 @@ export type ThreadHistoryCursorPayload = {
39 readonly p: number;44 readonly p: number;
40};45};
4146
42export type SelectTimelinePageResult = {47type HistoryRow = Pick<
43 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;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>;
44 readonly nextCursor: string | null;60 readonly nextCursor: string | null;
45 readonly hasMoreHistory: boolean;61 readonly hasMoreHistory: boolean;
46};62};
...@@ -67,7 +83,7 @@ function bytesOfJson(value: unknown): number {...@@ -67,7 +83,7 @@ function bytesOfJson(value: unknown): number {
67}83}
6884
69/** Ordinary history-page cost: one projected row (item is nested once). */85/** Ordinary history-page cost: one projected row (item is nested once). */
70export function projectedRowEncodedBytes(row: OrchestrationV2ProjectedTurnItem): number {86export function projectedRowEncodedBytes(row: HistoryRow): number {
71 return bytesOfJson(row);87 return bytesOfJson(row);
72}88}
7389
...@@ -87,9 +103,7 @@ export function projectedRowBoundedSnapshotEncodedBytes(...@@ -87,9 +103,7 @@ export function projectedRowBoundedSnapshotEncodedBytes(
87 return rowBytes;103 return rowBytes;
88}104}
89105
90function renumberPositions(106function renumberPositions<Row extends HistoryRow>(items: ReadonlyArray<Row>): Row[] {
91 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
92): OrchestrationV2ProjectedTurnItem[] {
93 return items.map((row, position) => (row.position === position ? row : { ...row, position }));107 return items.map((row, position) => (row.position === position ? row : { ...row, position }));
94}108}
95109
...@@ -148,7 +162,7 @@ export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPa...@@ -148,7 +162,7 @@ export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPa
148 return parsed as ThreadHistoryCursorPayload;162 return parsed as ThreadHistoryCursorPayload;
149}163}
150164
151export function isThreadHistoryTurnStart(item: OrchestrationV2TurnItem): boolean {165export function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean {
152 return (166 return (
153 item.type === "user_message" &&167 item.type === "user_message" &&
154 (item.inputIntent === "turn_start" || item.inputIntent === "queued_turn")168 (item.inputIntent === "turn_start" || item.inputIntent === "queued_turn")
...@@ -156,7 +170,7 @@ export function isThreadHistoryTurnStart(item: OrchestrationV2TurnItem): boolean...@@ -156,7 +170,7 @@ export function isThreadHistoryTurnStart(item: OrchestrationV2TurnItem): boolean
156}170}
157171
158/** Steering belongs to its existing turn and must not consume another page slot. */172/** 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 {
160 return (174 return (
161 isThreadHistoryTurnStart(item) && item.type === "user_message" && item.createdBy === "user"175 isThreadHistoryTurnStart(item) && item.type === "user_message" && item.createdBy === "user"
162 );176 );
...@@ -167,13 +181,13 @@ export function isThreadHistoryUserTurn(item: OrchestrationV2TurnItem): boolean...@@ -167,13 +181,13 @@ export function isThreadHistoryUserTurn(item: OrchestrationV2TurnItem): boolean
167 * through complete user turns. Histories without turn starts use row budgets181 * through complete user turns. Histories without turn starts use row budgets
168 * and always admit at least one row so oversized items cannot deadlock paging.182 * and always admit at least one row so oversized items cannot deadlock paging.
169 */183 */
170function selectOlderTimelinePage(input: {184function selectOlderTimelinePage<Row extends HistoryRow>(input: {
171 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;185 readonly items: ReadonlyArray<Row>;
172 readonly exclusiveEndIndex: number;186 readonly exclusiveEndIndex: number;
173 readonly snapshotSequence: number;187 readonly snapshotSequence: number;
174 readonly policy?: ThreadHistoryPagePolicy | undefined;188 readonly policy?: ThreadHistoryPagePolicy | undefined;
175 readonly rowEncodedBytes?: ((row: OrchestrationV2ProjectedTurnItem) => number) | undefined;189 readonly rowEncodedBytes?: ((row: Row) => number) | undefined;
176}): SelectTimelinePageResult {190}): SelectTimelinePageResult<Row> {
177 const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY;191 const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY;
178 const rowCost = input.rowEncodedBytes ?? projectedRowEncodedBytes;192 const rowCost = input.rowEncodedBytes ?? projectedRowEncodedBytes;
179 const end = Math.min(Math.max(input.exclusiveEndIndex, 0), input.items.length);193 const end = Math.min(Math.max(input.exclusiveEndIndex, 0), input.items.length);
...@@ -181,7 +195,7 @@ function selectOlderTimelinePage(input: {...@@ -181,7 +195,7 @@ function selectOlderTimelinePage(input: {
181 return { items: [], nextCursor: null, hasMoreHistory: false };195 return { items: [], nextCursor: null, hasMoreHistory: false };
182 }196 }
183197
184 const selected: OrchestrationV2ProjectedTurnItem[] = [];198 const selected: Row[] = [];
185 let encodedBytes = 0;199 let encodedBytes = 0;
186 let userTurns = 0;200 let userTurns = 0;
187 let rawTurns = 0;201 let rawTurns = 0;
...@@ -247,7 +261,7 @@ export function selectRecentTimelineWindow(input: {...@@ -247,7 +261,7 @@ export function selectRecentTimelineWindow(input: {
247}261}
248262
249function findCursorIndex(263function findCursorIndex(
250 items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,264 items: ReadonlyArray<Pick<HistoryRow, "sourceThreadId" | "sourceItemId">>,
251 cursor: ThreadHistoryCursorPayload,265 cursor: ThreadHistoryCursorPayload,
252): number {266): number {
253 const byIdentity = items.findIndex(267 const byIdentity = items.findIndex(
...@@ -264,22 +278,48 @@ function findCursorIndex(...@@ -264,22 +278,48 @@ function findCursorIndex(
264 return Math.min(cursor.p, items.length);278 return Math.min(cursor.p, items.length);
265}279}
266280
267export function selectHistoryPageFromCursor(input: {281export function selectHistoryPageFromCursor<Row extends HistoryRow>(input: {
268 readonly items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;282 readonly items: ReadonlyArray<Row>;
269 readonly cursor: string;283 readonly cursor: string;
270 readonly snapshotSequence: number;284 readonly snapshotSequence: number;
271 readonly policy?: ThreadHistoryPagePolicy | undefined;285 readonly policy?: ThreadHistoryPagePolicy | undefined;
272}): SelectTimelinePageResult {286 readonly rowEncodedBytes?: ((row: Row) => number) | undefined;
287 readonly throughEntryId?: string | undefined;
288}): SelectTimelinePageResult<Row> {
273 const decoded = decodeThreadHistoryCursor(input.cursor);289 const decoded = decodeThreadHistoryCursor(input.cursor);
274 const anchorIndex = findCursorIndex(input.items, decoded);290 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 }
275 return selectOlderTimelinePage({317 return selectOlderTimelinePage({
276 items: input.items,318 items: input.items,
277 exclusiveEndIndex: anchorIndex,319 exclusiveEndIndex: anchorIndex,
278 snapshotSequence: input.snapshotSequence,320 snapshotSequence: input.snapshotSequence,
279 policy: input.policy ?? {321 ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }),
280 ...THREAD_HISTORY_PAGE_POLICY,322 policy,
281 maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT,
282 },
283 });323 });
284}324}
285325
apps/server/src/relay/AgentAwarenessRelay.test.ts+3
...@@ -193,6 +193,9 @@ const makeTestRelay = Effect.fnUntraced(function* (...@@ -193,6 +193,9 @@ const makeTestRelay = Effect.fnUntraced(function* (
193 readShellSnapshot: unused,193 readShellSnapshot: unused,
194 ensureLegacyTranscript: unused,194 ensureLegacyTranscript: unused,
195 dispatch: unused,195 dispatch: unused,
196 searchThread: () => Effect.die("unused"),
197 searchThreadStream: () => Stream.empty,
198 getThreadHistoryPage: () => Effect.die("unused"),
196 getTimelinePage: () => Effect.die("Unused timeline read"),199 getTimelinePage: () => Effect.die("Unused timeline read"),
197 getMessageCount: () => Effect.die("unused message count"),200 getMessageCount: () => Effect.die("unused message count"),
198 getTurnItem: () => Effect.die("unused turn item read"),201 getTurnItem: () => Effect.die("unused turn item read"),
apps/server/src/ws.ts+11
...@@ -52,6 +52,7 @@ import {...@@ -52,6 +52,7 @@ import {
52 type AcpRegistrySetProviderInput,52 type AcpRegistrySetProviderInput,
53 OrchestrationGetFullThreadDiffError,53 OrchestrationGetFullThreadDiffError,
54 OrchestrationSearchThreadsError,54 OrchestrationSearchThreadsError,
55 OrchestrationV2SearchThreadError,
55 OrchestrationGetTurnDiffError,56 OrchestrationGetTurnDiffError,
56 ORCHESTRATION_V2_WS_METHODS,57 ORCHESTRATION_V2_WS_METHODS,
57 ORCHESTRATION_PROTOCOL_QUERY_PARAM,58 ORCHESTRATION_PROTOCOL_QUERY_PARAM,
...@@ -1727,6 +1728,8 @@ const layerWsRpc = (...@@ -1727,6 +1728,8 @@ const layerWsRpc = (
1727 shellResumeCompletionMarker: true,1728 shellResumeCompletionMarker: true,
1728 threadResumeCompletionMarker: true,1729 threadResumeCompletionMarker: true,
1729 threadSnapshotPagination: true,1730 threadSnapshotPagination: true,
1731 threadFind: true,
1732 threadFindProgressive: true,
1730 ...Option.match(scratchWorkspaceRoot, {1733 ...Option.match(scratchWorkspaceRoot, {
1731 onNone: () => ({}),1734 onNone: () => ({}),
1732 onSome: (root) => ({ scratchWorkspaceRoot: root }),1735 onSome: (root) => ({ scratchWorkspaceRoot: root }),
...@@ -1894,6 +1897,14 @@ const layerWsRpc = (...@@ -1894,6 +1897,14 @@ const layerWsRpc = (
1894 }),1897 }),
1895 ),1898 ),
1896 ),1899 ),
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 }))),
1897 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) =>1908 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) =>
1898 threadSearch.search(input).pipe(1909 threadSearch.search(input).pipe(
1899 Effect.mapError(1910 Effect.mapError(
apps/web/package.json+2-5
...@@ -60,10 +60,7 @@...@@ -60,10 +60,7 @@
60 "react": "19.2.6",60 "react": "19.2.6",
61 "react-dom": "19.2.6",61 "react-dom": "19.2.6",
62 "react-markdown": "^10.1.0",62 "react-markdown": "^10.1.0",
63 "rehype-raw": "^7.0.0",63 "remark-gfm": "catalog:",
64 "rehype-sanitize": "^6.0.0",
65 "remark-breaks": "^4.0.0",
66 "remark-gfm": "^4.0.1",
67 "tailwind-merge": "^3.4.0",64 "tailwind-merge": "^3.4.0",
68 "unbash": "^5.0.0",65 "unbash": "^5.0.0",
69 "zustand": "^5.0.11"66 "zustand": "^5.0.11"
...@@ -88,7 +85,7 @@...@@ -88,7 +85,7 @@
88 "jsdom": "^30.0.1",85 "jsdom": "^30.0.1",
89 "react-test-renderer": "19.2.6",86 "react-test-renderer": "19.2.6",
90 "tailwindcss": "^4.0.0",87 "tailwindcss": "^4.0.0",
91 "unified": "^11.0.5",88 "unified": "catalog:",
92 "vite": "catalog:",89 "vite": "catalog:",
93 "vite-plus": "catalog:"90 "vite-plus": "catalog:"
94 }91 }
apps/web/src/components/ChatMarkdown.test.tsx+238
...@@ -1,6 +1,12 @@...@@ -1,6 +1,12 @@
1// @vitest-environment jsdom1// @vitest-environment jsdom
22
3import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts";3import { 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";
4import { act, type ComponentProps, type ReactNode } from "react";10import { act, type ComponentProps, type ReactNode } from "react";
5import { renderToStaticMarkup } from "react-dom/server";11import { renderToStaticMarkup } from "react-dom/server";
6import { create, type ReactTestRenderer } from "react-test-renderer";12import { create, type ReactTestRenderer } from "react-test-renderer";
...@@ -12,6 +18,10 @@ import { Button } from "./ui/button";...@@ -12,6 +18,10 @@ import { Button } from "./ui/button";
12import { setMarkdownTaskChecked } from "./files/filePreviewMode";18import { setMarkdownTaskChecked } from "./files/filePreviewMode";
1319
14vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));20vi.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}));
15vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));25vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
16vi.mock("../hooks/useSettings", async (importOriginal) => {26vi.mock("../hooks/useSettings", async (importOriginal) => {
17 const actual = await importOriginal<typeof import("../hooks/useSettings")>();27 const actual = await importOriginal<typeof import("../hooks/useSettings")>();
...@@ -1034,3 +1044,231 @@ describe("ChatMarkdown Windows file links", () => {...@@ -1034,3 +1044,231 @@ describe("ChatMarkdown Windows file links", () => {
1034 expect(html).not.toContain("chat-markdown-file-link");1044 expect(html).not.toContain("chat-markdown-file-link");
1035 });1045 });
1036});1046});
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 @@...@@ -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";
1import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";15import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
2import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts";16import { 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";
3import { useAtomValue } from "@effect/atom-react";22import { useAtomValue } from "@effect/atom-react";
4import {23import {
5 COMPOSER_CONTEXT_CLIPBOARD_MIME,24 COMPOSER_CONTEXT_CLIPBOARD_MIME,
...@@ -46,12 +65,11 @@ import {...@@ -46,12 +65,11 @@ import {
46 codexArtifactTemplatePresentationLabel,65 codexArtifactTemplatePresentationLabel,
47 type CodexArtifactTemplate,66 type CodexArtifactTemplate,
48 type CodexArtifactTemplateKind,67 type CodexArtifactTemplateKind,
49} from "@t3tools/client-runtime/codex-artifact-templates";68} from "@t3tools/shared/codexArtifactTemplates";
50import {69import {
51 classifyMarkdownImageSource,70 classifyMarkdownImageSource,
52 markdownImageSourceFragment,71 markdownImageSourceFragment,
53} from "@t3tools/client-runtime/markdown-images";72} from "@t3tools/client-runtime/markdown-images";
54import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links";
55import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";73import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference";
56import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";74import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview";
57import * as Cause from "effect/Cause";75import * as Cause from "effect/Cause";
...@@ -83,24 +101,16 @@ import ReactMarkdown from "react-markdown";...@@ -83,24 +101,16 @@ import ReactMarkdown from "react-markdown";
83import { toHtml } from "hast-util-to-html";101import { toHtml } from "hast-util-to-html";
84import { createIncrementalMarkdownPlugin } from "../markdown-incremental";102import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
85import { defaultUrlTransform } from "react-markdown";103import { defaultUrlTransform } from "react-markdown";
86import rehypeRaw from "rehype-raw";
87import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
88import remarkBreaks from "remark-breaks";
89import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";104import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
90import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";105import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
91import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks";106import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
92import { AssistantCitationChip } from "./chat/AssistantCitationChip";107import { AssistantCitationChip } from "./chat/AssistantCitationChip";
93import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";108import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
94import remarkGfm from "remark-gfm";
95import type { Processor } from "unified";
96import { isWindowsAbsolutePath } from "@t3tools/shared/path";109import { isWindowsAbsolutePath } from "@t3tools/shared/path";
97import { remarkGithubAlerts } from "../markdown-github-alerts";
98import {110import {
99 artifactTemplateFromHastProperties,111 artifactTemplateFromHastProperties,
100 CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES,
101 remarkCodexDirectives,
102 renderCodexFileCitationsAsMarkdown,112 renderCodexFileCitationsAsMarkdown,
103} from "@t3tools/client-runtime/codex-markdown-directives";113} from "@t3tools/shared/codexMarkdownDirectives";
104import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";114import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText";
105import {115import {
106 resolveMarkdownMediaPreview,116 resolveMarkdownMediaPreview,
...@@ -153,11 +163,7 @@ import {...@@ -153,11 +163,7 @@ import {
153 serializeTableElementToCsv,163 serializeTableElementToCsv,
154 serializeTableElementToMarkdown,164 serializeTableElementToMarkdown,
155} from "../markdown-clipboard";165} from "../markdown-clipboard";
156import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
157import {166import {
158 extractMarkdownLinkHrefs,
159 isWindowsDrivePathHref,
160 normalizeMarkdownLinkDestination,
161 resolveInlineCodeFileLinkMeta,167 resolveInlineCodeFileLinkMeta,
162 resolveMarkdownFileLinkMeta,168 resolveMarkdownFileLinkMeta,
163 rewriteMarkdownFileUriHref,169 rewriteMarkdownFileUriHref,
...@@ -165,7 +171,7 @@ import {...@@ -165,7 +171,7 @@ import {
165 shouldOpenMarkdownFileLinkInEditor,171 shouldOpenMarkdownFileLinkInEditor,
166 type MarkdownFileLinkMeta,172 type MarkdownFileLinkMeta,
167} from "../markdown-links";173} from "../markdown-links";
168import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";174import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks";
169import { readLocalApi } from "../localApi";175import { readLocalApi } from "../localApi";
170import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";176import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls";
171import { cn } from "../lib/utils";177import { cn } from "../lib/utils";
...@@ -195,7 +201,6 @@ import {...@@ -195,7 +201,6 @@ import {
195import { useOpenLink } from "../browser/useOpenLink";201import { useOpenLink } from "../browser/useOpenLink";
196import { writeTextToClipboard } from "../hooks/useCopyToClipboard";202import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
197import { isPreviewAvailableFor } from "../browser/previewRuntime";203import { isPreviewAvailableFor } from "../browser/previewRuntime";
198import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links";
199import {204import {
200 isBrowserPreviewFile,205 isBrowserPreviewFile,
201 openFileInPreview,206 openFileInPreview,
...@@ -322,15 +327,17 @@ function CodexArtifactTemplateCard(props: {...@@ -322,15 +327,17 @@ function CodexArtifactTemplateCard(props: {
322 <SparklesIcon aria-hidden className="size-2.5" />327 <SparklesIcon aria-hidden className="size-2.5" />
323 </span>328 </span>
324 </span>329 </span>
325 <span className="min-w-0">330 {/* Block elements keep the name and label separate thread-find segments. */}
326 <span className="block truncate text-sm font-medium text-foreground">331 <div className="min-w-0">
332 <div className="truncate text-sm font-medium text-foreground">
327 {props.template.displayName}333 {props.template.displayName}
328 </span>334 </div>
329 <span className="block text-xs text-muted-foreground">{presentationLabel}</span>335 <div className="text-xs text-muted-foreground">{presentationLabel}</div>
330 </span>336 </div>
331 </div>337 </div>
332 {props.onUse ? (338 {props.onUse ? (
333 <Button339 <Button
340 data-thread-find-ignore
334 type="button"341 type="button"
335 size="sm"342 size="sm"
336 variant="outline"343 variant="outline"
...@@ -396,190 +403,6 @@ function orderedListGutterStyle(...@@ -396,190 +403,6 @@ function orderedListGutterStyle(
396 return { "--list-gutter": `${markerWidth + 2}ch` };403 return { "--list-gutter": `${markerWidth + 2}ch` };
397}404}
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. */
583const GITHUB_ALERT_PRESENTATIONS: Record<406const GITHUB_ALERT_PRESENTATIONS: Record<
584 string,407 string,
585 { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }408 { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string }
...@@ -680,87 +503,6 @@ type MarkdownAstNode = {...@@ -680,87 +503,6 @@ type MarkdownAstNode = {
680 children?: MarkdownAstNode[];503 children?: MarkdownAstNode[];
681};504};
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
764function nodeToPlainText(node: ReactNode): string {506function nodeToPlainText(node: ReactNode): string {
765 if (typeof node === "string" || typeof node === "number") {507 if (typeof node === "string" || typeof node === "number") {
766 return String(node);508 return String(node);
...@@ -947,6 +689,12 @@ function MarkdownDetails({...@@ -947,6 +689,12 @@ function MarkdownDetails({
947 open = false,689 open = false,
948}: Pick<React.ComponentProps<"details">, "children" | "open">) {690}: Pick<React.ComponentProps<"details">, "children" | "open">) {
949 const [isOpen, setIsOpen] = useState(open);691 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);
950 const childNodes = Children.toArray(children);698 const childNodes = Children.toArray(children);
951 const summaryIndex = childNodes.findIndex(699 const summaryIndex = childNodes.findIndex(
952 (child) => isValidElement(child) && child.type === "summary",700 (child) => isValidElement(child) && child.type === "summary",
...@@ -961,10 +709,10 @@ function MarkdownDetails({...@@ -961,10 +709,10 @@ function MarkdownDetails({
961 return (709 return (
962 <div className="my-2 border-y border-border/60">710 <div className="my-2 border-y border-border/60">
963 <Collapsible711 <Collapsible
964 defaultOpen={open}712 open={expanded}
965 onOpenChange={setIsOpen}713 onOpenChange={setIsOpen}
966 data-markdown-details=""714 data-markdown-details=""
967 data-markdown-details-open={isOpen ? "true" : "false"}715 data-markdown-details-open={expanded ? "true" : "false"}
968 >716 >
969 <CollapsibleTrigger717 <CollapsibleTrigger
970 className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90"718 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({...@@ -976,7 +724,7 @@ function MarkdownDetails({
976 />724 />
977 <span>{summary}</span>725 <span>{summary}</span>
978 </CollapsibleTrigger>726 </CollapsibleTrigger>
979 <CollapsiblePanel>727 <CollapsiblePanel ref={findRevealRef} hiddenUntilFound={searching}>
980 <div728 <div
981 className="pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"729 className="pb-3 ps-6 text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)]"
982 data-markdown-details-content=""730 data-markdown-details-content=""
...@@ -995,6 +743,10 @@ function MarkdownDetails({...@@ -995,6 +743,10 @@ function MarkdownDetails({
995 * language title, an optional actions slot, and the body as children. The743 * language title, an optional actions slot, and the body as children. The
996 * composer passes Tiptap's node view wrapper as the outer element.744 * composer passes Tiptap's node view wrapper as the outer element.
997 */745 */
746const THREAD_FIND_IGNORE_PROPS = {
747 "data-thread-find-ignore": "",
748} as React.HTMLAttributes<HTMLDivElement>;
749
998export function MarkdownCodeBlockFrame({750export function MarkdownCodeBlockFrame({
999 as: Wrapper = "div",751 as: Wrapper = "div",
1000 language,752 language,
...@@ -1203,6 +955,8 @@ function MarkdownCodeBlock({...@@ -1203,6 +955,8 @@ function MarkdownCodeBlock({
1203 fenceTitle={fenceTitle}955 fenceTitle={fenceTitle}
1204 theme={theme}956 theme={theme}
1205 wrapped={wrapped}957 wrapped={wrapped}
958 // Find does not count the header, so it must not highlight it either.
959 headerProps={THREAD_FIND_IGNORE_PROPS}
1206 actions={960 actions={
1207 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">961 <span className="flex items-center gap-0.5" role="toolbar" aria-label="Code block actions">
1208 {leadingActions}962 {leadingActions}
...@@ -1272,6 +1026,9 @@ function MarkdownMermaidCodeBlock({...@@ -1272,6 +1026,9 @@ function MarkdownMermaidCodeBlock({
1272 children: ReactNode;1026 children: ReactNode;
1273}) {1027}) {
1274 const [showCode, setShowCode] = useState(false);1028 const [showCode, setShowCode] = useState(false);
1029 const searching = use(MarkdownFindContext);
1030 const revealSource = useCallback(() => setShowCode(true), []);
1031 const sourceRevealRef = useFindRevealRef(revealSource);
1275 const showDiagram = !showCode && !isStreaming && code.trim().length > 0;1032 const showDiagram = !showCode && !isStreaming && code.trim().length > 0;
1276 const toggleLabel = showCode ? "Show diagram" : "Show code";1033 const toggleLabel = showCode ? "Show diagram" : "Show code";
1277 return (1034 return (
...@@ -1315,6 +1072,12 @@ function MarkdownMermaidCodeBlock({...@@ -1315,6 +1072,12 @@ function MarkdownMermaidCodeBlock({
1315 >1072 >
1316 <MermaidDiagram source={code} theme={theme} onExpand={onExpand} />1073 <MermaidDiagram source={code} theme={theme} onExpand={onExpand} />
1317 </Suspense>1074 </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}
1318 </RenderErrorBoundary>1081 </RenderErrorBoundary>
1319 ) : (1082 ) : (
1320 children1083 children
...@@ -1454,79 +1217,6 @@ interface MarkdownFileLinkProps {...@@ -1454,79 +1217,6 @@ interface MarkdownFileLinkProps {
14541217
1455const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link";1218const 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
1530function normalizeMarkdownLinkHrefKey(href: string): string {1220function normalizeMarkdownLinkHrefKey(href: string): string {
1531 const normalizedHref = normalizeMarkdownLinkDestination(href);1221 const normalizedHref = normalizeMarkdownLinkDestination(href);
1532 const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;1222 const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
...@@ -1659,7 +1349,8 @@ function ChatMarkdownMediaUnavailableLabel(props: {...@@ -1659,7 +1349,8 @@ function ChatMarkdownMediaUnavailableLabel(props: {
1659}) {1349}) {
1660 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";1350 const label = props.kind === "video" ? "Video unavailable" : "Image unavailable";
1661 return (1351 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">
1663 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />1354 <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" />
1664 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}1355 {props.alt.length > 0 ? `${label} · ${props.alt}` : label}
1665 </span>1356 </span>
...@@ -2877,18 +2568,6 @@ function useChatMarkdownState({...@@ -2877,18 +2568,6 @@ function useChatMarkdownState({
2877 );2568 );
2878 const fileLinkChip = useCallback(2569 const fileLinkChip = useCallback(
2879 (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => {2570 (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 }
2892 const mediaPath = mediaSource ?? fileLinkMeta.filePath;2571 const mediaPath = mediaSource ?? fileLinkMeta.filePath;
2893 const canPreviewMedia =2572 const canPreviewMedia =
2894 mediaMimeTypeFromExtension(2573 mediaMimeTypeFromExtension(
...@@ -2908,7 +2587,14 @@ function useChatMarkdownState({...@@ -2908,7 +2587,14 @@ function useChatMarkdownState({
2908 displayPath={fileLinkMeta.displayPath}2587 displayPath={fileLinkMeta.displayPath}
2909 panelPath={panelPath}2588 panelPath={panelPath}
2910 line={fileLinkMeta.line}2589 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 )}
2912 copyMarkdown={copyMarkdown}2598 copyMarkdown={copyMarkdown}
2913 theme={resolvedTheme}2599 theme={resolvedTheme}
2914 threadRef={threadRef}2600 threadRef={threadRef}
...@@ -3086,7 +2772,10 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -3086,7 +2772,10 @@ const CHAT_MARKDOWN_COMPONENTS = {
3086 // text under a colored title — which is how the host renders it.2772 // text under a colored title — which is how the host renders it.
3087 return (2773 return (
3088 <div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>2774 <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 >
3090 <alert.Icon aria-hidden className="size-3.5 shrink-0" />2779 <alert.Icon aria-hidden className="size-3.5 shrink-0" />
3091 {alert.label}2780 {alert.label}
3092 </p>2781 </p>
apps/web/src/components/ChatView.logic.test.ts+1-1
...@@ -24,7 +24,7 @@ import {...@@ -24,7 +24,7 @@ import {
24 type OrchestrationV2ProjectedTurnItem,24 type OrchestrationV2ProjectedTurnItem,
25 type WorktreeSetupSnapshot,25 type WorktreeSetupSnapshot,
26} from "@t3tools/contracts";26} from "@t3tools/contracts";
27import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";27import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
28import * as DateTime from "effect/DateTime";28import * as DateTime from "effect/DateTime";
29import * as Option from "effect/Option";29import * as Option from "effect/Option";
30import { afterEach, describe, expect, it, vi } from "vite-plus/test";30import { afterEach, describe, expect, it, vi } from "vite-plus/test";
apps/web/src/components/ChatView.logic.ts+1-1
...@@ -38,7 +38,7 @@ import {...@@ -38,7 +38,7 @@ import {
38 appendCodexArtifactTemplateUsePrompt,38 appendCodexArtifactTemplateUsePrompt,
39 codexArtifactTemplateUsePrompt,39 codexArtifactTemplateUsePrompt,
40 type CodexArtifactTemplate,40 type CodexArtifactTemplate,
41} from "@t3tools/client-runtime/codex-artifact-templates";41} from "@t3tools/shared/codexArtifactTemplates";
42import { presentThreadShell } from "@t3tools/client-runtime/state/shell";42import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
43import {43import {
44 type ChatMessage,44 type ChatMessage,
apps/web/src/components/ChatView.tsx+71-13
...@@ -1,4 +1,5 @@...@@ -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";
2import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";3import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
3import {4import {
4 resolveBackgroundDraftWorkspaceOptions,5 resolveBackgroundDraftWorkspaceOptions,
...@@ -608,7 +609,7 @@ import {...@@ -608,7 +609,7 @@ import {
608const EMPTY_PROVIDERS: ServerProvider[] = [];609const EMPTY_PROVIDERS: ServerProvider[] = [];
609const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = [];610const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = [];
610const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];611const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
611import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";612import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
612613
613const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});614const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
614const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = [];615const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = [];
...@@ -1735,10 +1736,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -1735,10 +1736,13 @@ export default function ChatView(props: ChatViewProps) {
1735 hasMoreHistory: serverThreadHistory.hasMoreHistory,1736 hasMoreHistory: serverThreadHistory.hasMoreHistory,
1736 loading: serverThreadHistory.loading,1737 loading: serverThreadHistory.loading,
1737 error: serverThreadHistory.error,1738 error: serverThreadHistory.error,
1738 onLoadEarlier: () => {1739 onLoadEarlier: (throughEntryId) => {
1739 void loadEarlierThreadHistory({1740 void loadEarlierThreadHistory({
1740 environmentId: routeThreadDetailRef.environmentId,1741 environmentId: routeThreadDetailRef.environmentId,
1741 input: { threadId: routeThreadDetailRef.threadId },1742 input: {
1743 threadId: routeThreadDetailRef.threadId,
1744 ...(throughEntryId === undefined ? {} : { throughEntryId }),
1745 },
1742 });1746 });
1743 },1747 },
1744 };1748 };
...@@ -7931,6 +7935,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -7931,6 +7935,18 @@ export default function ChatView(props: ChatViewProps) {
7931 }),7935 }),
7932 [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase],7936 [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase],
7933 );7937 );
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
7935 useEffect(() => {7951 useEffect(() => {
7936 const handler = (event: globalThis.KeyboardEvent) => {7952 const handler = (event: globalThis.KeyboardEvent) => {
...@@ -7938,6 +7954,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -7938,6 +7954,8 @@ export default function ChatView(props: ChatViewProps) {
7938 event.stopPropagation();7954 event.stopPropagation();
7939 return;7955 return;
7940 }7956 }
7957 // Let contextual controls claim Escape before the bubbling find handler.
7958 if (isThreadFindActive && event.key === "Escape") return;
7941 if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {7959 if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {
7942 event.stopPropagation();7960 event.stopPropagation();
7943 return;7961 return;
...@@ -8029,6 +8047,14 @@ export default function ChatView(props: ChatViewProps) {...@@ -8029,6 +8047,14 @@ export default function ChatView(props: ChatViewProps) {
8029 return;8047 return;
8030 }8048 }
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
8032 if (command === "rightPanel.toggle") {8058 if (command === "rightPanel.toggle") {
8033 event.preventDefault();8059 event.preventDefault();
8034 event.stopPropagation();8060 event.stopPropagation();
...@@ -8211,8 +8237,28 @@ export default function ChatView(props: ChatViewProps) {...@@ -8211,8 +8237,28 @@ export default function ChatView(props: ChatViewProps) {
8211 event.stopPropagation();8237 event.stopPropagation();
8212 void runProjectScript(script);8238 void runProjectScript(script);
8213 };8239 };
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 };
8214 window.addEventListener("keydown", handler, true);8256 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 };
8216 }, [8262 }, [
8217 activeProject,8263 activeProject,
8218 activeRightPanelSurface,8264 activeRightPanelSurface,
...@@ -8239,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8239,6 +8285,7 @@ export default function ChatView(props: ChatViewProps) {
8239 scriptKeybindings,8285 scriptKeybindings,
8240 handleUnsettleActiveThread,8286 handleUnsettleActiveThread,
8241 isServerThread,8287 isServerThread,
8288 serverConfig?.threadFind,
8242 onInterrupt,8289 onInterrupt,
8243 onToggleDiff,8290 onToggleDiff,
8244 pinThread,8291 pinThread,
...@@ -8248,6 +8295,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -8248,6 +8295,9 @@ export default function ChatView(props: ChatViewProps) {
8248 confirmAndUnpinThread,8295 confirmAndUnpinThread,
8249 copyActiveThreadReference,8296 copyActiveThreadReference,
8250 getShortcutContext,8297 getShortcutContext,
8298 openThreadFind,
8299 closeThreadFind,
8300 isThreadFindActive,
8251 toggleRightPanel,8301 toggleRightPanel,
8252 toggleThreadPanel,8302 toggleThreadPanel,
8253 toggleTerminalVisibility,8303 toggleTerminalVisibility,
...@@ -11237,7 +11287,18 @@ export default function ChatView(props: ChatViewProps) {...@@ -11237,7 +11287,18 @@ export default function ChatView(props: ChatViewProps) {
11237 {/* Main content area with optional plan sidebar */}11287 {/* Main content area with optional plan sidebar */}
11238 <div className="relative flex min-h-0 min-w-0 flex-1">11288 <div className="relative flex min-h-0 min-w-0 flex-1">
11239 {/* Chat column */}11289 {/* Chat column */}
11240 <ChatCanvas11290 <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}
11241 composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}11302 composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
11242 data-chat-workspace-drop-target="true"11303 data-chat-workspace-drop-target="true"
11243 onDragEnter={workspaceFileDropHandlers.onDragEnter}11304 onDragEnter={workspaceFileDropHandlers.onDragEnter}
...@@ -11245,6 +11306,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11245,6 +11306,7 @@ export default function ChatView(props: ChatViewProps) {
11245 onDragLeave={workspaceFileDropHandlers.onDragLeave}11306 onDragLeave={workspaceFileDropHandlers.onDragLeave}
11246 onDrop={workspaceFileDropHandlers.onDrop}11307 onDrop={workspaceFileDropHandlers.onDrop}
11247 >11308 >
11309 <ThreadFind onClose={focusComposer} />
11248 {isWorkspaceFileDragActive ? (11310 {isWorkspaceFileDragActive ? (
11249 <div11311 <div
11250 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]"11312 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) {...@@ -11367,11 +11429,7 @@ export default function ChatView(props: ChatViewProps) {
11367 ? (heldPaintContext?.workspaceRoot ?? undefined)11429 ? (heldPaintContext?.workspaceRoot ?? undefined)
11368 : activeWorkspaceRoot11430 : activeWorkspaceRoot
11369 }11431 }
11370 skills={11432 skills={timelineSkills}
11371 activeProviderStatus
11372 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
11373 : EMPTY_PROVIDER_SKILLS
11374 }
11375 anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}11433 anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}
11376 onAnchorReady={onTimelineAnchorReady}11434 onAnchorReady={onTimelineAnchorReady}
11377 onAnchorSizeChanged={onTimelineAnchorSizeChanged}11435 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
...@@ -11390,7 +11448,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11390,7 +11448,7 @@ export default function ChatView(props: ChatViewProps) {
11390 />11448 />
1139111449
11392 {/* scroll to end pill — shown when user has scrolled away from the live edge */}11450 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
11393 {showScrollToBottom && (11451 {showScrollToBottom && !isThreadFindActive && (
11394 <div11452 <div
11395 className="chat-scroll-to-bottom pointer-events-none absolute z-30 flex justify-center py-1.5"11453 className="chat-scroll-to-bottom pointer-events-none absolute z-30 flex justify-center py-1.5"
11396 style={{ bottom: scrollToEndClearance + 4 }}11454 style={{ bottom: scrollToEndClearance + 4 }}
...@@ -11809,7 +11867,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -11809,7 +11867,7 @@ export default function ChatView(props: ChatViewProps) {
11809 onPrepared={handlePreparedPullRequestThread}11867 onPrepared={handlePreparedPullRequestThread}
11810 />11868 />
11811 ) : null}11869 ) : null}
11812 </ChatCanvas>11870 </ThreadFindCanvas>
11813 {/* end chat column */}11871 {/* end chat column */}
11814 </div>11872 </div>
11815 {/* end horizontal flex container */}11873 {/* end horizontal flex container */}
apps/web/src/components/CommandPalette.tsx+27
...@@ -68,6 +68,7 @@ import {...@@ -68,6 +68,7 @@ import {
68 SunIcon,68 SunIcon,
69 TextSearchIcon,69 TextSearchIcon,
70} from "lucide-react";70} from "lucide-react";
71import { requestThreadFindOpen } from "./chat/threadFindActionBus";
71import {72import {
72 useCallback,73 useCallback,
73 useDeferredValue,74 useDeferredValue,
...@@ -1139,6 +1140,9 @@ function OpenCommandPaletteDialog(props: {...@@ -1139,6 +1140,9 @@ function OpenCommandPaletteDialog(props: {
1139 );1140 );
11401141
1141 const activeThreadId = activeThread?.id;1142 const activeThreadId = activeThread?.id;
1143 const supportsThreadFind =
1144 environments.find((environment) => environment.environmentId === activeThread?.environmentId)
1145 ?.serverConfig?.threadFind === true;
1142 const currentProjectEnvironmentId =1146 const currentProjectEnvironmentId =
1143 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;1147 activeThread?.environmentId ?? activeDraftThread?.environmentId ?? null;
1144 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;1148 const currentProjectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? null;
...@@ -1929,6 +1933,20 @@ function OpenCommandPaletteDialog(props: {...@@ -1929,6 +1933,20 @@ function OpenCommandPaletteDialog(props: {
1929 });1933 });
1930 }1934 }
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
1932 if (activeThreadReferenceCopyTarget !== null) {1950 if (activeThreadReferenceCopyTarget !== null) {
1933 actionItems.push({1951 actionItems.push({
1934 kind: "action",1952 kind: "action",
...@@ -3093,6 +3111,15 @@ function OpenCommandPaletteDialog(props: {...@@ -3093,6 +3111,15 @@ function OpenCommandPaletteDialog(props: {
3093 void copyActiveThreadReference();3111 void copyActiveThreadReference();
3094 return;3112 return;
3095 }3113 }
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
3097 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {3124 if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
3098 event.preventDefault();3125 event.preventDefault();
apps/web/src/components/ComposerPromptEditorTiptap.tsx+1-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
1import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";2import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core";
2import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";3import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
3import StarterKit from "@tiptap/starter-kit";4import StarterKit from "@tiptap/starter-kit";
...@@ -102,7 +103,6 @@ import {...@@ -102,7 +103,6 @@ import {
102 ComposerContextRecordsContext,103 ComposerContextRecordsContext,
103} from "./composerContextPresentation";104} from "./composerContextPresentation";
104import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";105import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
105import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";106import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
107import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";107import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView";
108import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";108import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight";
apps/web/src/components/GitActionsControl.tsx+1-1
...@@ -121,7 +121,7 @@ import { threadEnvironment } from "~/state/threads";...@@ -121,7 +121,7 @@ import { threadEnvironment } from "~/state/threads";
121import { useAtomCommand } from "~/state/use-atom-command";121import { useAtomCommand } from "~/state/use-atom-command";
122import { vcsActionManager, vcsEnvironment } from "~/state/vcs";122import { vcsActionManager, vcsEnvironment } from "~/state/vcs";
123import { randomUUID } from "~/lib/utils";123import { randomUUID } from "~/lib/utils";
124import { resolvePathLinkTarget } from "~/terminal-links";124import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
125import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";125import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
126import {126import {
127 THREAD_DETAILS_PANEL_CHEVRON_CLASS,127 THREAD_DETAILS_PANEL_CHEVRON_CLASS,
apps/web/src/components/ThreadTerminalDrawer.tsx+2-1
...@@ -60,7 +60,8 @@ import {...@@ -60,7 +60,8 @@ import {
60} from "~/terminal/ghostty/surface";60} from "~/terminal/ghostty/surface";
61import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";61import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
62import { useOpenInPreferredEditor } from "../editorPreferences";62import { useOpenInPreferredEditor } from "../editorPreferences";
63import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links";63import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
64import { isTerminalUrl } from "../terminal-links";
64import {65import {
65 isDiffToggleShortcut,66 isDiffToggleShortcut,
66 isTerminalClearShortcut,67 isTerminalClearShortcut,
apps/web/src/components/chat/ChatCanvas.tsx+12-1
...@@ -17,10 +17,12 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay...@@ -17,10 +17,12 @@ import type { PreviewMiniPlayerObstacles } from "../preview/previewMiniPlayerLay
17 */17 */
18export function ChatCanvas({18export function ChatCanvas({
19 composerOverlayElement,19 composerOverlayElement,
20 detailsCardTopInset = 0,
20 children,21 children,
21 ...props22 ...props
22}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {23}: Omit<ComponentProps<"div">, "className" | "style" | "ref"> & {
23 composerOverlayElement: HTMLElement | null;24 composerOverlayElement: HTMLElement | null;
25 detailsCardTopInset?: number;
24}) {26}) {
25 const elementRef = useRef<HTMLDivElement | null>(null);27 const elementRef = useRef<HTMLDivElement | null>(null);
26 const widthProbeRef = useRef<HTMLDivElement | null>(null);28 const widthProbeRef = useRef<HTMLDivElement | null>(null);
...@@ -106,8 +108,17 @@ export function ChatCanvas({...@@ -106,8 +108,17 @@ export function ChatCanvas({
106 clearPreview,108 clearPreview,
107 registerTimeline,109 registerTimeline,
108 reportDetailsCard,110 reportDetailsCard,
111 detailsCardTopInset,
109 };112 };
110 }, [measurements, preview, detailsCard, reportPreview, clearPreview, reportDetailsCard]);113 }, [
114 measurements,
115 preview,
116 detailsCard,
117 reportPreview,
118 clearPreview,
119 reportDetailsCard,
120 detailsCardTopInset,
121 ]);
111 const { layout } = context;122 const { layout } = context;
112 return (123 return (
113 <ChatCanvasContext value={context}>124 <ChatCanvasContext value={context}>
apps/web/src/components/chat/ChatCanvasContext.ts+2
...@@ -11,6 +11,8 @@ export const ChatCanvasContext = createContext<{...@@ -11,6 +11,8 @@ export const ChatCanvasContext = createContext<{
11 clearPreview: (key: string) => void;11 clearPreview: (key: string) => void;
12 registerTimeline: (element: HTMLElement | null) => void;12 registerTimeline: (element: HTMLElement | null) => void;
13 reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void;13 reportDetailsCard: (card: PreviewMiniPlayerObstacles["detailsCard"]) => void;
14 /** Height reserved above the details card, such as the open find bar. */
15 detailsCardTopInset: number;
14} | null>(null);16} | null>(null);
1517
16export const useChatCanvas = () => useContext(ChatCanvasContext);18export const useChatCanvas = () => useContext(ChatCanvasContext);
apps/web/src/components/chat/ChatComposer.tsx+2-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
1import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";2import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText";
2import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";3import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig";
3import { isLocalEnvironmentDisabled } from "../../localEnvironment";4import { isLocalEnvironmentDisabled } from "../../localEnvironment";
...@@ -1088,7 +1089,7 @@ import {...@@ -1088,7 +1089,7 @@ import {
1088 ShieldIcon,1089 ShieldIcon,
1089 XIcon,1090 XIcon,
1090} from "lucide-react";1091} from "lucide-react";
1091import { proposedPlanTitle } from "../../proposedPlan";1092import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
1092import { hasProviderSetup } from "./ProviderStatusBanner";1093import { hasProviderSetup } from "./ProviderStatusBanner";
1093import {1094import {
1094 applyProviderInstanceSettings,1095 applyProviderInstanceSettings,
...@@ -1124,7 +1125,6 @@ import {...@@ -1124,7 +1125,6 @@ import {
1124} from "@t3tools/client-runtime/state/composer-dispatch";1125} from "@t3tools/client-runtime/state/composer-dispatch";
1125import type { ContextWindowSnapshot } from "../../lib/contextWindow";1126import type { ContextWindowSnapshot } from "../../lib/contextWindow";
1126import {1127import {
1127 formatProviderSkillDisplayName,
1128 getProviderSlashCommandsForSlashMenu,1128 getProviderSlashCommandsForSlashMenu,
1129 getProviderSkillsForSlashMenu,1129 getProviderSkillsForSlashMenu,
1130 hasCompleteProviderWorkspaceSnapshot,1130 hasCompleteProviderWorkspaceSnapshot,
apps/web/src/components/chat/ComposerCommandMenu.tsx+1-1
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
1import {2import {
2 formatProviderSkillDisplayName,
3 resolveProviderSkillSourceKind,3 resolveProviderSkillSourceKind,
4 type ProviderSkillSourceKind,4 type ProviderSkillSourceKind,
5} from "@t3tools/client-runtime/providerSkills";5} from "@t3tools/client-runtime/providerSkills";
apps/web/src/components/chat/MermaidDiagram.tsx+5-1
...@@ -192,7 +192,11 @@ export function MermaidDiagram({...@@ -192,7 +192,11 @@ export function MermaidDiagram({
192 </Button>192 </Button>
193 ) : null}193 ) : null}
194 </div>194 </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 >
196 {source}200 {source}
197 </pre>201 </pre>
198 </div>202 </div>
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+29-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
1import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";2import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";
2import {3import {
3 CheckpointRef,4 CheckpointRef,
...@@ -27,6 +28,7 @@ import {...@@ -27,6 +28,7 @@ import {
27 computeStableMessagesTimelineRows,28 computeStableMessagesTimelineRows,
28 computeMessageDurationStart,29 computeMessageDurationStart,
29 deriveMessagesTimelineRows,30 deriveMessagesTimelineRows,
31 timelineEntryTurnFoldRunId,
30 deriveMessagesTimelineRowsWithState,32 deriveMessagesTimelineRowsWithState,
31 shouldCollapseUserMessage,33 shouldCollapseUserMessage,
32 liveWorkEntryLabel,34 liveWorkEntryLabel,
...@@ -34,7 +36,6 @@ import {...@@ -34,7 +36,6 @@ import {
34 resolveAssistantMessageCopyState,36 resolveAssistantMessageCopyState,
35 resolveWorkGroupScrollIndex,37 resolveWorkGroupScrollIndex,
36 shouldFollowWorkGroupAppend,38 shouldFollowWorkGroupAppend,
37 shouldPreserveAssistantLineBreaks,
38 threadReadLabelPrefix,39 threadReadLabelPrefix,
39 threadReadTargetId,40 threadReadTargetId,
40 threadReadTargetTitle,41 threadReadTargetTitle,
...@@ -2404,6 +2405,33 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2404,6 +2405,33 @@ describe("deriveMessagesTimelineRows", () => {
2404 const withoutPrompt = rows([]);2405 const withoutPrompt = rows([]);
2405 expect(withoutPrompt).toContain("turn-fold");2406 expect(withoutPrompt).toContain("turn-fold");
2406 expect(withoutPrompt).not.toContain("assistant:imported-update");2407 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);
2407 });2435 });
24082436
2409 it("shows a provider-native subagent's runless tools as live work while it works", () => {2437 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 {...@@ -7,7 +7,7 @@ import {
7 collectAssistantCitations,7 collectAssistantCitations,
8} from "@t3tools/shared/assistantCitations";8} from "@t3tools/shared/assistantCitations";
9import { shallow } from "zustand/vanilla/shallow";9import { shallow } from "zustand/vanilla/shallow";
10import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";10import { renderCodexDirectivesForCopy } from "@t3tools/shared/codexMarkdownDirectives";
11import {11import {
12 commandDisplayText,12 commandDisplayText,
13 commandProgramName,13 commandProgramName,
...@@ -351,10 +351,6 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo...@@ -351,10 +351,6 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo
351 return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;351 return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
352}352}
353353
354export function shouldPreserveAssistantLineBreaks(text: string): boolean {
355 return /^★ Insight(?:\s|─)/mu.test(text);
356}
357
358export function resolveTimelineMinimapHeightStyle(itemCount: number): string {354export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
359 const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING);355 const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING);
360 return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`;356 return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`;
...@@ -1298,6 +1294,35 @@ function settleSupersededReasoning(entries: ReadonlyArray<TimelineEntry>) {...@@ -1298,6 +1294,35 @@ function settleSupersededReasoning(entries: ReadonlyArray<TimelineEntry>) {
1298 });1294 });
1299}1295}
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
1301export function deriveMessagesTimelineRows(input: {1326export function deriveMessagesTimelineRows(input: {
1302 timelineEntries: ReadonlyArray<TimelineEntry>;1327 timelineEntries: ReadonlyArray<TimelineEntry>;
1303 latestRun?: TimelineLatestRun | null;1328 latestRun?: TimelineLatestRun | null;
...@@ -1319,9 +1344,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -1319,9 +1344,7 @@ export function deriveMessagesTimelineRows(input: {
1319 /** Live bootstrap progress. Renders a stage card under the first user message. */1344 /** Live bootstrap progress. Renders a stage card under the first user message. */
1320 worktreeSetup?: WorktreeSetupSnapshot | null;1345 worktreeSetup?: WorktreeSetupSnapshot | null;
1321}): MessagesTimelineRow[] {1346}): MessagesTimelineRow[] {
1322 const timelineEntries = withoutSubagentDelegationRows(1347 const timelineEntries = timelineRowEntries(input.timelineEntries);
1323 settleSupersededReasoning(input.timelineEntries),
1324 );
1325 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();1348 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>();
1326 for (const summary of input.turnDiffSummaries) {1349 for (const summary of input.turnDiffSummaries) {
1327 if (summary.assistantMessageId) {1350 if (summary.assistantMessageId) {
...@@ -1989,6 +2012,35 @@ export interface MessagesTimelineRowsProjection {...@@ -1989,6 +2012,35 @@ export interface MessagesTimelineRowsProjection {
1989 readonly rows: MessagesTimelineRow[];2012 readonly rows: MessagesTimelineRow[];
1990}2013}
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
1992function sameCheckpointSummaries(2044function sameCheckpointSummaries(
1993 previous: MessagesTimelineRowsInput["turnDiffSummaries"],2045 previous: MessagesTimelineRowsInput["turnDiffSummaries"],
1994 next: MessagesTimelineRowsInput["turnDiffSummaries"],2046 next: MessagesTimelineRowsInput["turnDiffSummaries"],
apps/web/src/components/chat/MessagesTimeline.tsx+315-123
...@@ -1,3 +1,6 @@...@@ -1,3 +1,6 @@
1import { ThreadFindTimelineContext } from "./ThreadFindProvider";
2import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
3import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext";
1import { ComputerUseAppIcon } from "~/components/Icons";4import { ComputerUseAppIcon } from "~/components/Icons";
2import { useChatCanvas } from "./ChatCanvasContext";5import { useChatCanvas } from "./ChatCanvasContext";
3import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";6import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog";
...@@ -70,6 +73,7 @@ import {...@@ -70,6 +73,7 @@ import {
70 memo,73 memo,
71 use,74 use,
72 useCallback,75 useCallback,
76 useContext,
73 useEffect,77 useEffect,
74 useLayoutEffect,78 useLayoutEffect,
75 useMemo,79 useMemo,
...@@ -95,7 +99,7 @@ import {...@@ -95,7 +99,7 @@ import {
95 workEntrySignalsSevereFailure,99 workEntrySignalsSevereFailure,
96 workLogEntryIsToolLike,100 workLogEntryIsToolLike,
97} from "../../session-logic";101} from "../../session-logic";
98import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";102import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
99import {103import {
100 type ChatMessage,104 type ChatMessage,
101 type ChatFileAttachment,105 type ChatFileAttachment,
...@@ -209,7 +213,7 @@ import {...@@ -209,7 +213,7 @@ import {
209 resolveWorkGroupScrollIndex,213 resolveWorkGroupScrollIndex,
210 shouldCollapseUserMessage,214 shouldCollapseUserMessage,
211 shouldFollowWorkGroupAppend,215 shouldFollowWorkGroupAppend,
212 shouldPreserveAssistantLineBreaks,216 timelineTurnFoldRunIdsByEntryId,
213 threadReadLabelPrefix,217 threadReadLabelPrefix,
214 threadReadTargetId,218 threadReadTargetId,
215 threadReadTargetTitle,219 threadReadTargetTitle,
...@@ -224,6 +228,10 @@ import {...@@ -224,6 +228,10 @@ import {
224 type TimelineLatestRun,228 type TimelineLatestRun,
225 type WorkGroupScrollAnchor,229 type WorkGroupScrollAnchor,
226} from "./MessagesTimeline.logic";230} from "./MessagesTimeline.logic";
231import { type ThreadFindMatch } from "./threadFind";
232import { useThreadFindNavigation } from "./useThreadFindNavigation";
233import { readThreadFindPosition } from "./threadFindPosition";
234import type { ThreadFindPositionReader } from "./threadFind";
227import { TerminalContextInlineChip } from "./TerminalContextInlineChip";235import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
228import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";236import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
229import { Spinner } from "../ui/spinner";237import { Spinner } from "../ui/spinner";
...@@ -327,6 +335,7 @@ interface TimelineRowSharedState {...@@ -327,6 +335,7 @@ interface TimelineRowSharedState {
327 */335 */
328 onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void;336 onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void;
329 onRunShellCommand: ((command: string) => void) | undefined;337 onRunShellCommand: ((command: string) => void) | undefined;
338 findActive: boolean;
330 onImageExpand: (preview: ExpandedImagePreview) => void;339 onImageExpand: (preview: ExpandedImagePreview) => void;
331 displayThreadKey?: string;340 displayThreadKey?: string;
332 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;341 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
...@@ -402,6 +411,7 @@ function TimelineListFooter({...@@ -402,6 +411,7 @@ function TimelineListFooter({
402 </div>411 </div>
403 );412 );
404}413}
414
405const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];415const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
406const TIMELINE_MAINTAIN_SCROLL_AT_END = {416const TIMELINE_MAINTAIN_SCROLL_AT_END = {
407 animated: false,417 animated: false,
...@@ -433,7 +443,7 @@ export interface MessagesTimelineHistoryControls {...@@ -433,7 +443,7 @@ export interface MessagesTimelineHistoryControls {
433 readonly hasMoreHistory: boolean;443 readonly hasMoreHistory: boolean;
434 readonly loading: boolean;444 readonly loading: boolean;
435 readonly error: string | null;445 readonly error: string | null;
436 readonly onLoadEarlier: () => void;446 readonly onLoadEarlier: (throughEntryId?: string) => void;
437}447}
438448
439interface MessagesTimelineProps {449interface MessagesTimelineProps {
...@@ -519,19 +529,41 @@ interface MessagesTimelineProps {...@@ -519,19 +529,41 @@ interface MessagesTimelineProps {
519 onContentOverflowChange?: (overflows: boolean) => void;529 onContentOverflowChange?: (overflows: boolean) => void;
520 onToolOutputCollapsedAtEnd?: () => void;530 onToolOutputCollapsedAtEnd?: () => void;
521 onManualNavigation: () => void;531 onManualNavigation: () => void;
522 cancelPositionRestoreRef?: React.RefObject<(() => void) | null>;532 findOpen?: boolean;
533 cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined;
523 hideEmptyPlaceholder?: boolean;534 hideEmptyPlaceholder?: boolean;
524 topFadeEnabled?: boolean;535 topFadeEnabled?: boolean;
525 historyControls?: MessagesTimelineHistoryControls;536 historyControls?: MessagesTimelineHistoryControls | undefined;
526 /** Non-null when older turns exist beyond the loaded window. */537 /** Non-null when older turns exist beyond the loaded window. */
527 loadEarlier?: CitationHistoryPage | null;538 loadEarlier?: CitationHistoryPage | null;
539 findPositionReaderRef?: React.RefObject<ThreadFindPositionReader | null>;
540 findExpanded?: boolean;
541 findQuery?: string;
542 activeFindMatch?: ThreadFindMatch | null;
543 findNavigationId?: number;
528}544}
529545
530// ---------------------------------------------------------------------------546// ---------------------------------------------------------------------------
531// MessagesTimeline — list owner547// MessagesTimeline — list owner
532// ---------------------------------------------------------------------------548// ---------------------------------------------------------------------------
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({
535 citationRequest = null,567 citationRequest = null,
536 citationHistoryLoading = false,568 citationHistoryLoading = false,
537 onCiteAssistantText,569 onCiteAssistantText,
...@@ -551,6 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -551,6 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
551 awaitingUser = false,583 awaitingUser = false,
552 listRef,584 listRef,
553 timelineEntries,585 timelineEntries,
586 findExpanded = false,
587 findQuery = "",
588 activeFindMatch = null,
589 findNavigationId = 0,
590 findPositionReaderRef,
554 latestRun,591 latestRun,
555 runningRunId = null,592 runningRunId = null,
556 turnDiffSummaries,593 turnDiffSummaries,
...@@ -651,6 +688,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -651,6 +688,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
651 const disclosureAnchorKeyRef = useRef<string | null>(null);688 const disclosureAnchorKeyRef = useRef<string | null>(null);
652 const disclosureSettleFrameRef = useRef<number | null>(null);689 const disclosureSettleFrameRef = useRef<number | null>(null);
653 const disclosureSettleSecondFrameRef = useRef<number | null>(null);690 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
691 const normalizedFindQuery = findQuery.trim();
692 const findActive = findExpanded;
693
654 useEffect(() => {694 useEffect(() => {
655 return () => {695 return () => {
656 if (disclosureSettleFrameRef.current !== null) {696 if (disclosureSettleFrameRef.current !== null) {
...@@ -784,6 +824,54 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -784,6 +824,54 @@ export const MessagesTimeline = memo(function MessagesTimeline({
784 });824 });
785 }, [latestRun]);825 }, [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
787 const rowsProjectionRef = useRef<{875 const rowsProjectionRef = useRef<{
788 readonly threadKey: string;876 readonly threadKey: string;
789 readonly workspaceRoot: string | undefined;877 readonly workspaceRoot: string | undefined;
...@@ -796,8 +884,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -796,8 +884,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
796 timelineEntries,884 timelineEntries,
797 latestRun,885 latestRun,
798 runningRunId,886 runningRunId,
799 expandedRunIds,887 expandedRunIds: visibleExpandedRunIds,
800 expandedAttemptIds,888 expandedAttemptIds: visibleExpandedAttemptIds,
801 expandedWorkGroupIds,889 expandedWorkGroupIds,
802 isWorking,890 isWorking,
803 runlessWorkActive,891 runlessWorkActive,
...@@ -819,8 +907,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -819,8 +907,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
819 timelineEntries,907 timelineEntries,
820 latestRun,908 latestRun,
821 runningRunId,909 runningRunId,
822 expandedRunIds,910 visibleExpandedRunIds,
823 expandedAttemptIds,911 visibleExpandedAttemptIds,
824 expandedWorkGroupIds,912 expandedWorkGroupIds,
825 isWorking,913 isWorking,
826 runlessWorkActive,914 runlessWorkActive,
...@@ -979,6 +1067,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -979,6 +1067,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
979 onExpandTurn: expandCitedRun,1067 onExpandTurn: expandCitedRun,
980 onManualNavigation,1068 onManualNavigation,
981 });1069 });
1070 const [findListReady, setFindListReady] = useState(false);
1071 const handleListLoad = useCallback(() => {
1072 onCitationListLoad();
1073 setFindListReady(true);
1074 }, [onCitationListLoad]);
982 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);1075 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
983 const [fullscreenAppRowId, setFullscreenAppRowId] = useState<string | null>(null);1076 const [fullscreenAppRowId, setFullscreenAppRowId] = useState<string | null>(null);
984 // Only the row that holds the pin can release it.1077 // Only the row that holds the pin can release it.
...@@ -1220,6 +1313,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1220,6 +1313,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1220 activeThreadEnvironmentId,1313 activeThreadEnvironmentId,
1221 onRevertToTurnCount,1314 onRevertToTurnCount,
1222 onRunShellCommand,1315 onRunShellCommand,
1316 findActive,
1223 onImageExpand,1317 onImageExpand,
1224 onFileOpen,1318 onFileOpen,
1225 onUseArtifactTemplate,1319 onUseArtifactTemplate,
...@@ -1257,6 +1351,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1257,6 +1351,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1257 activeThreadEnvironmentId,1351 activeThreadEnvironmentId,
1258 onRevertToTurnCount,1352 onRevertToTurnCount,
1259 onRunShellCommand,1353 onRunShellCommand,
1354 findActive,
1260 onImageExpand,1355 onImageExpand,
1261 onFileOpen,1356 onFileOpen,
1262 onUseArtifactTemplate,1357 onUseArtifactTemplate,
...@@ -1351,6 +1446,86 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1351,6 +1446,86 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1351 [listRef, registerTimeline],1446 [listRef, registerTimeline],
1352 );1447 );
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
1354 // Stable renderItem — no closure deps. Row components read shared state1529 // Stable renderItem — no closure deps. Row components read shared state
1355 // from TimelineRowCtx, which propagates through LegendList's memo.1530 // from TimelineRowCtx, which propagates through LegendList's memo.
1356 const renderItem = useCallback(1531 const renderItem = useCallback(
...@@ -1387,81 +1562,86 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1387,81 +1562,86 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1387 }1562 }
13881563
1389 return (1564 return (
1390 <TimelineRowCtx value={sharedState}>1565 <MarkdownFindContext value={findActive}>
1391 <TimelineRowActivityCtx value={activityState}>1566 <TimelineRowCtx value={sharedState}>
1392 <TooltipScrollDismissArea1567 <TimelineRowActivityCtx value={activityState}>
1393 ref={setTimelineViewportElement}1568 <TooltipScrollDismissArea
1394 className="relative h-full min-h-0"1569 ref={setTimelineViewportElement}
1395 data-assistant-citation-viewport="true"1570 className="relative h-full min-h-0"
1396 >1571 data-assistant-citation-viewport="true"
1397 {onCiteAssistantText && citationThreadRef ? (1572 >
1398 <AssistantSelectionToolbar1573 {onCiteAssistantText && citationThreadRef ? (
1399 viewport={timelineViewportElement}1574 <AssistantSelectionToolbar
1400 threadRef={citationThreadRef}1575 viewport={timelineViewportElement}
1401 onCite={onCiteAssistantText}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}
1402 />1625 />
1403 ) : null}1626 <TimelineMinimap
1404 <LegendList<MessagesTimelineRow>1627 items={minimapItems}
1405 ref={setTimelineList}1628 hasPersistentGutter={minimapHasPersistentGutter}
1406 data={rows}1629 hitStripWidth={minimapHitStripWidth}
1407 extraData={`${listIdentityKey}:${rows.length}`}1630 currentIndex={minimapCurrentIndex}
1408 keyExtractor={keyExtractor}1631 stripMap={minimapStripMap}
1409 getItemType={getItemType}1632 onSelect={(item) => {
1410 renderItem={renderItem}1633 onManualNavigation();
1411 estimatedItemSize={90}1634 void listRef.current?.scrollToIndex({
1412 initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false}1635 index: item.rowIndex,
1413 // Legend needs a data refresh to mount new pins without a scroll event.1636 animated: true,
1414 dataVersion={readyCitationRequest?.key ?? listIdentityKey}1637 viewOffset: 24,
1415 {...(alwaysRender ? { alwaysRender } : {})}1638 });
1416 onLoad={onCitationListLoad}1639 }}
1417 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}1640 />
1418 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}1641 </TooltipScrollDismissArea>
1419 maintainScrollAtEnd={1642 </TimelineRowActivityCtx>
1420 citationPositioning ||1643 </TimelineRowCtx>
1421 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||1644 </MarkdownFindContext>
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>
1465 );1645 );
1466});1646});
14671647
...@@ -1477,7 +1657,7 @@ function TimelineHistoryControl(props: MessagesTimelineHistoryControls) {...@@ -1477,7 +1657,7 @@ function TimelineHistoryControl(props: MessagesTimelineHistoryControls) {
1477 type="button"1657 type="button"
1478 disabled={props.loading}1658 disabled={props.loading}
1479 aria-label="Load earlier turns"1659 aria-label="Load earlier turns"
1480 onClick={props.onLoadEarlier}1660 onClick={() => props.onLoadEarlier()}
1481 className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default"1661 className="w-full py-1.5 text-xs text-muted-foreground/60 hover:text-foreground disabled:cursor-default"
1482 >1662 >
1483 {props.loading ? "Loading earlier turns…" : "Load earlier turns"}1663 {props.loading ? "Loading earlier turns…" : "Load earlier turns"}
...@@ -2144,21 +2324,23 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -2144,21 +2324,23 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
2144 ? (userFiles.find((file) => file.id === record.attachmentId) ?? null)2324 ? (userFiles.find((file) => file.id === record.attachmentId) ?? null)
2145 : null;2325 : null;
2146 return (2326 return (
2147 <UserMessageContextReferenceChip2327 <span data-thread-find-ignore>
2148 reference={reference}2328 <UserMessageContextReferenceChip
2149 record={record}2329 reference={reference}
2150 annotationImage={annotationImage}2330 record={record}
2151 attachment={attachment}2331 annotationImage={annotationImage}
2152 onExpandImage={(image) => {2332 attachment={attachment}
2153 const preview = buildExpandedImagePreview(userImages, image.id);2333 onExpandImage={(image) => {
2154 if (preview) onImageExpand(preview);2334 const preview = buildExpandedImagePreview(userImages, image.id);
2155 }}2335 if (preview) onImageExpand(preview);
2156 onOpenFile={onFileOpen}2336 }}
2157 onExpandVideo={(file) => {2337 onOpenFile={onFileOpen}
2158 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);2338 onExpandVideo={(file) => {
2159 if (preview) onImageExpand(preview);2339 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
2160 }}2340 if (preview) onImageExpand(preview);
2161 />2341 }}
2342 />
2343 </span>
2162 );2344 );
2163 },2345 },
2164 [2346 [
...@@ -2568,26 +2750,28 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -2568,26 +2750,28 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
2568 <>2750 <>
2569 <div className="relative min-w-0 px-1 py-0.5">2751 <div className="relative min-w-0 px-1 py-0.5">
2570 <MessageAuthorHeading>T3 Code</MessageAuthorHeading>2752 <MessageAuthorHeading>T3 Code</MessageAuthorHeading>
2571 <AssistantCitationSource2753 <div data-thread-find-text="true">
2572 messageId={row.message.id}2754 <AssistantCitationSource
2573 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}2755 messageId={row.message.id}
2574 itemKey={row.id}2756 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
2575 request={ctx.citationRequest}2757 itemKey={row.id}
2576 listRef={ctx.listRef}2758 request={ctx.citationRequest}
2577 >2759 listRef={ctx.listRef}
2578 <ChatMarkdown2760 >
2579 text={messageText}2761 <ChatMarkdown
2580 cwd={ctx.markdownCwd}2762 text={messageText}
2581 threadRef={ctx.threadRef ?? undefined}2763 cwd={ctx.markdownCwd}
2582 isStreaming={Boolean(row.message.streaming)}2764 threadRef={ctx.threadRef ?? undefined}
2583 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}2765 isStreaming={Boolean(row.message.streaming)}
2584 skills={ctx.skills}2766 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
2585 headingLevelOffset={MESSAGE_HEADING_LEVEL}2767 skills={ctx.skills}
2586 onUseArtifactTemplate={ctx.onUseArtifactTemplate}2768 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2587 onRunShellCommand={ctx.onRunShellCommand}2769 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2588 onImageExpand={ctx.onImageExpand}2770 onRunShellCommand={ctx.onRunShellCommand}
2589 />2771 onImageExpand={ctx.onImageExpand}
2590 </AssistantCitationSource>2772 />
2773 </AssistantCitationSource>
2774 </div>
2591 <AssistantChangedFilesSection2775 <AssistantChangedFilesSection
2592 turnSummary={row.assistantTurnDiffSummary}2776 turnSummary={row.assistantTurnDiffSummary}
2593 routeThreadKey={ctx.routeThreadKey}2777 routeThreadKey={ctx.routeThreadKey}
...@@ -2764,6 +2948,7 @@ function ProposedPlanTimelineRow({...@@ -2764,6 +2948,7 @@ function ProposedPlanTimelineRow({
2764 threadRef={ctx.threadRef ?? undefined}2948 threadRef={ctx.threadRef ?? undefined}
2765 cwd={ctx.markdownCwd}2949 cwd={ctx.markdownCwd}
2766 workspaceRoot={ctx.workspaceRoot}2950 workspaceRoot={ctx.workspaceRoot}
2951 findActive={ctx.findActive}
2767 />2952 />
2768 </div>2953 </div>
2769 );2954 );
...@@ -4534,16 +4719,23 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop...@@ -4534,16 +4719,23 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
4534 footer?: ReactNode;4719 footer?: ReactNode;
4535}) {4720}) {
4536 const [expanded, setExpanded] = useState(false);4721 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);
4537 const hasVisibleBody = props.text.trim().length > 0;4725 const hasVisibleBody = props.text.trim().length > 0;
4538 const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);4726 const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text);
4539 const isCollapsed = canCollapse && !expanded;4727 const isCollapsed = canCollapse && !expanded;
4728 const showCollapseControl = canCollapse;
45404729
4541 return (4730 return (
4542 <div>4731 <div>
4543 {hasVisibleBody ? (4732 {hasVisibleBody ? (
4544 <div4733 <div
4734 ref={findRevealRef}
4545 className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}4735 className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
4546 data-user-message-body="true"4736 data-user-message-body="true"
4737 data-thread-find-text="true"
4738 data-thread-find-fold={isCollapsed ? "" : undefined}
4547 data-user-message-collapsed={isCollapsed ? "true" : "false"}4739 data-user-message-collapsed={isCollapsed ? "true" : "false"}
4548 data-user-message-collapsible={canCollapse ? "true" : "false"}4740 data-user-message-collapsible={canCollapse ? "true" : "false"}
4549 data-user-message-fade={isCollapsed ? "true" : "false"}4741 data-user-message-fade={isCollapsed ? "true" : "false"}
...@@ -4564,15 +4756,15 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop...@@ -4564,15 +4756,15 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop
4564 />4756 />
4565 </div>4757 </div>
4566 ) : null}4758 ) : null}
4567 {canCollapse || props.footer ? (4759 {showCollapseControl || props.footer ? (
4568 <div4760 <div
4569 className={cn(4761 className={cn(
4570 "mt-1.5 flex items-center gap-2",4762 "mt-1.5 flex items-center gap-2",
4571 canCollapse && props.footer ? "justify-between" : "justify-end",4763 showCollapseControl && props.footer ? "justify-between" : "justify-end",
4572 )}4764 )}
4573 data-user-message-footer="true"4765 data-user-message-footer="true"
4574 >4766 >
4575 {canCollapse ? (4767 {showCollapseControl ? (
4576 <Button4768 <Button
4577 type="button"4769 type="button"
4578 size="xs"4770 size="xs"
apps/web/src/components/chat/ProposedPlanCard.tsx+22-7
...@@ -1,4 +1,6 @@...@@ -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";
2import {4import {
3 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
4 squashAtomCommandFailure,6 squashAtomCommandFailure,
...@@ -13,8 +15,6 @@ import {...@@ -13,8 +15,6 @@ import {
13 buildProposedPlanMarkdownFilename,15 buildProposedPlanMarkdownFilename,
14 downloadPlanAsTextFile,16 downloadPlanAsTextFile,
15 normalizePlanMarkdownForExport,17 normalizePlanMarkdownForExport,
16 proposedPlanTitle,
17 stripDisplayedPlanMarkdown,
18} from "../../proposedPlan";18} from "../../proposedPlan";
19import ChatMarkdown from "../ChatMarkdown";19import ChatMarkdown from "../ChatMarkdown";
20import { EllipsisIcon } from "lucide-react";20import { EllipsisIcon } from "lucide-react";
...@@ -44,12 +44,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -44,12 +44,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
44 threadRef,44 threadRef,
45 cwd,45 cwd,
46 workspaceRoot,46 workspaceRoot,
47 findActive = false,
47}: {48}: {
48 planMarkdown: string;49 planMarkdown: string;
49 environmentId: EnvironmentId;50 environmentId: EnvironmentId;
50 threadRef?: ScopedThreadRef | undefined;51 threadRef?: ScopedThreadRef | undefined;
51 cwd: string | undefined;52 cwd: string | undefined;
52 workspaceRoot: string | undefined;53 workspaceRoot: string | undefined;
54 findActive?: boolean;
53}) {55}) {
54 const [expanded, setExpanded] = useState(false);56 const [expanded, setExpanded] = useState(false);
55 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);57 const canWriteFiles = useEnvironmentScope(environmentId, AuthFilesystemWriteScope);
...@@ -79,6 +81,12 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -79,6 +81,12 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
79 const collapsedPreview = canCollapse81 const collapsedPreview = canCollapse
80 ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 })82 ? buildCollapsedProposedPlanPreviewMarkdown(planMarkdown, { maxLines: 10 })
81 : null;83 : 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);
82 const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);90 const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown);
83 const saveContents = normalizePlanMarkdownForExport(planMarkdown);91 const saveContents = normalizePlanMarkdownForExport(planMarkdown);
8492
...@@ -159,7 +167,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -159,7 +167,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
159 <Badge variant="secondary">Plan</Badge>167 <Badge variant="secondary">Plan</Badge>
160 {/* Same heading level as the message author headings in the timeline,168 {/* Same heading level as the message author headings in the timeline,
161 so a plan's own headings nest beneath it in the outline. */}169 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>
163 </div>173 </div>
164 <Menu>174 <Menu>
165 <MenuTrigger175 <MenuTrigger
...@@ -182,8 +192,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -182,8 +192,13 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
182 </Menu>192 </Menu>
183 </div>193 </div>
184 <div className="mt-4">194 <div className="mt-4">
185 <div className={cn("relative", canCollapse && !expanded && "max-h-104 overflow-hidden")}>195 <div
186 {canCollapse && !expanded ? (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 ? (
187 <ChatMarkdown202 <ChatMarkdown
188 text={collapsedPreview ?? ""}203 text={collapsedPreview ?? ""}
189 cwd={cwd}204 cwd={cwd}
...@@ -202,7 +217,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -202,7 +217,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
202 headingLevelOffset={3}217 headingLevelOffset={3}
203 />218 />
204 )}219 )}
205 {canCollapse && !expanded ? (220 {isCollapsed ? (
206 <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" />221 <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" />
207 ) : null}222 ) : null}
208 </div>223 </div>
apps/web/src/components/chat/SkillInlineText.tsx+5-19
...@@ -1,34 +1,20 @@...@@ -1,34 +1,20 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
1import { Children, cloneElement, isValidElement, type ReactNode } from "react";2import { Children, cloneElement, isValidElement, type ReactNode } from "react";
2import type { ServerProviderSkill } from "@t3tools/contracts";3import { matchInlineSkills, type InlineSkill } from "@t3tools/shared/inlineSkills";
3import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
44
5import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip";5import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip";
6import { ContextChip, ContextChipLabel } from "../ContextChip";6import { 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
13export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) {8export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) {
14 const nodes: ReactNode[] = [];9 const nodes: ReactNode[] = [];
15 let cursor = 0;10 let cursor = 0;
1611
17 for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) {12 for (const { start, end, skill, rawText } of matchInlineSkills(props.text, props.skills)) {
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
27 if (start > cursor) {13 if (start > cursor) {
28 nodes.push(props.text.slice(cursor, start));14 nodes.push(props.text.slice(cursor, start));
29 }15 }
30 nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />);16 nodes.push(<SkillChip key={`${start}:${skill.name}`} skill={skill} rawText={rawText} />);
31 cursor = (match.index ?? 0) + match[0].length;17 cursor = end;
32 }18 }
3319
34 if (cursor === 0) {20 if (cursor === 0) {
apps/web/src/components/chat/ThreadDetailsCard.tsx+2
...@@ -31,6 +31,7 @@ export function ThreadDetailsCard({...@@ -31,6 +31,7 @@ export function ThreadDetailsCard({
31 container: canvas.container,31 container: canvas.container,
32 lane: canvas.lane,32 lane: canvas.lane,
33 frame: null,33 frame: null,
34 topInset: canvas.detailsCardTopInset,
34 })35 })
35 : null;36 : null;
36 const placement = canvas37 const placement = canvas
...@@ -39,6 +40,7 @@ export function ThreadDetailsCard({...@@ -39,6 +40,7 @@ export function ThreadDetailsCard({
39 lane: canvas.lane,40 lane: canvas.lane,
40 frame: canvas.layout.frame,41 frame: canvas.layout.frame,
41 overlapsDetailsCard: canvas.layout.overlapsDetailsCard,42 overlapsDetailsCard: canvas.layout.overlapsDetailsCard,
43 topInset: canvas.detailsCardTopInset,
42 })44 })
43 : null;45 : null;
44 const mode = placement ? "inline" : "popover";46 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", () => {...@@ -22,6 +22,16 @@ describe("workspace card", () => {
22 });22 });
23 expect(resolve(1344, 900)).toMatchObject({ x: 1052, width: 280 });23 expect(resolve(1344, 900)).toMatchObject({ x: 1052, width: 280 });
24 });24 });
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 });
25 it("hides when a readable chat lane cannot fit beside it", () => {35 it("hides when a readable chat lane cannot fit beside it", () => {
26 expect(resolve(984, 900)).toMatchObject({ x: 692 });36 expect(resolve(984, 900)).toMatchObject({ x: 692 });
27 expect(resolve(983, 900)).toBeNull();37 expect(resolve(983, 900)).toBeNull();
apps/web/src/components/chat/threadDetailsCardLayout.ts+11-4
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";1import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout";
2import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout";2import { 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
4export function resolveThreadDetailsCardDensity(7export function resolveThreadDetailsCardDensity(
5 height: number,8 height: number,
6 content: { full: number; compact: number },9 content: { full: number; compact: number },
...@@ -19,28 +22,32 @@ export function resolveThreadDetailsCardLayout({...@@ -19,28 +22,32 @@ export function resolveThreadDetailsCardLayout({
19 lane,22 lane,
20 frame,23 frame,
21 overlapsDetailsCard = false,24 overlapsDetailsCard = false,
25 topInset = 0,
22}: {26}: {
23 container: { width: number; height: number };27 container: { width: number; height: number };
24 lane: { padding: number; minChatWidth: number };28 lane: { padding: number; minChatWidth: number };
25 frame: PreviewMiniPlayerFrame | null;29 frame: PreviewMiniPlayerFrame | null;
26 overlapsDetailsCard?: boolean;30 overlapsDetailsCard?: boolean;
31 /** Space taken above the card, such as the open find bar. */
32 topInset?: number;
27}) {33}) {
28 const gap = 12;34 const gap = THREAD_DETAILS_CARD_GAP;
29 // Keep in sync with --thread-details-panel-width, which sizes the popover.35 // Keep in sync with --thread-details-panel-width, which sizes the popover.
30 const width = 280;36 const width = 280;
31 const x = container.width - width - gap;37 const x = container.width - width - gap;
32 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;38 if (x - DETAILS_CARD_CLEARANCE - lane.padding < lane.minChatWidth) return null;
39 const y = gap + topInset;
33 // Resizing consumes the height above the player. Dragging first tries to40 // Resizing consumes the height above the player. Dragging first tries to
34 // clear the full card and folds it only when there is no readable placement.41 // clear the full card and folds it only when there is no readable placement.
35 const height =42 const height =
36 overlapsDetailsCard && frame && frame.x + frame.width > x - gap && frame.x < x + width + gap43 overlapsDetailsCard && frame && frame.x + frame.width > x - gap && frame.x < x + width + gap
37 ? Math.min(container.height - gap * 2, frame.y - gap * 2)44 ? Math.min(container.height - y - gap, frame.y - y - gap)
38 : container.height - gap * 2;45 : container.height - y - gap;
39 if (height < 160) return null;46 if (height < 160) return null;
40 return {47 return {
41 x,48 x,
42 width,49 width,
43 y: gap,50 y,
44 height,51 height,
45 } as const;52 } as const;
46}53}
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";...@@ -19,7 +19,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
19import { useTheme } from "~/hooks/useTheme";19import { useTheme } from "~/hooks/useTheme";
20import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";20import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh";
21import { cn } from "~/lib/utils";21import { cn } from "~/lib/utils";
22import { isAbsolutePath } from "~/terminal-links";22import { isAbsolutePath } from "@t3tools/shared/path";
2323
24import {24import {
25 type FileBreadcrumb,25 type FileBreadcrumb,
apps/web/src/components/files/FileMarkdownPreview.tsx+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { ScopedThreadRef } from "@t3tools/contracts";1import type { ScopedThreadRef } from "@t3tools/contracts";
22
3import ChatMarkdown from "~/components/ChatMarkdown";3import ChatMarkdown from "~/components/ChatMarkdown";
4import { resolvePathLinkTarget } from "~/terminal-links";4import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
55
6export function FileMarkdownPreview(props: {6export function FileMarkdownPreview(props: {
7 readonly cwd: string;7 readonly cwd: string;
apps/web/src/components/files/FilePreviewPanel.tsx+2-1
...@@ -57,7 +57,8 @@ import { resolveDiffThemeName } from "~/lib/diffRendering";...@@ -57,7 +57,8 @@ import { resolveDiffThemeName } from "~/lib/diffRendering";
57import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";57import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting";
58import { cn } from "~/lib/utils";58import { cn } from "~/lib/utils";
59import type { ChatFileAttachment } from "~/types";59import type { ChatFileAttachment } from "~/types";
60import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links";60import { isAbsolutePath } from "@t3tools/shared/path";
61import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
61import { ScrollArea } from "~/components/ui/scroll-area";62import { ScrollArea } from "~/components/ui/scroll-area";
62import { stackedThreadToast, toastManager } from "~/components/ui/toast";63import { stackedThreadToast, toastManager } from "~/components/ui/toast";
63import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";64import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
apps/web/src/components/files/filePath.ts+1-3
...@@ -1,7 +1,5 @@...@@ -1,7 +1,5 @@
1import type { ProjectEntry } from "@t3tools/contracts";1import type { ProjectEntry } from "@t3tools/contracts";
2import { isWindowsAbsolutePath } from "@t3tools/shared/path";2import { isWindowsAbsolutePath, isAbsolutePath } from "@t3tools/shared/path";
3
4import { isAbsolutePath } from "~/terminal-links";
53
6export interface FileBreadcrumb {4export interface FileBreadcrumb {
7 label: string;5 label: string;
apps/web/src/components/files/filePreviewMode.ts+1-2
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
2import type { ProjectReadFileError } from "@t3tools/contracts";1import type { ProjectReadFileError } from "@t3tools/contracts";
3import { isAbsolutePath } from "~/terminal-links";2import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path";
43
5/** Resolve workspace links before choosing between the explorer and a file preview. */4/** Resolve workspace links before choosing between the explorer and a file preview. */
6export function resolveFilePreviewPath(path: string | null, cwd: string): string | null {5export 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<{...@@ -162,6 +162,24 @@ const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{
162 },162 },
163 ],163 ],
164 },164 },
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 },
165 {183 {
166 id: "context",184 id: "context",
167 title: "Context",185 title: "Context",
apps/web/src/components/ui/input-group.tsx+29-2
...@@ -11,11 +11,19 @@ const inputGroupVariants = cva(...@@ -11,11 +11,19 @@ const inputGroupVariants = cva(
11 {11 {
12 defaultVariants: {12 defaultVariants: {
13 variant: "default",13 variant: "default",
14 size: "default",
14 },15 },
15 variants: {16 variants: {
17 size: {
18 default: null,
19 lg: "h-9",
20 },
16 variant: {21 variant: {
17 default:22 default:
18 "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%)]",23 "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)]",
19 ghost:27 ghost:
20 "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background",28 "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background",
21 },29 },
...@@ -26,11 +34,12 @@ const inputGroupVariants = cva(...@@ -26,11 +34,12 @@ const inputGroupVariants = cva(
26function InputGroup({34function InputGroup({
27 className,35 className,
28 variant,36 variant,
37 size,
29 ...props38 ...props
30}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {39}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
31 return (40 return (
32 <div41 <div
33 className={cn(inputGroupVariants({ variant }), className)}42 className={cn(inputGroupVariants({ variant, size }), className)}
34 data-slot="input-group"43 data-slot="input-group"
35 role="group"44 role="group"
36 {...props}45 {...props}
...@@ -44,7 +53,24 @@ const inputGroupAddonVariants = cva(...@@ -44,7 +53,24 @@ const inputGroupAddonVariants = cva(
44 defaultVariants: {53 defaultVariants: {
45 align: "inline-start",54 align: "inline-start",
46 },55 },
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 ],
47 variants: {69 variants: {
70 inset: {
71 default: null,
72 pill: "[&_button]:rounded-full",
73 },
48 align: {74 align: {
49 "block-end":75 "block-end":
50 "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)]",76 "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(...@@ -62,11 +88,12 @@ const inputGroupAddonVariants = cva(
62function InputGroupAddon({88function InputGroupAddon({
63 className,89 className,
64 align = "inline-start",90 align = "inline-start",
91 inset,
65 ...props92 ...props
66}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {93}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
67 return (94 return (
68 <div95 <div
69 className={cn(inputGroupAddonVariants({ align }), className)}96 className={cn(inputGroupAddonVariants({ align, inset }), className)}
70 data-align={align}97 data-align={align}
71 data-slot="input-group-addon"98 data-slot="input-group-addon"
72 onMouseDown={(e) => {99 onMouseDown={(e) => {
apps/web/src/diffFileActions.ts+1-1
...@@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts";...@@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts";
2import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path";2import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path";
33
4import { useRightPanelStore } from "./rightPanelStore";4import { useRightPanelStore } from "./rightPanelStore";
5import { resolvePathLinkTarget } from "./terminal-links";5import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
66
7interface OpenDiffFilePrimaryActionInput {7interface OpenDiffFilePrimaryActionInput {
8 readonly threadRef: ScopedThreadRef | null;8 readonly threadRef: ScopedThreadRef | null;
apps/web/src/fileContextMenu.ts+1-1
...@@ -22,7 +22,7 @@ import { readLocalApi } from "./localApi";...@@ -22,7 +22,7 @@ import { readLocalApi } from "./localApi";
22import { serverEnvironment } from "./state/server";22import { serverEnvironment } from "./state/server";
23import { shellEnvironment } from "./state/shell";23import { shellEnvironment } from "./state/shell";
24import { useAtomCommand } from "./state/use-atom-command";24import { useAtomCommand } from "./state/use-atom-command";
25import { resolvePathLinkTarget } from "./terminal-links";25import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
26import { toastManager } from "./components/ui/toast";26import { toastManager } from "./components/ui/toast";
27import { useAtomValue } from "@effect/atom-react";27import { useAtomValue } from "@effect/atom-react";
2828
apps/web/src/filePathDisplay.ts+3-4
...@@ -1,10 +1,9 @@...@@ -1,10 +1,9 @@
1import {1import {
2 fileBasename,2 fileBasename,
3 formatFilePathPosition,
4 splitFilePathPosition,
5 stripSlashPrefixedWindowsDrive,3 stripSlashPrefixedWindowsDrive,
6} from "@t3tools/client-runtime/markdown-links";4 isWindowsAbsolutePath,
7import { isWindowsAbsolutePath } from "@t3tools/shared/path";5} from "@t3tools/shared/path";
6import { formatFilePathPosition, splitFilePathPosition } from "@t3tools/shared/fileLinks";
87
9function normalizePathSeparators(path: string): string {8function normalizePathSeparators(path: string): string {
10 return path.replaceAll("\\", "/");9 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...@@ -373,6 +373,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
373 }373 }
374}374}
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
376@utility dialog-glass {390@utility dialog-glass {
377 background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);391 background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);
378 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));392 -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([...@@ -142,6 +142,14 @@ const DEFAULT_BINDINGS = compile([
142 shortcut: modShortcut("t", { altKey: true, shiftKey: true }),142 shortcut: modShortcut("t", { altKey: true, shiftKey: true }),
143 command: "themeEditor.toggle",143 command: "themeEditor.toggle",
144 },144 },
145 {
146 shortcut: modShortcut("f"),
147 command: "chat.find",
148 whenAst: whenAnd(
149 whenNot(whenIdentifier("terminalFocus")),
150 whenNot(whenIdentifier("previewFocus")),
151 ),
152 },
145 {153 {
146 shortcut: modShortcut("m", { shiftKey: true }),154 shortcut: modShortcut("m", { shiftKey: true }),
147 command: "modelPicker.toggle",155 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 @@...@@ -1,16 +1,15 @@
1import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline";
1import type { Root } from "mdast";2import type { Root } from "mdast";
2import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
3import ReactMarkdown from "react-markdown";4import ReactMarkdown from "react-markdown";
4import rehypeRaw from "rehype-raw";
5import rehypeSanitize from "rehype-sanitize";
6import remarkGfm from "remark-gfm";5import remarkGfm from "remark-gfm";
7import type { Plugin } from "unified";6import type { Plugin } from "unified";
8import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
98
10import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives";9import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives";
11import { remarkGithubAlerts } from "./markdown-github-alerts";10import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts";
12import { createIncrementalMarkdownPlugin } from "./markdown-incremental";11import { createIncrementalMarkdownPlugin } from "./markdown-incremental";
13import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation";12import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation";
1413
15function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {14function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) {
16 let tree: Root | undefined;15 let tree: Root | undefined;
...@@ -37,7 +36,7 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?:...@@ -37,7 +36,7 @@ function render(source: string, incremental?: Plugin<[], Root>, parsedSources?:
37 remarkCodexDirectives,36 remarkCodexDirectives,
38 ...(incremental ? [incremental] : []),37 ...(incremental ? [incremental] : []),
39 ]}38 ]}
40 rehypePlugins={[rehypeRaw, rehypeSanitize]}39 rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
41 >40 >
42 {source}41 {source}
43 </ReactMarkdown>,42 </ReactMarkdown>,
apps/web/src/markdown-links.test.ts-260
...@@ -1,66 +1,13 @@...@@ -1,66 +1,13 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { createElement } from "react";
3import { renderToStaticMarkup } from "react-dom/server";
4import ReactMarkdown from "react-markdown";
52
6import {3import {
7 extractMarkdownLinkHrefs,
8 isWindowsDrivePathHref,
9 resolveInlineCodeFileLinkMeta,4 resolveInlineCodeFileLinkMeta,
10 resolveMarkdownFileLinkMeta,5 resolveMarkdownFileLinkMeta,
11 resolveMarkdownFileLinkTarget,
12 rewriteMarkdownFileUriHref,6 rewriteMarkdownFileUriHref,
13 shouldOpenMarkdownFileLinkInBrowserByDefault,7 shouldOpenMarkdownFileLinkInBrowserByDefault,
14 shouldOpenMarkdownFileLinkInEditor,8 shouldOpenMarkdownFileLinkInEditor,
15} from "./markdown-links";9} 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
64describe("shouldOpenMarkdownFileLinkInEditor", () => {11describe("shouldOpenMarkdownFileLinkInEditor", () => {
65 it("uses command-click on macOS", () => {12 it("uses command-click on macOS", () => {
66 expect(shouldOpenMarkdownFileLinkInEditor({ metaKey: true, ctrlKey: false }, "MacIntel")).toBe(13 expect(shouldOpenMarkdownFileLinkInEditor({ metaKey: true, ctrlKey: false }, "MacIntel")).toBe(
...@@ -130,213 +77,6 @@ describe("rewriteMarkdownFileUriHref", () => {...@@ -130,213 +77,6 @@ describe("rewriteMarkdownFileUriHref", () => {
130 });77 });
131});78});
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
340describe("relative links inside a rendered host file", () => {80describe("relative links inside a rendered host file", () => {
341 it("anchor to the file's directory while workspace membership follows cwd", () => {81 it("anchor to the file's directory while workspace membership follows cwd", () => {
342 const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report");82 const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report");
apps/web/src/markdown-links.ts+5-48
...@@ -1,23 +1,13 @@...@@ -1,23 +1,13 @@
1import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path";
1import {2import {
2 fileBasename,
3 formatFilePathPosition,
4 inlineCodeFilePathCandidate,3 inlineCodeFilePathCandidate,
5 isRelativeFilePath,
6 normalizeMarkdownLinkDestination,4 normalizeMarkdownLinkDestination,
7 parseFileUrlHref,5 resolveMarkdownFileLinkTarget,
8 parseMarkdownFileLink,6} from "@t3tools/shared/markdownLinks";
9 safeDecodeURIComponent,7import { parseFileUrlHref, splitFilePathPosition } from "@t3tools/shared/fileLinks";
10 splitFilePathPosition,
11 workspaceRelativeFilePath,
12} from "@t3tools/client-runtime/markdown-links";
138
14import { formatWorkspaceRelativePath } from "./filePathDisplay";9import { formatWorkspaceRelativePath } from "./filePathDisplay";
15import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links";10import { isTerminalLinkActivation } from "./terminal-links";
16
17export { normalizeMarkdownLinkDestination };
18
19const MARKDOWN_LINK_HREF_PATTERN =
20 /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g;
2111
22export interface MarkdownFileLinkMeta {12export interface MarkdownFileLinkMeta {
23 filePath: string;13 filePath: string;
...@@ -29,15 +19,6 @@ export interface MarkdownFileLinkMeta {...@@ -29,15 +19,6 @@ export interface MarkdownFileLinkMeta {
29 column?: number;19 column?: number;
30}20}
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
41export function shouldOpenMarkdownFileLinkInEditor(22export function shouldOpenMarkdownFileLinkInEditor(
42 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,23 event: Pick<MouseEvent, "metaKey" | "ctrlKey">,
43 platform?: string,24 platform?: string,
...@@ -49,36 +30,12 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool...@@ -49,36 +30,12 @@ export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): bool
49 return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");30 return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? "");
50}31}
5132
52export function isWindowsDrivePathHref(href: string): boolean {
53 return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
54}
55
56export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {33export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {
57 if (!href) return null;34 if (!href) return null;
58 const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href));35 const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href));
59 return target ? `${target.path}${target.hash}` : null;36 return target ? `${target.path}${target.hash}` : null;
60}37}
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
82/**39/**
83 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,40 * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`,
84 * `origin/main`) rather than deliberate link destinations, so auto-linking41 * `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", () => {...@@ -305,6 +305,10 @@ describe("Open VSX themes", () => {
305 "colors": {305 "colors": {
306 "editor.foreground": "#eeeeee",306 "editor.foreground": "#eeeeee",
307 "focusBorder": "#8b5cf6",307 "focusBorder": "#8b5cf6",
308 "editor.findMatchBackground": "#663399",
309 "editor.findMatchForeground": "#ffffff",
310 "editor.findMatchHighlightBackground": "#445566",
311 "editor.findMatchHighlightForeground": "#ffffff",
308 },312 },
309 }`,313 }`,
310 );314 );
...@@ -312,7 +316,10 @@ describe("Open VSX themes", () => {...@@ -312,7 +316,10 @@ describe("Open VSX themes", () => {
312 "extension/themes/demo-dark.json",316 "extension/themes/demo-dark.json",
313 `{317 `{
314 "include": "./base.jsonc",318 "include": "./base.jsonc",
315 "colors": { "editor.background": "#111111" }319 "colors": {
320 "editor.background": "#111111",
321 "editor.findMatchHighlightBackground": "#ffffff20"
322 }
316 }`,323 }`,
317 );324 );
318 zip.file(325 zip.file(
...@@ -321,7 +328,11 @@ describe("Open VSX themes", () => {...@@ -321,7 +328,11 @@ describe("Open VSX themes", () => {
321 "colors": {328 "colors": {
322 "editor.background": "#fafafa",329 "editor.background": "#fafafa",
323 "editor.foreground": "#222222",330 "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"
325 }336 }
326 }`,337 }`,
327 );338 );
...@@ -410,6 +421,15 @@ describe("Open VSX themes", () => {...@@ -410,6 +421,15 @@ describe("Open VSX themes", () => {
410 expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa");421 expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa");
411 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111");422 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111");
412 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee");423 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
414 packagedManifest.contributes.themes[0]!.label = "Renamed Dark";434 packagedManifest.contributes.themes[0]!.label = "Renamed Dark";
415 packagedManifest.contributes.themes[1]!.label = "Renamed Light";435 packagedManifest.contributes.themes[1]!.label = "Renamed Light";
apps/web/src/openVsxThemes.ts+4
...@@ -49,6 +49,10 @@ const USED_WORKBENCH_COLORS = new Set([...@@ -49,6 +49,10 @@ const USED_WORKBENCH_COLORS = new Set([
49 "dropdown.border",49 "dropdown.border",
50 "editor.background",50 "editor.background",
51 "editor.foreground",51 "editor.foreground",
52 "editor.findMatchBackground",
53 "editor.findMatchForeground",
54 "editor.findMatchHighlightBackground",
55 "editor.findMatchHighlightForeground",
52 "editor.selectionBackground",56 "editor.selectionBackground",
53 "editorCursor.foreground",57 "editorCursor.foreground",
54 "editorError.foreground",58 "editorError.foreground",
apps/web/src/proposedPlan.test.ts-26
...@@ -5,21 +5,9 @@ import {...@@ -5,21 +5,9 @@ import {
5 buildPlanImplementationThreadTitle,5 buildPlanImplementationThreadTitle,
6 buildPlanImplementationPrompt,6 buildPlanImplementationPrompt,
7 buildProposedPlanMarkdownFilename,7 buildProposedPlanMarkdownFilename,
8 proposedPlanTitle,
9 resolvePlanFollowUpSubmission,8 resolvePlanFollowUpSubmission,
10 stripDisplayedPlanMarkdown,
11} from "./proposedPlan";9} 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
23describe("buildPlanImplementationPrompt", () => {11describe("buildPlanImplementationPrompt", () => {
24 it("formats the plan exactly like the Codex follow-up handoff prompt", () => {12 it("formats the plan exactly like the Codex follow-up handoff prompt", () => {
25 expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe(13 expect(buildPlanImplementationPrompt("## Ship it\n\n- step 1\n")).toBe(
...@@ -49,20 +37,6 @@ describe("buildCollapsedProposedPlanPreviewMarkdown", () => {...@@ -49,20 +37,6 @@ describe("buildCollapsedProposedPlanPreviewMarkdown", () => {
49 });37 });
50});38});
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
66describe("resolvePlanFollowUpSubmission", () => {40describe("resolvePlanFollowUpSubmission", () => {
67 it.each(["", "Consider this "])(41 it.each(["", "Consider this "])(
68 "keeps context-bearing feedback in plan mode with prose %j",42 "keeps context-bearing feedback in plan mode with prose %j",
apps/web/src/proposedPlan.ts+1-20
...@@ -1,23 +1,4 @@...@@ -1,23 +1,4 @@
1export function proposedPlanTitle(planMarkdown: string): string | null {1import { stripDisplayedPlanMarkdown, proposedPlanTitle } from "@t3tools/shared/proposedPlanText";
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}
212
22export function buildCollapsedProposedPlanPreviewMarkdown(3export function buildCollapsedProposedPlanPreviewMarkdown(
23 planMarkdown: string,4 planMarkdown: string,
apps/web/src/providerSkillSearch.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
1import type { ServerProviderSkill } from "@t3tools/contracts";2import type { ServerProviderSkill } from "@t3tools/contracts";
2import {3import {
3 dedupeProviderSkillsByName,4 dedupeProviderSkillsByName,
4 formatProviderSkillDisplayName,
5 isProviderSkillUserInvocable,5 isProviderSkillUserInvocable,
6} from "@t3tools/client-runtime/providerSkills";6} from "@t3tools/client-runtime/providerSkills";
7import {7import {
apps/web/src/terminal-links.test.ts+1-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
23
3import {4import {
...@@ -5,7 +6,6 @@ import {...@@ -5,7 +6,6 @@ import {
5 extractTerminalLinks,6 extractTerminalLinks,
6 isTerminalLinkActivation,7 isTerminalLinkActivation,
7 isTerminalUrl,8 isTerminalUrl,
8 resolvePathLinkTarget,
9 type TerminalBufferLineLike,9 type TerminalBufferLineLike,
10} from "./terminal-links";10} from "./terminal-links";
1111
apps/web/src/terminal-links.ts-63
...@@ -1,8 +1,3 @@...@@ -1,8 +1,3 @@
1import {
2 formatFilePathPosition,
3 splitFilePathPosition,
4} from "@t3tools/client-runtime/markdown-links";
5
6import { isMacPlatform } from "./lib/utils";1import { isMacPlatform } from "./lib/utils";
72
8export type TerminalLinkKind = "url" | "path";3export type TerminalLinkKind = "url" | "path";
...@@ -98,45 +93,6 @@ function collectMatches(...@@ -98,45 +93,6 @@ function collectMatches(
98 return matches;93 return matches;
99}94}
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
140export function extractTerminalLinks(line: string): TerminalLinkMatch[] {96export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
141 const urlMatches = collectMatches(line, "url", URL_PATTERN, []);97 const urlMatches = collectMatches(line, "url", URL_PATTERN, []);
142 const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches);98 const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, urlMatches);
...@@ -203,22 +159,3 @@ export function isTerminalLinkActivation(...@@ -203,22 +159,3 @@ export function isTerminalLinkActivation(
203 ? event.metaKey && !event.ctrlKey159 ? event.metaKey && !event.ctrlKey
204 : event.ctrlKey && !event.metaKey;160 : event.ctrlKey && !event.metaKey;
205}161}
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 {...@@ -33,6 +33,7 @@ import {
33 IRIS_THEME,33 IRIS_THEME,
34 OCEAN_THEME,34 OCEAN_THEME,
35 updateCustomTheme,35 updateCustomTheme,
36 updateThemeColorFamily,
36 CUSTOM_THEMES_STORAGE_KEY,37 CUSTOM_THEMES_STORAGE_KEY,
37 createVividThemeColors,38 createVividThemeColors,
38 getDefaultThemeColors,39 getDefaultThemeColors,
...@@ -90,6 +91,68 @@ describe("theme files", () => {...@@ -90,6 +91,68 @@ describe("theme files", () => {
90 }91 }
91 });92 });
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
93 it("keeps stock dark controls in the neutral-black surface hierarchy", () => {156 it("keeps stock dark controls in the neutral-black surface hierarchy", () => {
94 expectThemeColors(getStandardThemeColors("dark"), {157 expectThemeColors(getStandardThemeColors("dark"), {
95 canvas: "#0a0a0a",158 canvas: "#0a0a0a",
...@@ -155,6 +218,12 @@ describe("theme files", () => {...@@ -155,6 +218,12 @@ describe("theme files", () => {
155 );218 );
156 expect(colors.secondaryLabel).toBe(colors.textMuted);219 expect(colors.secondaryLabel).toBe(colors.textMuted);
157 expect(contrastRatio(colors.accentForeground, colors.accent)).toBeGreaterThanOrEqual(4.5);220 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);
158 expect(227 expect(
159 contrastRatio(colors.messageActionForeground, colors.messageAction),228 contrastRatio(colors.messageActionForeground, colors.messageAction),
160 ).toBeGreaterThanOrEqual(4.5);229 ).toBeGreaterThanOrEqual(4.5);
apps/web/src/themePalette.ts+24
...@@ -743,6 +743,10 @@ export function createVividThemeColors(...@@ -743,6 +743,10 @@ export function createVividThemeColors(
743 const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6));743 const mutedForeground = themeRgbToThemeColor(readableThemeText(mutedRgb, textRgb, 1, 4.6));
744 const placeholder = themeRgbToThemeColor(readableThemeText(surfaceRaisedRgb, textRgb, 1, 4.6));744 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
746 const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) };750 const actionHover: ThemeOklch = { ...action, L: action.L + (dark ? 0.06 : -0.06) };
747751
748 return {752 return {
...@@ -786,6 +790,10 @@ export function createVividThemeColors(...@@ -786,6 +790,10 @@ export function createVividThemeColors(
786 messageActionHover: themeColor(actionHover),790 messageActionHover: themeColor(actionHover),
787 codeBackground: themeColor(codeBackground),791 codeBackground: themeColor(codeBackground),
788 codeForeground: themeRgbToThemeColor(textRgb),792 codeForeground: themeRgbToThemeColor(textRgb),
793 searchMatchBackground: themeRgbToThemeColor(searchMatch),
794 searchMatchForeground: themeRgbToThemeColor(readableThemeForeground(searchMatch)),
795 searchMatchActiveBackground: themeRgbToThemeColor(searchMatchActive),
796 searchMatchActiveForeground: themeRgbToThemeColor(readableThemeForeground(searchMatchActive)),
789 sidebar: themeColor(sidebar),797 sidebar: themeColor(sidebar),
790 sidebarForeground: foregroundOn(sidebarRgb),798 sidebarForeground: foregroundOn(sidebarRgb),
791 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)),799 sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)),
...@@ -1005,6 +1013,18 @@ export function updateThemeColorFamily(...@@ -1005,6 +1013,18 @@ export function updateThemeColorFamily(
1005 messageSurface: normalized,1013 messageSurface: normalized,
1006 messageForeground: foregroundOn(selectedOnCanvas),1014 messageForeground: foregroundOn(selectedOnCanvas),
1007 };1015 };
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 };
1008 case "codeBackground":1028 case "codeBackground":
1009 return {1029 return {
1010 ...colors,1030 ...colors,
...@@ -1469,6 +1489,10 @@ const APP_THEME_VARIABLES: Readonly<Record<ThemeColorRole, string>> = {...@@ -1469,6 +1489,10 @@ const APP_THEME_VARIABLES: Readonly<Record<ThemeColorRole, string>> = {
1469 messageActionHover: "--app-theme-message-action-hover",1489 messageActionHover: "--app-theme-message-action-hover",
1470 codeBackground: "--app-theme-code-background",1490 codeBackground: "--app-theme-code-background",
1471 codeForeground: "--app-theme-code-foreground",1491 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",
1472 sidebar: "--app-theme-sidebar",1496 sidebar: "--app-theme-sidebar",
1473 sidebarForeground: "--app-theme-sidebar-foreground",1497 sidebarForeground: "--app-theme-sidebar-foreground",
1474 sidebarMutedForeground: "--app-theme-sidebar-muted-foreground",1498 sidebarMutedForeground: "--app-theme-sidebar-muted-foreground",
apps/web/src/vscodeThemeImport.test.ts+94
...@@ -292,6 +292,100 @@ describe("VS Code theme import", () => {...@@ -292,6 +292,100 @@ describe("VS Code theme import", () => {
292 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),292 contrastRatio(lightPlus.colors.placeholder, lightPlus.colors.surfaceRaised),
293 ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised));293 ).toBeLessThan(contrastRatio(lightPlus.colors.text, lightPlus.colors.surfaceRaised));
294 });294 });
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
296 it("fills every role the file omits with a readable derived value", () => {390 it("fills every role the file omits with a readable derived value", () => {
297 const theme = parseVsCodeThemeFile(VSCODE_DARK);391 const theme = parseVsCodeThemeFile(VSCODE_DARK);
apps/web/src/vscodeThemeImport.ts+22
...@@ -321,6 +321,12 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -321,6 +321,12 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
321 break;321 break;
322 }322 }
323 }323 }
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
325 const overrides: Partial<Record<ThemeColorRole, string>> = {331 const overrides: Partial<Record<ThemeColorRole, string>> = {
326 canvas: canvasHex,332 canvas: canvasHex,
...@@ -346,6 +352,22 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {...@@ -346,6 +352,22 @@ export function parseVsCodeThemeFile(value: unknown): ThemeDefinition {
346 solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??352 solidOver(canvas, "list.activeSelectionBackground", "list.hoverBackground") ??
347 derived.accentSurface,353 derived.accentSurface,
348 codeBackground: solidOver(canvas, "textCodeBlock.background") ?? derived.codeBackground,354 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 ),
349 sidebar: sidebarHex,371 sidebar: sidebarHex,
350 sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),372 sidebarForeground: readableOn(sidebarHex, derived.sidebarForeground, "sideBar.foreground"),
351 sidebarBorder: solidOver(sidebar, "sideBar.border") ?? derived.sidebarBorder,373 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:...@@ -121,6 +121,14 @@ Combine keys with `!` for not, `&&` for and, `||` for or, and parentheses:
121{ "key": "mod+j", "command": "terminal.toggle", "when": "terminalOpen && !terminalFocus" }121{ "key": "mod+j", "command": "terminal.toggle", "when": "terminalOpen && !terminalFocus" }
122```122```
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
124## Precedence132## Precedence
125133
126The last rule whose key and condition both match wins, even if it belongs to a134The 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 @@...@@ -63,10 +63,6 @@
63 "types": "./src/markdownImages.ts",63 "types": "./src/markdownImages.ts",
64 "default": "./src/markdownImages.ts"64 "default": "./src/markdownImages.ts"
65 },65 },
66 "./markdown-links": {
67 "types": "./src/markdownLinks.ts",
68 "default": "./src/markdownLinks.ts"
69 },
70 "./media-reference": {66 "./media-reference": {
71 "types": "./src/mediaReference.ts",67 "types": "./src/mediaReference.ts",
72 "default": "./src/mediaReference.ts"68 "default": "./src/mediaReference.ts"
...@@ -79,18 +75,6 @@...@@ -79,18 +75,6 @@
79 "types": "./src/mediaActions.ts",75 "types": "./src/mediaActions.ts",
80 "default": "./src/mediaActions.ts"76 "default": "./src/mediaActions.ts"
81 },77 },
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 },
94 "./errors": {78 "./errors": {
95 "types": "./src/errors/index.ts",79 "types": "./src/errors/index.ts",
96 "default": "./src/errors/index.ts"80 "default": "./src/errors/index.ts"
...@@ -412,17 +396,11 @@...@@ -412,17 +396,11 @@
412 "@t3tools/contracts": "workspace:*",396 "@t3tools/contracts": "workspace:*",
413 "@t3tools/shared": "workspace:*",397 "@t3tools/shared": "workspace:*",
414 "effect": "catalog:",398 "effect": "catalog:",
415 "mdast-util-directive": "^3.1.0",399 "three": "0.180.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"
421 },400 },
422 "devDependencies": {401 "devDependencies": {
423 "@effect/vitest": "catalog:",402 "@effect/vitest": "catalog:",
424 "@types/three": "0.180.0",403 "@types/three": "0.180.0",
425 "micromark-util-types": "^2.0.2",
426 "vite-plus": "catalog:"404 "vite-plus": "catalog:"
427 }405 }
428}406}
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 @@...@@ -1,12 +1,11 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";1import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "@t3tools/shared/path";
22
3import {3import {
4 normalizeMarkdownLinkDestination,4 normalizeMarkdownLinkDestination,
5 parseFileUrlHref,
6 safeDecodeURIComponent,5 safeDecodeURIComponent,
7 splitMarkdownLinkSearchAndHash,6 splitMarkdownLinkSearchAndHash,
8 stripSlashPrefixedWindowsDrive,7} from "@t3tools/shared/markdownLinks";
9} from "./markdownLinks.ts";8import { parseFileUrlHref } from "@t3tools/shared/fileLinks";
109
11const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i;10const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i;
12const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/;11const 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 @@...@@ -1,6 +1,6 @@
1import { isWindowsAbsolutePath } from "@t3tools/shared/path";1import { isWindowsAbsolutePath } from "@t3tools/shared/path";
22
3import { safeDecodeURIComponent } from "./markdownLinks.ts";3import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks";
44
5/** The authored media location, never the temporary URL used to load its bytes. */5/** The authored media location, never the temporary URL used to load its bytes. */
6export type MediaReference =6export type MediaReference =
packages/client-runtime/src/mediaSource.ts+3-5
...@@ -6,11 +6,9 @@ import {...@@ -6,11 +6,9 @@ import {
6 markdownImageSourceFragment,6 markdownImageSourceFragment,
7 type MarkdownImageSource,7 type MarkdownImageSource,
8} from "./markdownImages.ts";8} from "./markdownImages.ts";
9import {9import { fileBasename } from "@t3tools/shared/path";
10 fileBasename,10import { splitFilePathPosition } from "@t3tools/shared/fileLinks";
11 splitFilePathPosition,11import { splitMarkdownLinkSearchAndHash } from "@t3tools/shared/markdownLinks";
12 splitMarkdownLinkSearchAndHash,
13} from "./markdownLinks.ts";
14import {12import {
15 mediaFileReference,13 mediaFileReference,
16 mediaReferenceFileName,14 mediaReferenceFileName,
packages/client-runtime/src/providerSkills.test.ts+1-1
...@@ -4,11 +4,11 @@ import {...@@ -4,11 +4,11 @@ import {
4 ProviderInstanceId,4 ProviderInstanceId,
5 type ServerProvider,5 type ServerProvider,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills";
7import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
89
9import {10import {
10 dedupeProviderSkillsByName,11 dedupeProviderSkillsByName,
11 formatProviderSkillDisplayName,
12 getProviderSlashCommandsForSlashMenu,12 getProviderSlashCommandsForSlashMenu,
13 getProviderSkillsForSlashMenu,13 getProviderSkillsForSlashMenu,
14 hasCompleteProviderWorkspaceSnapshot,14 hasCompleteProviderWorkspaceSnapshot,
packages/client-runtime/src/providerSkills.ts-19
...@@ -7,29 +7,10 @@ import {...@@ -7,29 +7,10 @@ import {
77
8export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";8export 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
19function normalizePathSeparators(pathValue: string): string {10function normalizePathSeparators(pathValue: string): string {
20 return pathValue.replaceAll("\\", "/");11 return pathValue.replaceAll("\\", "/");
21}12}
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
33export function dedupeProviderSkillsByName(14export function dedupeProviderSkillsByName(
34 skills: ReadonlyArray<ServerProviderSkill>,15 skills: ReadonlyArray<ServerProviderSkill>,
35): ServerProviderSkill[] {16): ServerProviderSkill[] {
packages/client-runtime/src/rpc/client.ts+1
...@@ -53,6 +53,7 @@ export type EnvironmentSubscriptionRpcTag =...@@ -53,6 +53,7 @@ export type EnvironmentSubscriptionRpcTag =
53 | typeof WS_METHODS.providerInstallSubscribe53 | typeof WS_METHODS.providerInstallSubscribe
54 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell54 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell
55 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread55 | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread
56 | typeof ORCHESTRATION_V2_WS_METHODS.searchThreadStream
56 | typeof WS_METHODS.subscribeAuthAccess57 | typeof WS_METHODS.subscribeAuthAccess
57 | typeof WS_METHODS.subscribeServerConfig58 | typeof WS_METHODS.subscribeServerConfig
58 | typeof WS_METHODS.subscribeServerLifecycle59 | typeof WS_METHODS.subscribeServerLifecycle
packages/client-runtime/src/state/orchestration.ts+12
...@@ -55,6 +55,18 @@ export function createOrchestrationEnvironmentAtoms<R, E>(...@@ -55,6 +55,18 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
55 label: "environment-data:orchestration:full-thread-diff",55 label: "environment-data:orchestration:full-thread-diff",
56 tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,56 tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,
57 }),57 }),
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 }),
58 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {70 threadSearch: createEnvironmentRpcQueryAtomFamily(runtime, {
59 label: "environment-data:orchestration:thread-search",71 label: "environment-data:orchestration:thread-search",
60 tag: ORCHESTRATION_V2_WS_METHODS.searchThreads,72 tag: ORCHESTRATION_V2_WS_METHODS.searchThreads,
packages/client-runtime/src/state/runtime.test.ts+70
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 environmentRpcKey,30 environmentRpcKey,
31 createAtomCommandScheduler,31 createAtomCommandScheduler,
32 createEnvironmentQueryAtomFamily,32 createEnvironmentQueryAtomFamily,
33 createEnvironmentSubscriptionAtomFamily,
33 createRuntimeCommand,34 createRuntimeCommand,
34 scheduleAtomCommandEffect,35 scheduleAtomCommandEffect,
35 executeAtomCommand,36 executeAtomCommand,
...@@ -291,6 +292,75 @@ describe("environmentRpcKey", () => {...@@ -291,6 +292,75 @@ describe("environmentRpcKey", () => {
291 });292 });
292});293});
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
294describe("environment query lifecycle", () => {364describe("environment query lifecycle", () => {
295 it.effect(365 it.effect(
296 "retries an interrupted query without exposing a failure during session replacement",366 "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> {...@@ -78,6 +78,7 @@ interface EnvironmentSubscriptionAtomOptions<Input, A, E, R> {
78 readonly sensitiveInput?: boolean;78 readonly sensitiveInput?: boolean;
79 readonly label: string;79 readonly label: string;
80 readonly subscribe: (input: Input) => Stream.Stream<A, E, R>;80 readonly subscribe: (input: Input) => Stream.Stream<A, E, R>;
81 readonly completeWhen?: (value: A) => boolean;
81 readonly idleTtlMs?: number;82 readonly idleTtlMs?: number;
82}83}
8384
...@@ -618,7 +619,11 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(...@@ -618,7 +619,11 @@ export function createEnvironmentSubscriptionAtomFamily<R, ER, Input, A, E>(
618 const family = Atom.family((key: string) => {619 const family = Atom.family((key: string) => {
619 const target = parseEnvironmentRpcKey<Input>(key);620 const target = parseEnvironmentRpcKey<Input>(key);
620 return runtime621 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 )
622 .pipe(627 .pipe(
623 Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000),628 Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000),
624 Atom.withLabel(629 Atom.withLabel(
...@@ -705,6 +710,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<...@@ -705,6 +710,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
705 readonly label: string;710 readonly label: string;
706 readonly tag: TTag;711 readonly tag: TTag;
707 readonly idleTtlMs?: number;712 readonly idleTtlMs?: number;
713 readonly completeWhen?: (value: B) => boolean;
708 readonly transform?: (714 readonly transform?: (
709 stream: Stream.Stream<715 stream: Stream.Stream<
710 EnvironmentRpcStreamValue<TTag>,716 EnvironmentRpcStreamValue<TTag>,
...@@ -720,6 +726,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<...@@ -720,6 +726,7 @@ export function createEnvironmentRpcSubscriptionAtomFamily<
720) {726) {
721 return createEnvironmentSubscriptionAtomFamily(runtime, {727 return createEnvironmentSubscriptionAtomFamily(runtime, {
722 label: options.label,728 label: options.label,
729 ...(options.completeWhen === undefined ? {} : { completeWhen: options.completeWhen }),
723 ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }),730 ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }),
724 subscribe: (input: EnvironmentRpcInput<TTag>) => {731 subscribe: (input: EnvironmentRpcInput<TTag>) => {
725 const stream = subscribe(options.tag, input);732 const stream = subscribe(options.tag, input);
packages/client-runtime/src/state/subagentDisplay.ts+1-1
...@@ -5,7 +5,7 @@ import type {...@@ -5,7 +5,7 @@ import type {
5 ServerProvider,5 ServerProvider,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";7import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
8import { fileBasename } from "../markdownLinks.ts";8import { fileBasename } from "@t3tools/shared/path";
9import { isTerminalSubagentStatus } from "./subagentRuntime.ts";9import { isTerminalSubagentStatus } from "./subagentRuntime.ts";
1010
11/** Summarizes one adjacent group, without changing its member identities or order. */11/** 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";...@@ -97,6 +97,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts";
9797
98export type LoadEarlierThreadHistoryInput = {98export type LoadEarlierThreadHistoryInput = {
99 readonly threadId: ThreadId;99 readonly threadId: ThreadId;
100 readonly throughEntryId?: string;
100};101};
101102
102export type {103export type {
...@@ -387,6 +388,7 @@ export function createThreadEnvironmentAtoms<R, E>(...@@ -387,6 +388,7 @@ export function createThreadEnvironmentAtoms<R, E>(
387 return yield* controller.value.loadEarlier(388 return yield* controller.value.loadEarlier(
388 supervisor.target.environmentId,389 supervisor.target.environmentId,
389 input.threadId,390 input.threadId,
391 input.throughEntryId,
390 );392 );
391 }),393 }),
392 scheduler,394 scheduler,
packages/client-runtime/src/state/threadHistoryController.ts+4-3
...@@ -13,7 +13,7 @@ export type ThreadHistoryLoadEarlierResult =...@@ -13,7 +13,7 @@ export type ThreadHistoryLoadEarlierResult =
13 | { readonly _tag: "error"; readonly message: string };13 | { readonly _tag: "error"; readonly message: string };
1414
15export type ThreadHistoryHandler = {15export type ThreadHistoryHandler = {
16 readonly loadEarlier: () => Effect.Effect<ThreadHistoryLoadEarlierResult>;16 readonly loadEarlier: (throughEntryId?: string) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
17};17};
1818
19/**19/**
...@@ -42,6 +42,7 @@ export class ThreadHistoryController extends Context.Service<...@@ -42,6 +42,7 @@ export class ThreadHistoryController extends Context.Service<
42 readonly loadEarlier: (42 readonly loadEarlier: (
43 environmentId: EnvironmentId,43 environmentId: EnvironmentId,
44 threadId: ThreadId,44 threadId: ThreadId,
45 throughEntryId?: string,
45 ) => Effect.Effect<ThreadHistoryLoadEarlierResult>;46 ) => Effect.Effect<ThreadHistoryLoadEarlierResult>;
46 }47 }
47>()("@t3tools/client-runtime/state/threadHistoryController") {}48>()("@t3tools/client-runtime/state/threadHistoryController") {}
...@@ -78,14 +79,14 @@ export const layer: Layer.Layer<ThreadHistoryController> = Layer.effect(...@@ -78,14 +79,14 @@ export const layer: Layer.Layer<ThreadHistoryController> = Layer.effect(
78 next.delete(key);79 next.delete(key);
79 return next;80 return next;
80 }),81 }),
81 loadEarlier: (environmentId, threadId) =>82 loadEarlier: (environmentId, threadId, throughEntryId) =>
82 Ref.get(handlers).pipe(83 Ref.get(handlers).pipe(
83 Effect.flatMap((current) => {84 Effect.flatMap((current) => {
84 const handler = current.get(threadKey({ environmentId, threadId }));85 const handler = current.get(threadKey({ environmentId, threadId }));
85 if (handler === undefined) {86 if (handler === undefined) {
86 return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult);87 return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult);
87 }88 }
88 return handler.loadEarlier();89 return handler.loadEarlier(throughEntryId);
89 }),90 }),
90 ),91 ),
91 });92 });
packages/client-runtime/src/state/threadHistoryHttp.ts+7-1
...@@ -19,6 +19,8 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(...@@ -19,6 +19,8 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(
19 readonly prepared: PreparedConnection;19 readonly prepared: PreparedConnection;
20 readonly threadId: ThreadId;20 readonly threadId: ThreadId;
21 readonly cursor: string;21 readonly cursor: string;
22 readonly throughEntryId?: string | undefined;
23 readonly view?: "conversation" | "activity" | undefined;
22 readonly signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>;24 readonly signer: Option.Option<ManagedRelay.ManagedRelayDpopSigner["Service"]>;
23 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;25 readonly remoteAuthorization?: Option.Option<RemoteEnvironmentAuthorization["Service"]>;
24 readonly timeoutMs?: number;26 readonly timeoutMs?: number;
...@@ -33,7 +35,11 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(...@@ -33,7 +35,11 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn(
33 request: ({ client, headers }) =>35 request: ({ client, headers }) =>
34 client.threadHistoryPage({36 client.threadHistoryPage({
35 params: { threadId: input.threadId },37 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 },
37 headers: withOrchestrationProtocolHeader(headers),43 headers: withOrchestrationProtocolHeader(headers),
38 }),44 }),
39 });45 });
packages/client-runtime/src/state/threadHistoryMerge.test.ts+71
...@@ -45,6 +45,51 @@ function row(index: number): OrchestrationV2ProjectedTurnItem {...@@ -45,6 +45,51 @@ function row(index: number): OrchestrationV2ProjectedTurnItem {
45}45}
4646
47describe("threadHistoryMerge", () => {47describe("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
48 it("prepends older rows and dedupes by source identity", () => {93 it("prepends older rows and dedupes by source identity", () => {
49 const recent = [row(2), row(3)];94 const recent = [row(2), row(3)];
50 const projection = {95 const projection = {
...@@ -69,6 +114,32 @@ describe("threadHistoryMerge", () => {...@@ -69,6 +114,32 @@ describe("threadHistoryMerge", () => {
69 ]);114 ]);
70 });115 });
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
72 it("retains live rows that arrived after the older page was fetched", () => {143 it("retains live rows that arrived after the older page was fetched", () => {
73 const live = [row(5), row(6)];144 const live = [row(5), row(6)];
74 const projection = {145 const projection = {
packages/client-runtime/src/state/threadHistoryMerge.ts+36-38
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import type {1import type {
2 OrchestrationV2ProjectedTurnItem,2 OrchestrationV2ProjectedTurnItem,
3 OrchestrationV2ThreadProjection,3 OrchestrationV2ThreadProjection,
4 OrchestrationV2TurnItem,
5} from "@t3tools/contracts";4} from "@t3tools/contracts";
6import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";5import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline";
76
...@@ -73,62 +72,61 @@ export function mergeOlderHistoryIntoProjection(...@@ -73,62 +72,61 @@ export function mergeOlderHistoryIntoProjection(
73 return projection;72 return projection;
74 }73 }
7574
76 const existingKeys = new Set(projection.visibleTurnItems.map(projectedItemKey));75 const currentByKey = new Map(
77 const turnItemById = new Map<string, OrchestrationV2TurnItem>();76 projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]),
78 for (const item of projection.turnItems) {77 );
79 turnItemById.set(String(item.id), item);78 const turnItemById = new Map(projection.turnItems.map((item) => [String(item.id), item]));
80 }79 const pageKeys = new Set<string>();
81 const prepended: OrchestrationV2ProjectedTurnItem[] = [];80 const page: OrchestrationV2ProjectedTurnItem[] = [];
82 for (const pageRow of olderItems) {81 for (const pageRow of olderItems) {
83 let row = pageRow;82 const key = projectedItemKey(pageRow);
84 const key = projectedItemKey(row);83 if (pageKeys.has(key)) continue;
85 if (existingKeys.has(key)) {84 let row = currentByKey.get(key) ?? pageRow;
86 continue;85 if (
87 }86 !currentByKey.has(key) &&
88 if (row.visibility === "local" || row.sourceThreadId === projection.thread.id) {87 (row.visibility === "local" || row.sourceThreadId === projection.thread.id)
88 ) {
89 const currentItem = turnItemById.get(String(row.sourceItemId));89 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.
90 if (currentItem !== undefined) {92 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 }
98 if (93 if (
94 currentItem.type !== "run_interrupt_request" ||
99 !isOrchestrationV2TurnItemVisible({95 !isOrchestrationV2TurnItemVisible({
100 item: currentItem,96 item: currentItem,
101 runs: projection.runs,97 runs: projection.runs,
102 attempts: projection.attempts,98 attempts: projection.attempts,
103 items: projection.turnItems,99 items: projection.turnItems,
104 })100 })
105 ) {101 )
106 continue;102 continue;
107 }
108 row = { ...row, item: currentItem };103 row = { ...row, item: currentItem };
109 }104 }
110 }105 }
111 existingKeys.add(key);106 pageKeys.add(key);
112 prepended.push(row);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);
113 }113 }
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 )
115 return projection;125 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
128 return {126 return {
129 ...projection,127 ...projection,
130 turnItems: [...turnItemById.values()],128 turnItems: [...turnItemById.values()],
131 visibleTurnItems,129 visibleTurnItems: renumberVisible(visible),
132 };130 };
133}131}
134132
packages/client-runtime/src/state/threads-sync.test.ts+137-2
...@@ -254,7 +254,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -254,7 +254,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
254254
255 return {255 return {
256 threadState,256 threadState,
257 loadEarlier: () => historyController.loadEarlier(TARGET.environmentId, THREAD_ID),257 loadEarlier: (throughEntryId?: string) =>
258 historyController.loadEarlier(TARGET.environmentId, THREAD_ID, throughEntryId),
258 inputs,259 inputs,
259 observed,260 observed,
260 latest,261 latest,
...@@ -780,6 +781,7 @@ describe("EnvironmentThreads", () => {...@@ -780,6 +781,7 @@ describe("EnvironmentThreads", () => {
780 const harness = yield* makeHarness({781 const harness = yield* makeHarness({
781 historyHttpClient: HttpClient.make((request, url) => {782 historyHttpClient: HttpClient.make((request, url) => {
782 expect(url.searchParams.get("cursor")).toBe("socket-history-cursor");783 expect(url.searchParams.get("cursor")).toBe("socket-history-cursor");
784 expect(url.searchParams.get("throughEntryId")).toBe(olderItem.id);
783 return Effect.succeed(785 return Effect.succeed(
784 HttpClientResponse.fromWeb(786 HttpClientResponse.fromWeb(
785 request,787 request,
...@@ -837,7 +839,7 @@ describe("EnvironmentThreads", () => {...@@ -837,7 +839,7 @@ describe("EnvironmentThreads", () => {
837 hasMoreHistory: true,839 hasMoreHistory: true,
838 latestLocalTurnOrdinal: 47,840 latestLocalTurnOrdinal: 47,
839 });841 });
840 expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" });842 expect(yield* harness.loadEarlier(olderItem.id)).toEqual({ _tag: "loaded" });
841 const expanded = yield* SubscriptionRef.get(harness.threadState);843 const expanded = yield* SubscriptionRef.get(harness.threadState);
842 expect(844 expect(
843 Option.getOrThrow(expanded.data).turnItems.some((item) => item.id === olderItem.id),845 Option.getOrThrow(expanded.data).turnItems.some((item) => item.id === olderItem.id),
...@@ -846,6 +848,139 @@ describe("EnvironmentThreads", () => {...@@ -846,6 +848,139 @@ describe("EnvironmentThreads", () => {
846 }),848 }),
847 );849 );
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
849 it.effect("restores compact socket snapshot turnItems before reducing and caching", () =>984 it.effect("restores compact socket snapshot turnItems before reducing and caching", () =>
850 Effect.gen(function* () {985 Effect.gen(function* () {
851 const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");986 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...@@ -634,7 +634,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
634 );634 );
635 });635 });
636636
637 const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* () {637 const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* (
638 throughEntryId?: string,
639 ) {
638 const current = yield* SubscriptionRef.get(state);640 const current = yield* SubscriptionRef.get(state);
639 if (641 if (
640 current.status === "deleted" ||642 current.status === "deleted" ||
...@@ -657,117 +659,81 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -657,117 +659,81 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
657 : history,659 : history,
658 );660 );
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
660 const runLoad = Effect.gen(function* () {682 const runLoad = Effect.gen(function* () {
661 const preparedOption = yield* SubscriptionRef.get(supervisor.prepared);683 const preparedOption = yield* SubscriptionRef.get(supervisor.prepared);
662 if (Option.isNone(preparedOption) || Option.isNone(httpClient)) {684 if (Option.isNone(preparedOption) || Option.isNone(httpClient))
663 const message = "Environment is not connected.";685 return yield* failLoad("Environment is not connected.");
664 const stillCurrent = yield* SubscriptionRef.modify(686
665 state,687 const views =
666 (latest): readonly [boolean, EnvironmentThreadState] => {688 throughEntryId === undefined ? [undefined] : ["conversation" as const, undefined];
667 if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) {689 for (const view of views) {
668 return [false, latest];690 const pageResult = yield* fetchEnvironmentThreadHistoryPage({
669 }691 prepared: preparedOption.value,
670 return [692 threadId,
671 true,693 cursor: requestCursor,
672 {694 throughEntryId,
673 ...latest,695 view,
674 history: { ...latest.history, loading: false, error: message },696 signer: dpopSigner,
675 },697 remoteAuthorization,
676 ];698 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result);
677 },699 if (Result.isFailure(pageResult))
678 );700 return yield* failLoad(formatHistoryError(pageResult.failure));
679 if (!stillCurrent) {701 const page = pageResult.success;
680 return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;702 const complete = view === undefined;
681 }703 const waiting = yield* Ref.get(awaitingCompletion);
682 return {704 // Both stages merge against live state under the same lock. Only the
683 _tag: "error",705 // complete page advances the cursor and releases the loading indicator.
684 message,706 const result = yield* applyLock.withPermits(1)(
685 } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;707 SubscriptionRef.modify(
686 }708 state,
687709 (
688 const pageResult = yield* fetchEnvironmentThreadHistoryPage({710 latest,
689 prepared: preparedOption.value,711 ): readonly [
690 threadId,712 ThreadHistoryController.ThreadHistoryLoadEarlierResult,
691 cursor: requestCursor,713 EnvironmentThreadState,
692 signer: dpopSigner,714 ] => {
693 remoteAuthorization,715 if (!isActiveHistoryRequestCursor(requestCursor, latest.history))
694 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result);716 return [{ _tag: "noop" }, latest];
695717 if (Option.isNone(latest.data) || latest.status === "deleted")
696 if (Result.isFailure(pageResult)) {718 return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }];
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") {
744 return [719 return [
745 { _tag: "noop" },720 { _tag: "loaded" },
746 {721 {
747 ...latest,722 ...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 },
749 },728 },
750 ];729 ];
751 }730 },
752731 ),
753 const merged = mergeOlderHistoryIntoProjection(latest.data.value, page.items);732 );
754 const history = applyHistoryPageMeta(latest.history, page);733 if (result._tag !== "loaded") return result;
755 return [734 if (complete) yield* remember;
756 { _tag: "loaded" },735 }
757 {736 return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult;
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 );
771 });737 });
772738
773 // On Effect interruption only: clear loading when this request cursor is739 // On Effect interruption only: clear loading when this request cursor is
...@@ -785,7 +751,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -785,7 +751,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
785 if (Option.isSome(historyController)) {751 if (Option.isSome(historyController)) {
786 const scope = yield* Scope.Scope;752 const scope = yield* Scope.Scope;
787 const registration = yield* historyController.value.register(environmentId, threadId, {753 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)),
789 });756 });
790 yield* Effect.addFinalizer(() => historyController.value.unregister(registration));757 yield* Effect.addFinalizer(() => historyController.value.unregister(registration));
791 }758 }
packages/contracts/src/environmentHttp.ts+2
...@@ -594,6 +594,8 @@ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({...@@ -594,6 +594,8 @@ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({
594594
595const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({595const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({
596 cursor: TrimmedNonEmptyString,596 cursor: TrimmedNonEmptyString,
597 throughEntryId: Schema.optional(TrimmedNonEmptyString),
598 view: Schema.optional(Schema.Literals(["conversation", "activity"])),
597});599});
598600
599const EnvironmentOrchestrationThreadHistoryErrors = [601const EnvironmentOrchestrationThreadHistoryErrors = [
packages/contracts/src/keybindings.test.ts+6
...@@ -101,6 +101,12 @@ it.effect("parses keybinding rules", () =>...@@ -101,6 +101,12 @@ it.effect("parses keybinding rules", () =>
101 });101 });
102 assert.strictEqual(parsedLocal.command, "chat.newLocal");102 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
104 const parsedModelPickerToggle = yield* decode(KeybindingRule, {110 const parsedModelPickerToggle = yield* decode(KeybindingRule, {
105 key: "mod+shift+m",111 key: "mod+shift+m",
106 command: "modelPicker.toggle",112 command: "modelPicker.toggle",
packages/contracts/src/keybindings.ts+1
...@@ -100,6 +100,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -100,6 +100,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
100 "chat.new",100 "chat.new",
101 "chat.newLocal",101 "chat.newLocal",
102 "chat.newWithoutProject",102 "chat.newWithoutProject",
103 "chat.find",
103 "editor.openFavorite",104 "editor.openFavorite",
104 "usage.cost",105 "usage.cost",
105 "usage.tokens",106 "usage.tokens",
packages/contracts/src/orchestrationV2.ts+62
...@@ -3138,6 +3138,8 @@ export const ORCHESTRATION_V2_WS_METHODS = {...@@ -3138,6 +3138,8 @@ export const ORCHESTRATION_V2_WS_METHODS = {
3138 getTurnDiff: "orchestration.getTurnDiff",3138 getTurnDiff: "orchestration.getTurnDiff",
3139 getFullThreadDiff: "orchestration.getFullThreadDiff",3139 getFullThreadDiff: "orchestration.getFullThreadDiff",
3140 searchThreads: "orchestration.searchThreads",3140 searchThreads: "orchestration.searchThreads",
3141 searchThread: "orchestration.searchThread",
3142 searchThreadStream: "orchestration.searchThreadStream",
3141 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",3143 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
3142 getThreadProjection: "orchestration.getThreadProjection",3144 getThreadProjection: "orchestration.getThreadProjection",
3143 getWorkflowScript: "orchestration.getWorkflowScript",3145 getWorkflowScript: "orchestration.getWorkflowScript",
...@@ -3498,7 +3500,67 @@ export class OrchestrationGetWorkflowScriptError extends Schema.TaggedError<Orch...@@ -3498,7 +3500,67 @@ export class OrchestrationGetWorkflowScriptError extends Schema.TaggedError<Orch
3498 }3500 }
3499}3501}
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
3501export const OrchestrationV2RpcSchemas = {3559export const OrchestrationV2RpcSchemas = {
3560 searchThread: {
3561 input: OrchestrationV2SearchThreadInput,
3562 output: OrchestrationV2SearchThreadResult,
3563 },
3502 dispatchCommand: {3564 dispatchCommand: {
3503 input: OrchestrationV2Command,3565 input: OrchestrationV2Command,
3504 output: OrchestrationV2DispatchCommandResult,3566 output: OrchestrationV2DispatchCommandResult,
packages/contracts/src/rpc.ts+23
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1import {
2 OrchestrationV2SearchThreadError,
3 OrchestrationV2SearchThreadInput,
4 OrchestrationV2SearchThreadResult,
5} from "./orchestrationV2.ts";
1import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts";6import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts";
2import {7import {
3 McpAppCallToolInput,8 McpAppCallToolInput,
...@@ -1565,6 +1570,22 @@ const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make(...@@ -1565,6 +1570,22 @@ const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make(
1565 },1570 },
1566);1571);
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
1568const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThreads, {1589const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThreads, {
1569 payload: OrchestrationSearchThreadsInput,1590 payload: OrchestrationSearchThreadsInput,
1570 success: OrchestrationSearchThreadsResult,1591 success: OrchestrationSearchThreadsResult,
...@@ -1957,6 +1978,8 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1957,6 +1978,8 @@ export const WsRpcGroup = RpcGroup.make(
1957 WsOrchestrationV2GetTurnDiffRpc,1978 WsOrchestrationV2GetTurnDiffRpc,
1958 WsOrchestrationV2GetFullThreadDiffRpc,1979 WsOrchestrationV2GetFullThreadDiffRpc,
1959 WsOrchestrationV2SearchThreadsRpc,1980 WsOrchestrationV2SearchThreadsRpc,
1981 WsOrchestrationV2SearchThreadRpc,
1982 WsOrchestrationV2SearchThreadStreamRpc,
1960 WsOrchestrationV2GetArchivedShellSnapshotRpc,1983 WsOrchestrationV2GetArchivedShellSnapshotRpc,
1961 WsOrchestrationV2GetThreadProjectionRpc,1984 WsOrchestrationV2GetThreadProjectionRpc,
1962 WsOrchestrationV2LaunchThreadRpc,1985 WsOrchestrationV2LaunchThreadRpc,
packages/contracts/src/server.ts+2
...@@ -669,6 +669,8 @@ export const ServerConfig = Schema.Struct({...@@ -669,6 +669,8 @@ export const ServerConfig = Schema.Struct({
669 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),669 threadSnapshotPagination: Schema.optionalKey(Schema.Boolean),
670 /** Whether thread reads accept the reasoningMessages opt-in. */670 /** Whether thread reads accept the reasoningMessages opt-in. */
671 reasoningMessages: Schema.optionalKey(Schema.Boolean),671 reasoningMessages: Schema.optionalKey(Schema.Boolean),
672 threadFind: Schema.optionalKey(Schema.Boolean),
673 threadFindProgressive: Schema.optionalKey(Schema.Boolean),
672 /**674 /**
673 * Folder behind this environment's Scratch project, for threads that need675 * Folder behind this environment's Scratch project, for threads that need
674 * no repository. Present only on servers that answer projects.ensureScratch676 * no repository. Present only on servers that answer projects.ensureScratch
packages/shared/package.json+59
...@@ -407,6 +407,54 @@...@@ -407,6 +407,54 @@
407 "types": "./src/codexAuthHandoff.ts",407 "types": "./src/codexAuthHandoff.ts",
408 "import": "./src/codexAuthHandoff.ts"408 "import": "./src/codexAuthHandoff.ts"
409 },409 },
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 },
410 "./mcpApp": {458 "./mcpApp": {
411 "types": "./src/mcpApp.ts",459 "types": "./src/mcpApp.ts",
412 "import": "./src/mcpApp.ts"460 "import": "./src/mcpApp.ts"
...@@ -422,12 +470,23 @@...@@ -422,12 +470,23 @@
422 "@t3tools/contracts": "workspace:*",470 "@t3tools/contracts": "workspace:*",
423 "effect": "catalog:",471 "effect": "catalog:",
424 "jose": "catalog:",472 "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:",
425 "yaml": "catalog:"483 "yaml": "catalog:"
426 },484 },
427 "devDependencies": {485 "devDependencies": {
428 "@effect/platform-node": "catalog:",486 "@effect/platform-node": "catalog:",
429 "@effect/vitest": "catalog:",487 "@effect/vitest": "catalog:",
430 "@types/node": "catalog:",488 "@types/node": "catalog:",
489 "micromark-util-types": "catalog:",
431 "vite-plus": "catalog:"490 "vite-plus": "catalog:"
432 }491 }
433}492}
packages/shared/src/assistantCitations.ts+1
...@@ -98,6 +98,7 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu...@@ -98,6 +98,7 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu
98 }98 }
99}99}
100100
101/** Visible chip text: the comment, else the quoted text, collapsed and capped. */
101export function assistantCitationLabel(citation: AssistantCitation): string {102export function assistantCitationLabel(citation: AssistantCitation): string {
102 const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " ");103 const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " ");
103 return preview.length > 64 ? `${preview.slice(0, 64)}…` : preview;104 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 {...@@ -24,8 +24,10 @@ export interface UpgradedLegacyContext {
24}24}
2525
26const PLACEHOLDER = "";26const PLACEHOLDER = "";
27const TRAILING_TERMINAL = /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/;27const TRAILING_TERMINAL =
28const TRAILING_ELEMENT = /\n*<element_context>\n([\s\S]*?)\n<\/element_context>\s*$/;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*$/;
29const TRAILING_PREVIEW =31const TRAILING_PREVIEW =
30 /\n*<preview_annotation>\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/;32 /\n*<preview_annotation>\n((?:(?!\n<\/preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/;
31const REVIEW_OR_CONTEXT_BLOCK =33const 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> = [...@@ -171,6 +171,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
171 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },171 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
172 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },172 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
173 { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" },173 { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" },
174 { key: "mod+f", command: "chat.find", when: "!terminalFocus && !previewFocus" },
174 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },175 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
175 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },176 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },
176 { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },177 { 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 @@...@@ -1,5 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import {2import {
3 fileBasename,
4 workspaceRelativeFilePath,
3 isExplicitRelativePath,5 isExplicitRelativePath,
4 isUncPath,6 isUncPath,
5 isWindowsAbsolutePath,7 isWindowsAbsolutePath,
...@@ -58,3 +60,40 @@ describe("path helpers", () => {...@@ -58,3 +60,40 @@ describe("path helpers", () => {
58 expect(newProjectFolderName("console")).toBe("console");60 expect(newProjectFolderName("console")).toBe("console");
59 });61 });
60});62});
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 {...@@ -74,3 +74,40 @@ export function newProjectFolderName(name: string): string {
74 if (slug.length === 0) return "project";74 if (slug.length === 0) return "project";
75 return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug;75 return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug;
76}76}
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 = [...@@ -88,6 +88,10 @@ export const THEME_COLOR_ROLES = [
88 "messageActionHover",88 "messageActionHover",
89 "codeBackground",89 "codeBackground",
90 "codeForeground",90 "codeForeground",
91 "searchMatchBackground",
92 "searchMatchForeground",
93 "searchMatchActiveBackground",
94 "searchMatchActiveForeground",
91 "sidebar",95 "sidebar",
92 "sidebarForeground",96 "sidebarForeground",
93 "sidebarMutedForeground",97 "sidebarMutedForeground",
...@@ -129,6 +133,10 @@ export type ThemeDefinition = Readonly<{...@@ -129,6 +133,10 @@ export type ThemeDefinition = Readonly<{
129 * colors are stored as opaque OKLCH tokens.133 * colors are stored as opaque OKLCH tokens.
130 */134 */
131export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {135export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
136 searchMatchBackground: "#fce8c0",
137 searchMatchForeground: "#27272a",
138 searchMatchActiveBackground: "#eea52b",
139 searchMatchActiveForeground: "#21180a",
132 canvas: "#fcfcfc",140 canvas: "#fcfcfc",
133 chrome: "#fcfcfc",141 chrome: "#fcfcfc",
134 toolbar: "#fcfcfc",142 toolbar: "#fcfcfc",
...@@ -189,6 +197,10 @@ export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {...@@ -189,6 +197,10 @@ export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = {
189};197};
190198
191export const T3_CODE_DARK_THEME_COLORS: ThemeColors = {199export const T3_CODE_DARK_THEME_COLORS: ThemeColors = {
200 searchMatchBackground: "#533e16",
201 searchMatchForeground: "#f5f5f5",
202 searchMatchActiveBackground: "#eea52b",
203 searchMatchActiveForeground: "#21180a",
192 canvas: "#0a0a0a",204 canvas: "#0a0a0a",
193 chrome: "#0a0a0a",205 chrome: "#0a0a0a",
194 toolbar: "#0a0a0a",206 toolbar: "#0a0a0a",
...@@ -296,6 +308,10 @@ export const T3_CHAT_THEME: ThemeDefinition = {...@@ -296,6 +308,10 @@ export const T3_CHAT_THEME: ThemeDefinition = {
296 messageActionHover: "oklch(0.539042 0.197866 0.305)",308 messageActionHover: "oklch(0.539042 0.197866 0.305)",
297 codeBackground: "oklch(0.953855 0.019695 315.668)",309 codeBackground: "oklch(0.953855 0.019695 315.668)",
298 codeForeground: "oklch(0.445128 0.13005 307.026)",310 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)",
299 sidebar: "oklch(0.928886 0.031178 322.592)",315 sidebar: "oklch(0.928886 0.031178 322.592)",
300 sidebarForeground: "oklch(0.396296 0.025134 285.196)",316 sidebarForeground: "oklch(0.396296 0.025134 285.196)",
301 sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)",317 sidebarMutedForeground: "oklch(0.494754 0.190937 354.544)",
...@@ -356,6 +372,10 @@ export const T3_CHAT_THEME: ThemeDefinition = {...@@ -356,6 +372,10 @@ export const T3_CHAT_THEME: ThemeDefinition = {
356 messageActionHover: "oklch(0.458754 0.184639 3.857)",372 messageActionHover: "oklch(0.458754 0.184639 3.857)",
357 codeBackground: "oklch(0.22813 0.020366 307.469)",373 codeBackground: "oklch(0.22813 0.020366 307.469)",
358 codeForeground: "oklch(0.848703 0.064239 306.645)",374 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)",
359 sidebar: "oklch(0.185778 0.019368 322.159)",379 sidebar: "oklch(0.185778 0.019368 322.159)",
360 sidebarForeground: "oklch(0.967434 0.001326 286.375)",380 sidebarForeground: "oklch(0.967434 0.001326 286.375)",
361 sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)",381 sidebarMutedForeground: "oklch(0.880303 0.03077 342.696)",
...@@ -423,6 +443,10 @@ export const GROVE_THEME: ThemeDefinition = {...@@ -423,6 +443,10 @@ export const GROVE_THEME: ThemeDefinition = {
423 messageActionHover: "oklch(0.488753 0.096536 77.829)",443 messageActionHover: "oklch(0.488753 0.096536 77.829)",
424 codeBackground: "oklch(0.955888 0.004783 158.391)",444 codeBackground: "oklch(0.955888 0.004783 158.391)",
425 codeForeground: "oklch(0.222003 0.03479 328.979)",445 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)",
426 sidebar: "oklch(0.936464 0.014601 163.554)",450 sidebar: "oklch(0.936464 0.014601 163.554)",
427 sidebarForeground: "oklch(0.222003 0.03479 328.979)",451 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
428 sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)",452 sidebarMutedForeground: "oklch(0.515606 0.011938 318.897)",
...@@ -483,6 +507,10 @@ export const GROVE_THEME: ThemeDefinition = {...@@ -483,6 +507,10 @@ export const GROVE_THEME: ThemeDefinition = {
483 messageActionHover: "oklch(0.815227 0.117902 84.21)",507 messageActionHover: "oklch(0.815227 0.117902 84.21)",
484 codeBackground: "oklch(0.312979 0.018942 164.082)",508 codeBackground: "oklch(0.312979 0.018942 164.082)",
485 codeForeground: "oklch(0.990339 0.008411 325.64)",509 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)",
486 sidebar: "oklch(0.309925 0.032827 160.944)",514 sidebar: "oklch(0.309925 0.032827 160.944)",
487 sidebarForeground: "oklch(0.990339 0.008411 325.64)",515 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
488 sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)",516 sidebarMutedForeground: "oklch(0.711387 0.007643 175.89)",
...@@ -550,6 +578,10 @@ export const OCEAN_THEME: ThemeDefinition = {...@@ -550,6 +578,10 @@ export const OCEAN_THEME: ThemeDefinition = {
550 messageActionHover: "oklch(0.45151 0.074407 201.516)",578 messageActionHover: "oklch(0.45151 0.074407 201.516)",
551 codeBackground: "oklch(0.957684 0.002906 253.68)",579 codeBackground: "oklch(0.957684 0.002906 253.68)",
552 codeForeground: "oklch(0.222003 0.03479 328.979)",580 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)",
553 sidebar: "oklch(0.939254 0.01193 241.729)",585 sidebar: "oklch(0.939254 0.01193 241.729)",
554 sidebarForeground: "oklch(0.222003 0.03479 328.979)",586 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
555 sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)",587 sidebarMutedForeground: "oklch(0.517366 0.018944 311.433)",
...@@ -610,6 +642,10 @@ export const OCEAN_THEME: ThemeDefinition = {...@@ -610,6 +642,10 @@ export const OCEAN_THEME: ThemeDefinition = {
610 messageActionHover: "oklch(0.815308 0.096174 199.862)",642 messageActionHover: "oklch(0.815308 0.096174 199.862)",
611 codeBackground: "oklch(0.29661 0.021883 251.968)",643 codeBackground: "oklch(0.29661 0.021883 251.968)",
612 codeForeground: "oklch(0.990339 0.008411 325.64)",644 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)",
613 sidebar: "oklch(0.290387 0.032043 247.274)",649 sidebar: "oklch(0.290387 0.032043 247.274)",
614 sidebarForeground: "oklch(0.990339 0.008411 325.64)",650 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
615 sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)",651 sidebarMutedForeground: "oklch(0.69099 0.01395 266.424)",
...@@ -677,6 +713,10 @@ export const EMBER_THEME: ThemeDefinition = {...@@ -677,6 +713,10 @@ export const EMBER_THEME: ThemeDefinition = {
677 messageActionHover: "oklch(0.471223 0.145843 24.688)",713 messageActionHover: "oklch(0.471223 0.145843 24.688)",
678 codeBackground: "oklch(0.959965 0.002668 47.512)",714 codeBackground: "oklch(0.959965 0.002668 47.512)",
679 codeForeground: "oklch(0.222003 0.03479 328.979)",715 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)",
680 sidebar: "oklch(0.942267 0.01151 50.785)",720 sidebar: "oklch(0.942267 0.01151 50.785)",
681 sidebarForeground: "oklch(0.222003 0.03479 328.979)",721 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
682 sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)",722 sidebarMutedForeground: "oklch(0.519146 0.019214 343.427)",
...@@ -737,6 +777,10 @@ export const EMBER_THEME: ThemeDefinition = {...@@ -737,6 +777,10 @@ export const EMBER_THEME: ThemeDefinition = {
737 messageActionHover: "oklch(0.775116 0.117953 29.014)",777 messageActionHover: "oklch(0.775116 0.117953 29.014)",
738 codeBackground: "oklch(0.299662 0.017229 39.973)",778 codeBackground: "oklch(0.299662 0.017229 39.973)",
739 codeForeground: "oklch(0.990339 0.008411 325.64)",779 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)",
740 sidebar: "oklch(0.293349 0.029554 46.882)",784 sidebar: "oklch(0.293349 0.029554 46.882)",
741 sidebarForeground: "oklch(0.990339 0.008411 325.64)",785 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
742 sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)",786 sidebarMutedForeground: "oklch(0.691874 0.012538 24.638)",
...@@ -804,6 +848,10 @@ export const IRIS_THEME: ThemeDefinition = {...@@ -804,6 +848,10 @@ export const IRIS_THEME: ThemeDefinition = {
804 messageActionHover: "oklch(0.471003 0.16748 340.687)",848 messageActionHover: "oklch(0.471003 0.16748 340.687)",
805 codeBackground: "oklch(0.95997 0.004338 306.542)",849 codeBackground: "oklch(0.95997 0.004338 306.542)",
806 codeForeground: "oklch(0.222003 0.03479 328.979)",850 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)",
807 sidebar: "oklch(0.941387 0.014687 300.474)",855 sidebar: "oklch(0.941387 0.014687 300.474)",
808 sidebarForeground: "oklch(0.222003 0.03479 328.979)",856 sidebarForeground: "oklch(0.222003 0.03479 328.979)",
809 sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)",857 sidebarMutedForeground: "oklch(0.518417 0.023683 320.681)",
...@@ -864,6 +912,10 @@ export const IRIS_THEME: ThemeDefinition = {...@@ -864,6 +912,10 @@ export const IRIS_THEME: ThemeDefinition = {
864 messageActionHover: "oklch(0.813537 0.114101 337.23)",912 messageActionHover: "oklch(0.813537 0.114101 337.23)",
865 codeBackground: "oklch(0.281873 0.028308 295.193)",913 codeBackground: "oklch(0.281873 0.028308 295.193)",
866 codeForeground: "oklch(0.990339 0.008411 325.64)",914 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)",
867 sidebar: "oklch(0.266743 0.044689 294.138)",919 sidebar: "oklch(0.266743 0.044689 294.138)",
868 sidebarForeground: "oklch(0.990339 0.008411 325.64)",920 sidebarForeground: "oklch(0.990339 0.008411 325.64)",
869 sidebarMutedForeground: "oklch(0.668773 0.021522 302.949)",921 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:...@@ -30,9 +30,42 @@ catalogs:
30 jose:30 jose:
31 specifier: 6.2.231 specifier: 6.2.2
32 version: 6.2.232 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
33 typescript:63 typescript:
34 specifier: 7.0.264 specifier: 7.0.2
35 version: 7.0.265 version: 7.0.2
66 unified:
67 specifier: 11.0.5
68 version: 11.0.5
3669
37overrides:70overrides:
38 '@exodus/bytes>@noble/hashes': 1.8.071 '@exodus/bytes>@noble/hashes': 1.8.0
...@@ -777,17 +810,8 @@ importers:...@@ -777,17 +810,8 @@ importers:
777 react-markdown:810 react-markdown:
778 specifier: ^10.1.0811 specifier: ^10.1.0
779 version: 10.1.0(@types/react@19.3.0)(react@19.2.6)812 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
789 remark-gfm:813 remark-gfm:
790 specifier: ^4.0.1814 specifier: 'catalog:'
791 version: 4.0.1815 version: 4.0.1
792 tailwind-merge:816 tailwind-merge:
793 specifier: ^3.4.0817 specifier: ^3.4.0
...@@ -857,7 +881,7 @@ importers:...@@ -857,7 +881,7 @@ importers:
857 specifier: 4.3.3881 specifier: 4.3.3
858 version: 4.3.3882 version: 4.3.3
859 unified:883 unified:
860 specifier: ^11.0.5884 specifier: 'catalog:'
861 version: 11.0.5885 version: 11.0.5
862 vite:886 vite:
863 specifier: npm:@voidzero-dev/vite-plus-core@1.0.0887 specifier: npm:@voidzero-dev/vite-plus-core@1.0.0
...@@ -957,24 +981,9 @@ importers:...@@ -957,24 +981,9 @@ importers:
957 effect:981 effect:
958 specifier: 4.0.1982 specifier: 4.0.1
959 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f)983 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
972 three:984 three:
973 specifier: 0.180.0985 specifier: 0.180.0
974 version: 0.180.0986 version: 0.180.0
975 unified:
976 specifier: ^11.0.5
977 version: 11.0.5
978 devDependencies:987 devDependencies:
979 '@effect/vitest':988 '@effect/vitest':
980 specifier: 4.0.1989 specifier: 4.0.1
...@@ -982,9 +991,6 @@ importers:...@@ -982,9 +991,6 @@ importers:
982 '@types/three':991 '@types/three':
983 specifier: 0.180.0992 specifier: 0.180.0
984 version: 0.180.0993 version: 0.180.0
985 micromark-util-types:
986 specifier: ^2.0.2
987 version: 2.0.2
988 vite-plus:994 vite-plus:
989 specifier: 1.0.0995 specifier: 1.0.0
990 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)996 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:...@@ -1063,6 +1069,36 @@ importers:
1063 jose:1069 jose:
1064 specifier: 'catalog:'1070 specifier: 'catalog:'
1065 version: 6.2.21071 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
1066 yaml:1102 yaml:
1067 specifier: ^2.9.01103 specifier: ^2.9.0
1068 version: 2.9.01104 version: 2.9.0
...@@ -1076,6 +1112,9 @@ importers:...@@ -1076,6 +1112,9 @@ importers:
1076 '@types/node':1112 '@types/node':
1077 specifier: 24.12.41113 specifier: 24.12.4
1078 version: 24.12.41114 version: 24.12.4
1115 micromark-util-types:
1116 specifier: 'catalog:'
1117 version: 2.0.2
1079 vite-plus:1118 vite-plus:
1080 specifier: 1.0.01119 specifier: 1.0.0
1081 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)1120 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:...@@ -19004,7 +19043,7 @@ snapshots:
1900419043
19005 hast-util-raw@9.1.0:19044 hast-util-raw@9.1.0:
19006 dependencies:19045 dependencies:
19007 '@types/hast': 3.0.419046 '@types/hast': 3.0.5
19008 '@types/unist': 3.0.319047 '@types/unist': 3.0.3
19009 '@ungap/structured-clone': 1.3.119048 '@ungap/structured-clone': 1.3.1
19010 hast-util-from-parse5: 8.0.319049 hast-util-from-parse5: 8.0.3
...@@ -19020,7 +19059,7 @@ snapshots:...@@ -19020,7 +19059,7 @@ snapshots:
1902019059
19021 hast-util-sanitize@5.0.2:19060 hast-util-sanitize@5.0.2:
19022 dependencies:19061 dependencies:
19023 '@types/hast': 3.0.419062 '@types/hast': 3.0.5
19024 '@ungap/structured-clone': 1.3.119063 '@ungap/structured-clone': 1.3.1
19025 unist-util-position: 5.0.019064 unist-util-position: 5.0.0
1902619065
...@@ -21445,13 +21484,13 @@ snapshots:...@@ -21445,13 +21484,13 @@ snapshots:
2144521484
21446 rehype-raw@7.0.0:21485 rehype-raw@7.0.0:
21447 dependencies:21486 dependencies:
21448 '@types/hast': 3.0.421487 '@types/hast': 3.0.5
21449 hast-util-raw: 9.1.021488 hast-util-raw: 9.1.0
21450 vfile: 6.0.321489 vfile: 6.0.3
2145121490
21452 rehype-sanitize@6.0.0:21491 rehype-sanitize@6.0.0:
21453 dependencies:21492 dependencies:
21454 '@types/hast': 3.0.421493 '@types/hast': 3.0.5
21455 hast-util-sanitize: 5.0.221494 hast-util-sanitize: 5.0.2
2145621495
21457 remark-breaks@4.0.0:21496 remark-breaks@4.0.0:
...@@ -21482,7 +21521,7 @@ snapshots:...@@ -21482,7 +21521,7 @@ snapshots:
2148221521
21483 remark-rehype@11.1.2:21522 remark-rehype@11.1.2:
21484 dependencies:21523 dependencies:
21485 '@types/hast': 3.0.421524 '@types/hast': 3.0.5
21486 '@types/mdast': 4.0.421525 '@types/mdast': 4.0.4
21487 mdast-util-to-hast: 13.2.121526 mdast-util-to-hast: 13.2.1
21488 unified: 11.0.521527 unified: 11.0.5
pnpm-workspace.yaml+13
...@@ -56,6 +56,19 @@ catalog:...@@ -56,6 +56,19 @@ catalog:
56 vite-plus: 1.0.056 vite-plus: 1.0.0
57 ws: 8.22.057 ws: 8.22.0
58 yaml: ^2.9.058 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
60minimumReleaseAgeExclude:73minimumReleaseAgeExclude:
61 - expo-glass-effect@58.0.374 - expo-glass-effect@58.0.3