| author | |
| committer | |
| log | 12069eefd707f78eafc27812027c994eea0613cf |
| tree | 7fe8591a6067391ce75cd079ed9f74d7698a47a7 |
| parent | a4c9494b0e3606775cc5fc929fc138399288bd43 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { fileBasename } from "@t3tools/shared/path"; | ||
| 2 | import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; | ||
| 1 | import { | 3 | import { |
| 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"; |
| 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; | 8 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; |
| 9 | import { videoMimeType } from "@t3tools/shared/video"; | 9 | import { videoMimeType } from "@t3tools/shared/video"; |
| 10 | 10 |
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; | 2 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; |
| 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 4 | import { imageMimeType } from "@t3tools/shared/image"; | 4 | import { imageMimeType } from "@t3tools/shared/image"; |
| 5 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 5 | import { 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 { |
| 29 | import { | 29 | import { |
| 30 | appendCodexArtifactTemplateUsePrompt, | 30 | appendCodexArtifactTemplateUsePrompt, |
| 31 | type CodexArtifactTemplate, | 31 | type CodexArtifactTemplate, |
| 32 | } from "@t3tools/client-runtime/codex-artifact-templates"; | 32 | } from "@t3tools/shared/codexArtifactTemplates"; |
| 33 | import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; | 33 | import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; |
| 34 | import { | 34 | import { |
| 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 |
| 36 | import { | 36 | import { |
| 37 | codexArtifactTemplatePresentationLabel, | 37 | codexArtifactTemplatePresentationLabel, |
| 38 | type CodexArtifactTemplate, | 38 | type CodexArtifactTemplate, |
| 39 | } from "@t3tools/client-runtime/codex-artifact-templates"; | 39 | } from "@t3tools/shared/codexArtifactTemplates"; |
| 40 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 40 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 41 | import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; | 41 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; |
| 42 | import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless"; | 42 | import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 43 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 43 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 44 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 44 | import { 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 |
| 50 | import { | 50 | import { |
| 51 | renderCodexFileCitationsAsMarkdown, | 51 | renderCodexFileCitationsAsMarkdown, |
| 52 | splitCodexArtifactTemplateMarkdown, | 52 | splitCodexArtifactTemplateMarkdown, |
| 53 | } from "@t3tools/client-runtime/codex-markdown-directives"; | 53 | } from "@t3tools/shared/codexMarkdownDirectives"; |
| 54 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 54 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 55 | import { imageMimeType } from "@t3tools/shared/image"; | 55 | import { imageMimeType } from "@t3tools/shared/image"; |
| 56 | import { videoMimeType } from "@t3tools/shared/video"; | 56 | import { videoMimeType } from "@t3tools/shared/video"; |
apps/mobile/src/features/threads/fileChipMenu.ts+1-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { fileBasename } from "@t3tools/client-runtime/markdown-links"; | 1 | import { fileBasename } from "@t3tools/shared/path"; |
| 2 | import type { ThreadId } from "@t3tools/contracts"; | 2 | import type { ThreadId } from "@t3tools/contracts"; |
| 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | 3 | import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; |
| 4 | import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; | 4 | import 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 @@ |
| 1 | import { splitThreadSearchText } from "@t3tools/shared/threadSearch"; | ||
| 1 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; | 2 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 2 | 3 | ||
| 3 | import { AppText as Text } from "../../components/AppText"; | 4 | import { AppText as Text } from "../../components/AppText"; |
| 4 | import { cn } from "../../lib/cn"; | 5 | import { cn } from "../../lib/cn"; |
| 5 | 6 | ||
| 6 | function foldAsciiCase(value: string): string { | ||
| 7 | return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); | ||
| 8 | } | ||
| 9 | |||
| 10 | function 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 | |||
| 46 | export function ThreadSearchMatchExcerpt(props: { | 7 | export 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 | <Text | 17 | <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 @@ |
| 1 | import type { | ||
| 2 | OrchestrationV2SearchThreadInput, | ||
| 3 | OrchestrationV2SearchThreadResult, | ||
| 4 | OrchestrationV2ThreadHistoryPage, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 1 | import { | 6 | import { |
| 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 | projectionStore | 10858 | 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 @@ |
| 1 | import { projectTurnItemForWire } from "./WireProjection.ts"; | ||
| 2 | import * as Stream from "effect/Stream"; | ||
| 3 | import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts"; | ||
| 4 | import type { | ||
| 5 | OrchestrationV2SearchThreadInput, | ||
| 6 | OrchestrationV2SearchThreadResult, | ||
| 7 | OrchestrationV2ThreadHistoryPage, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 1 | import { | 9 | import { |
| 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"; |
| 33 | import { | 40 | import { |
| 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"; |
| 74 | import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; | 82 | import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; |
| 75 | import { | 83 | import { |
| 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"; |
| 80 | 93 | ||
| 81 | export class ProjectionStoreApplyEventError extends Schema.TaggedError<ProjectionStoreApplyEventError>()( | 94 | export class ProjectionStoreApplyEventError extends Schema.TaggedError<ProjectionStoreApplyEventError>()( |
| ... | @@ -318,9 +331,21 @@ export interface ShellSnapshotOptions { | ... | @@ -318,9 +331,21 @@ export interface ShellSnapshotOptions { |
| 318 | } | 331 | } |
| 319 | 332 | ||
| 320 | export interface ProjectionStoreV2Shape { | 333 | export 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 | }; |
| 971 | 996 | ||
| 997 | const encodeTimelineSources = Schema.encodeEffect( | ||
| 998 | Schema.fromJsonString( | ||
| 999 | Schema.Array(Schema.Struct({ threadId: Schema.String, id: Schema.String })), | ||
| 1000 | ), | ||
| 1001 | ); | ||
| 972 | const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); | 1002 | const encodeIdList = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); |
| 973 | 1003 | ||
| 974 | const encodeThreadPayload = Schema.encodeEffect( | 1004 | const 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 | ); |
| 4707 | 4737 | ||
| 4708 | // Only the timeline index is assembled across fork ancestry. Payloads are read | 4738 | // 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_intent | 4780 | CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent, |
| 4781 | CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by, | ||
| 4782 | CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id | ||
| 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 | ); |
| 4821 | 4862 | ||
| 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 | sql | 4974 | 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 @@ | ||
| 1 | import { assert, it, describe } from "@effect/vitest"; | ||
| 2 | import { | ||
| 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"; | ||
| 18 | import * as DateTime from "effect/DateTime"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 22 | import * as Stream from "effect/Stream"; | ||
| 23 | import { vi } from "vite-plus/test"; | ||
| 24 | import * as ThreadFindText from "@t3tools/shared/threadFindText"; | ||
| 25 | |||
| 26 | import * as SqlitePersistence from "../persistence/Sqlite.ts"; | ||
| 27 | import * as ProjectionStore from "./ProjectionStore.ts"; | ||
| 28 | import * as ProjectStore from "./ProjectStore.ts"; | ||
| 29 | import * as EventStore from "./EventStore.ts"; | ||
| 30 | import { encodeThreadHistoryCursor, selectHistoryPageFromCursor } from "./threadHistoryPaging.ts"; | ||
| 31 | import { projectTurnItemForWire } from "./WireProjection.ts"; | ||
| 32 | |||
| 33 | const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe( | ||
| 34 | Layer.provideMerge(SqlitePersistence.layerMemory), | ||
| 35 | ); | ||
| 36 | |||
| 37 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 38 | const at = (minute: number) => DateTime.makeUnsafe(Date.UTC(2026, 8, 27, 0, minute)); | ||
| 39 | |||
| 40 | const 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 | |||
| 65 | const 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 | |||
| 100 | const threadId = ThreadId.make("thread:find"); | ||
| 101 | const projectId = ProjectId.make("project:find"); | ||
| 102 | const runId = RunId.make("run:find"); | ||
| 103 | |||
| 104 | function 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 | |||
| 147 | function 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 | |||
| 178 | const 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 | }); | ||
| 186 | const 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 | ); | ||
| 196 | const setup = Effect.gen(function* () { | ||
| 197 | yield* createProject(projectId); | ||
| 198 | yield* commit([thread(threadId, projectId), run()]); | ||
| 199 | return yield* ProjectionStore.ProjectionStoreV2; | ||
| 200 | }); | ||
| 201 | |||
| 202 | describe("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 | |||
| 851 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | OrchestrationV2TurnItemJson, | ||
| 3 | type OrchestrationV2ProjectedTurnItem, | ||
| 4 | OrchestrationV2SearchThreadInput, | ||
| 5 | type OrchestrationV2SearchThreadResult, | ||
| 6 | type OrchestrationV2TurnItem, | ||
| 7 | ThreadId, | ||
| 8 | TurnItemId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText"; | ||
| 11 | import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; | ||
| 12 | import * as Cache from "effect/Cache"; | ||
| 13 | import * as Data from "effect/Data"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 18 | import { | ||
| 19 | ProjectionStoreReadError, | ||
| 20 | ProjectionStoreThreadNotFoundError, | ||
| 21 | type ProjectionStoreV2Error, | ||
| 22 | } from "./ProjectionStore.ts"; | ||
| 23 | |||
| 24 | interface FindRow { | ||
| 25 | readonly position: number; | ||
| 26 | readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"]; | ||
| 27 | readonly sourceThreadId: ThreadId; | ||
| 28 | readonly sourceItemId: TurnItemId; | ||
| 29 | } | ||
| 30 | interface FindPayload extends FindRow { | ||
| 31 | readonly payload: string; | ||
| 32 | readonly entryId: string; | ||
| 33 | readonly type: OrchestrationV2TurnItem["type"]; | ||
| 34 | } | ||
| 35 | interface FindDocument extends FindRow { | ||
| 36 | readonly entryId: string; | ||
| 37 | readonly runId: OrchestrationV2TurnItem["runId"]; | ||
| 38 | readonly count: number; | ||
| 39 | } | ||
| 40 | class 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 | |||
| 49 | function 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 | |||
| 70 | function 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 | |||
| 85 | function 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 | |||
| 100 | function 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 | |||
| 115 | function countSegments(segments: readonly string[], query: string) { | ||
| 116 | return segments.reduce((sum, text) => sum + countThreadSearchOccurrences(text, query), 0); | ||
| 117 | } | ||
| 118 | |||
| 119 | function 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. */ | ||
| 147 | export 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 | |||
| 163 | function 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. */ | ||
| 199 | export 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 @@ |
| 1 | import type { | ||
| 2 | OrchestrationV2SearchThreadInput, | ||
| 3 | OrchestrationV2SearchThreadResult, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 1 | import type { | 5 | import 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; |
| 271 | type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; | 275 | type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; |
| 272 | 276 | ||
| 273 | export interface ThreadManagementServiceShape { | 277 | export 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* () { |
| 859 | 870 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 8 | import * as Effect from "effect/Effect"; | 6 | import * 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 | } |
| 45 | 42 | ||
| 46 | function 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 the | 44 | * Serves orchestration V2 snapshots over HTTP so clients can load the |
| 64 | * (potentially large) shell and thread projections off the socket; gzip | 45 | * (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; |
| 18 | 18 | ||
| 19 | export const OLDER_THREAD_USER_TURN_LIMIT = 20; | 19 | export const OLDER_THREAD_USER_TURN_LIMIT = 20; |
| 20 | |||
| 21 | /** Load conversation rows before the bulk of a turn's folded tool activity. */ | ||
| 22 | export function isConversationHistoryItem(item: Pick<OrchestrationV2TurnItem, "type">): boolean { | ||
| 23 | return !["command_execution", "reasoning", "file_change", "dynamic_tool"].includes(item.type); | ||
| 24 | } | ||
| 20 | export const THREAD_HISTORY_MAX_RAW_TURNS = 150; | 25 | export const THREAD_HISTORY_MAX_RAW_TURNS = 150; |
| 21 | 26 | ||
| 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 | }; |
| 41 | 46 | ||
| 42 | export type SelectTimelinePageResult = { | 47 | type 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 | }; | ||
| 58 | export 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 | } |
| 68 | 84 | ||
| 69 | /** Ordinary history-page cost: one projected row (item is nested once). */ | 85 | /** Ordinary history-page cost: one projected row (item is nested once). */ |
| 70 | export function projectedRowEncodedBytes(row: OrchestrationV2ProjectedTurnItem): number { | 86 | export function projectedRowEncodedBytes(row: HistoryRow): number { |
| 71 | return bytesOfJson(row); | 87 | return bytesOfJson(row); |
| 72 | } | 88 | } |
| 73 | 89 | ||
| ... | @@ -87,9 +103,7 @@ export function projectedRowBoundedSnapshotEncodedBytes( | ... | @@ -87,9 +103,7 @@ export function projectedRowBoundedSnapshotEncodedBytes( |
| 87 | return rowBytes; | 103 | return rowBytes; |
| 88 | } | 104 | } |
| 89 | 105 | ||
| 90 | function renumberPositions( | 106 | function 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 | } |
| 95 | 109 | ||
| ... | @@ -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 | } |
| 150 | 164 | ||
| 151 | export function isThreadHistoryTurnStart(item: OrchestrationV2TurnItem): boolean { | 165 | export 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 | } |
| 157 | 171 | ||
| 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. */ |
| 159 | export function isThreadHistoryUserTurn(item: OrchestrationV2TurnItem): boolean { | 173 | export 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 budgets | 181 | * 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 | */ |
| 170 | function selectOlderTimelinePage(input: { | 184 | function 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 | } |
| 183 | 197 | ||
| 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 | } |
| 248 | 262 | ||
| 249 | function findCursorIndex( | 263 | function 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 | } |
| 266 | 280 | ||
| 267 | export function selectHistoryPageFromCursor(input: { | 281 | export 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 | } |
| 285 | 325 |
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 jsdom | 1 | // @vitest-environment jsdom |
| 2 | 2 | ||
| 3 | import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; | 3 | import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; |
| 4 | import { createRoot } from "react-dom/client"; | ||
| 5 | import { useThreadFindHighlights } from "./chat/threadFindHighlights"; | ||
| 6 | import { searchableMessageSegments } from "@t3tools/shared/threadFindText"; | ||
| 7 | import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; | ||
| 8 | |||
| 9 | import { MarkdownFindContext } from "./chat/markdownFindContext"; | ||
| 4 | import { act, type ComponentProps, type ReactNode } from "react"; | 10 | import { act, type ComponentProps, type ReactNode } from "react"; |
| 5 | import { renderToStaticMarkup } from "react-dom/server"; | 11 | import { renderToStaticMarkup } from "react-dom/server"; |
| 6 | import { create, type ReactTestRenderer } from "react-test-renderer"; | 12 | import { 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"; |
| 12 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; | 18 | import { setMarkdownTaskChecked } from "./files/filePreviewMode"; |
| 13 | 19 | ||
| 14 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 20 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 21 | vi.mock("./chat/MermaidDiagram", () => ({ | ||
| 22 | // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. | ||
| 23 | MermaidDiagram: () => <svg aria-label="Diagram" />, | ||
| 24 | })); | ||
| 15 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | 25 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 16 | vi.mock("../hooks/useSettings", async (importOriginal) => { | 26 | vi.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 | |||
| 1048 | it("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 | |||
| 1127 | it("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 | |||
| 1183 | it.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 @@ |
| 1 | import { MarkdownFindContext, useFindRevealRef } from "./chat/markdownFindContext"; | ||
| 2 | import { | ||
| 3 | buildFileLinkParentSuffixByPath, | ||
| 4 | fileLinkLabel, | ||
| 5 | resolvePathLinkTarget, | ||
| 6 | } from "@t3tools/shared/fileLinks"; | ||
| 7 | import { | ||
| 8 | isWindowsDrivePathHref, | ||
| 9 | normalizeMarkdownLinkDestination, | ||
| 10 | extractInlineCodeSpans, | ||
| 11 | extractMarkdownLinkHrefs, | ||
| 12 | inlineCodeFilePathCandidate, | ||
| 13 | } from "@t3tools/shared/markdownLinks"; | ||
| 14 | import { isAbsolutePath } from "@t3tools/shared/path"; | ||
| 1 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | 15 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 2 | import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; | 16 | import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; |
| 17 | import { | ||
| 18 | CHAT_MARKDOWN_REMARK_PLUGINS, | ||
| 19 | CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, | ||
| 20 | CHAT_MARKDOWN_REHYPE_PLUGINS, | ||
| 21 | } from "@t3tools/shared/markdownPipeline"; | ||
| 3 | import { useAtomValue } from "@effect/atom-react"; | 22 | import { useAtomValue } from "@effect/atom-react"; |
| 4 | import { | 23 | import { |
| 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"; |
| 50 | import { | 69 | import { |
| 51 | classifyMarkdownImageSource, | 70 | classifyMarkdownImageSource, |
| 52 | markdownImageSourceFragment, | 71 | markdownImageSourceFragment, |
| 53 | } from "@t3tools/client-runtime/markdown-images"; | 72 | } from "@t3tools/client-runtime/markdown-images"; |
| 54 | import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; | ||
| 55 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; | 73 | import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; |
| 56 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; | 74 | import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; |
| 57 | import * as Cause from "effect/Cause"; | 75 | import * as Cause from "effect/Cause"; |
| ... | @@ -83,24 +101,16 @@ import ReactMarkdown from "react-markdown"; | ... | @@ -83,24 +101,16 @@ import ReactMarkdown from "react-markdown"; |
| 83 | import { toHtml } from "hast-util-to-html"; | 101 | import { toHtml } from "hast-util-to-html"; |
| 84 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; | 102 | import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; |
| 85 | import { defaultUrlTransform } from "react-markdown"; | 103 | import { defaultUrlTransform } from "react-markdown"; |
| 86 | import rehypeRaw from "rehype-raw"; | ||
| 87 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | ||
| 88 | import remarkBreaks from "remark-breaks"; | ||
| 89 | import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; | 104 | import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; |
| 90 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; | 105 | import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; |
| 91 | import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; | 106 | import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; |
| 92 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; | 107 | import { AssistantCitationChip } from "./chat/AssistantCitationChip"; |
| 93 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; | 108 | import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; |
| 94 | import remarkGfm from "remark-gfm"; | ||
| 95 | import type { Processor } from "unified"; | ||
| 96 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 109 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 97 | import { remarkGithubAlerts } from "../markdown-github-alerts"; | ||
| 98 | import { | 110 | import { |
| 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"; |
| 104 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; | 114 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 105 | import { | 115 | import { |
| 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"; |
| 156 | import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | ||
| 157 | import { | 166 | import { |
| 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"; |
| 168 | import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; | 174 | import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; |
| 169 | import { readLocalApi } from "../localApi"; | 175 | import { readLocalApi } from "../localApi"; |
| 170 | import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; | 176 | import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; |
| 171 | import { cn } from "../lib/utils"; | 177 | import { cn } from "../lib/utils"; |
| ... | @@ -195,7 +201,6 @@ import { | ... | @@ -195,7 +201,6 @@ import { |
| 195 | import { useOpenLink } from "../browser/useOpenLink"; | 201 | import { useOpenLink } from "../browser/useOpenLink"; |
| 196 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; | 202 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; |
| 197 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; | 203 | import { isPreviewAvailableFor } from "../browser/previewRuntime"; |
| 198 | import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; | ||
| 199 | import { | 204 | import { |
| 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 | <Button | 339 | <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 | } |
| 398 | 405 | ||
| 399 | type MarkdownImageHastNode = { | ||
| 400 | type?: string; | ||
| 401 | value?: string; | ||
| 402 | tagName?: string; | ||
| 403 | properties?: Record<string, unknown>; | ||
| 404 | children?: MarkdownImageHastNode[]; | ||
| 405 | }; | ||
| 406 | |||
| 407 | function 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. */ | ||
| 421 | const STANDALONE_IMAGE_BLOCKS = new Set([ | ||
| 422 | "p", | ||
| 423 | "div", | ||
| 424 | "li", | ||
| 425 | "td", | ||
| 426 | "th", | ||
| 427 | "figure", | ||
| 428 | "center", | ||
| 429 | "blockquote", | ||
| 430 | ]); | ||
| 431 | |||
| 432 | function 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 | |||
| 445 | function 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. */ | ||
| 457 | function 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. */ | ||
| 505 | function 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 | |||
| 525 | const 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 | |||
| 554 | const CHAT_MARKDOWN_REMARK_PLUGINS = [ | ||
| 555 | remarkGfm, | ||
| 556 | remarkKeepWindowsPathDestinations, | ||
| 557 | remarkGithubAlerts, | ||
| 558 | remarkNormalizeListItemIndentation, | ||
| 559 | remarkCodexDirectives, | ||
| 560 | remarkPreserveCodeMeta, | ||
| 561 | remarkNormalizeLinksAndTagInlineCode, | ||
| 562 | ] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>; | ||
| 563 | |||
| 564 | const 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 | |||
| 575 | const 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. */ | ||
| 583 | const GITHUB_ALERT_PRESENTATIONS: Record< | 406 | const 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 | }; |
| 682 | 505 | ||
| 683 | function 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 | |||
| 702 | interface DestinationCompileContext { | ||
| 703 | readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>; | ||
| 704 | resume(): string; | ||
| 705 | sliceSerialize(token: unknown): string; | ||
| 706 | } | ||
| 707 | |||
| 708 | function 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 | */ | ||
| 722 | function 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 | */ | ||
| 737 | function 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 | |||
| 764 | function nodeToPlainText(node: ReactNode): string { | 506 | function 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 | <Collapsible | 711 | <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 | <CollapsibleTrigger | 717 | <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 | <div | 728 | <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. The | 743 | * 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 | */ |
| 746 | const THREAD_FIND_IGNORE_PROPS = { | ||
| 747 | "data-thread-find-ignore": "", | ||
| 748 | } as React.HTMLAttributes<HTMLDivElement>; | ||
| 749 | |||
| 998 | export function MarkdownCodeBlockFrame({ | 750 | export 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 | children | 1083 | children |
| ... | @@ -1454,79 +1217,6 @@ interface MarkdownFileLinkProps { | ... | @@ -1454,79 +1217,6 @@ interface MarkdownFileLinkProps { |
| 1454 | 1217 | ||
| 1455 | const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; | 1218 | const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; |
| 1456 | 1219 | ||
| 1457 | function 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 | |||
| 1463 | function 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 | |||
| 1515 | const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; | ||
| 1516 | const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; | ||
| 1517 | |||
| 1518 | function 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 | |||
| 1530 | function normalizeMarkdownLinkHrefKey(href: string): string { | 1220 | function 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"; |
| 27 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 27 | import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; |
| 28 | import * as DateTime from "effect/DateTime"; | 28 | import * as DateTime from "effect/DateTime"; |
| 29 | import * as Option from "effect/Option"; | 29 | import * as Option from "effect/Option"; |
| 30 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 30 | import { 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"; |
| 42 | import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; | 42 | import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 43 | import { | 43 | import { |
| 44 | type ChatMessage, | 44 | type ChatMessage, |
apps/web/src/components/ChatView.tsx+71-13| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { ChatCanvas } from "./chat/ChatCanvas"; | 1 | import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider"; |
| 2 | import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; | ||
| 2 | import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; | 3 | import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; |
| 3 | import { | 4 | import { |
| 4 | resolveBackgroundDraftWorkspaceOptions, | 5 | resolveBackgroundDraftWorkspaceOptions, |
| ... | @@ -608,7 +609,7 @@ import { | ... | @@ -608,7 +609,7 @@ import { |
| 608 | const EMPTY_PROVIDERS: ServerProvider[] = []; | 609 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 609 | const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; | 610 | const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; |
| 610 | const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; | 611 | const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; |
| 611 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 612 | import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; |
| 612 | 613 | ||
| 613 | const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); | 614 | const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); |
| 614 | const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = []; | 615 | const 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]); | ||
| 7934 | 7950 | ||
| 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 | } |
| 8031 | 8049 | ||
| 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 | <ChatCanvas | 11290 | <ThreadFindCanvas |
| 11291 | findOptions={{ | ||
| 11292 | skills: timelineSkills, | ||
| 11293 | progressive: serverConfig?.threadFindProgressive === true, | ||
| 11294 | thread: activeThreadRef, | ||
| 11295 | enabled: | ||
| 11296 | isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline, | ||
| 11297 | content: serverProjection ?? undefined, | ||
| 11298 | }} | ||
| 11299 | controlsRef={threadFindControlsRef} | ||
| 11300 | onOpenChange={setIsThreadFindActive} | ||
| 11301 | detailsCardTopInset={isThreadFindActive ? THREAD_FIND_BAR_RESERVED_HEIGHT : 0} | ||
| 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 | <div | 11311 | <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 | : activeWorkspaceRoot | 11430 | : 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 | /> |
| 11391 | 11449 | ||
| 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 | <div | 11452 | <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"; |
| 71 | import { requestThreadFindOpen } from "./chat/threadFindActionBus"; | ||
| 71 | import { | 72 | import { |
| 72 | useCallback, | 73 | useCallback, |
| 73 | useDeferredValue, | 74 | useDeferredValue, |
| ... | @@ -1139,6 +1140,9 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1139,6 +1140,9 @@ function OpenCommandPaletteDialog(props: { |
| 1139 | ); | 1140 | ); |
| 1140 | 1141 | ||
| 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 | } |
| 1931 | 1935 | ||
| 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 | } | ||
| 3096 | 3123 | ||
| 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 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 1 | import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; | 2 | import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; |
| 2 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; | 3 | import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; |
| 3 | import StarterKit from "@tiptap/starter-kit"; | 4 | import 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"; |
| 104 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | 105 | import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; |
| 105 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; | ||
| 106 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 106 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 107 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; | 107 | import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; |
| 108 | import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; | 108 | import { 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"; |
| 121 | import { useAtomCommand } from "~/state/use-atom-command"; | 121 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 122 | import { vcsActionManager, vcsEnvironment } from "~/state/vcs"; | 122 | import { vcsActionManager, vcsEnvironment } from "~/state/vcs"; |
| 123 | import { randomUUID } from "~/lib/utils"; | 123 | import { randomUUID } from "~/lib/utils"; |
| 124 | import { resolvePathLinkTarget } from "~/terminal-links"; | 124 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 125 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 125 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 126 | import { | 126 | import { |
| 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"; |
| 61 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; | 61 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; |
| 62 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 62 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 63 | import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; | 63 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 64 | import { isTerminalUrl } from "../terminal-links"; | ||
| 64 | import { | 65 | import { |
| 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 | */ |
| 18 | export function ChatCanvas({ | 18 | export function ChatCanvas({ |
| 19 | composerOverlayElement, | 19 | composerOverlayElement, |
| 20 | detailsCardTopInset = 0, | ||
| 20 | children, | 21 | children, |
| 21 | ...props | 22 | ...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); |
| 15 | 17 | ||
| 16 | export const useChatCanvas = () => useContext(ChatCanvasContext); | 18 | export const useChatCanvas = () => useContext(ChatCanvasContext); |
apps/web/src/components/chat/ChatComposer.tsx+2-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 1 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; | 2 | import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; |
| 2 | import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; | 3 | import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; |
| 3 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | 4 | import { 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"; |
| 1091 | import { proposedPlanTitle } from "../../proposedPlan"; | 1092 | import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; |
| 1092 | import { hasProviderSetup } from "./ProviderStatusBanner"; | 1093 | import { hasProviderSetup } from "./ProviderStatusBanner"; |
| 1093 | import { | 1094 | import { |
| 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"; |
| 1125 | import type { ContextWindowSnapshot } from "../../lib/contextWindow"; | 1126 | import type { ContextWindowSnapshot } from "../../lib/contextWindow"; |
| 1126 | import { | 1127 | import { |
| 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 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 1 | import { | 2 | import { |
| 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 @@ |
| 1 | import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; | ||
| 1 | import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; | 2 | import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; |
| 2 | import { | 3 | import { |
| 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 | }); |
| 2408 | 2436 | ||
| 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"; |
| 9 | import { shallow } from "zustand/vanilla/shallow"; | 9 | import { shallow } from "zustand/vanilla/shallow"; |
| 10 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; | 10 | import { renderCodexDirectivesForCopy } from "@t3tools/shared/codexMarkdownDirectives"; |
| 11 | import { | 11 | import { |
| 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 | } |
| 353 | 353 | ||
| 354 | export function shouldPreserveAssistantLineBreaks(text: string): boolean { | ||
| 355 | return /^★ Insight(?:\s|─)/mu.test(text); | ||
| 356 | } | ||
| 357 | |||
| 358 | export function resolveTimelineMinimapHeightStyle(itemCount: number): string { | 354 | export 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 | } |
| 1300 | 1296 | ||
| 1297 | function timelineRowEntries(entries: ReadonlyArray<TimelineEntry>) { | ||
| 1298 | return withoutSubagentDelegationRows(settleSupersededReasoning(entries)); | ||
| 1299 | } | ||
| 1300 | |||
| 1301 | /** The turn folds the timeline would draw, before applying expansion state. */ | ||
| 1302 | function 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 | |||
| 1301 | export function deriveMessagesTimelineRows(input: { | 1326 | export 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 | } |
| 1991 | 2014 | ||
| 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 | */ | ||
| 2020 | export 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. */ | ||
| 2031 | export 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 | |||
| 1992 | function sameCheckpointSummaries( | 2044 | function 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 @@ |
| 1 | import { ThreadFindTimelineContext } from "./ThreadFindProvider"; | ||
| 2 | import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; | ||
| 3 | import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; | ||
| 1 | import { ComputerUseAppIcon } from "~/components/Icons"; | 4 | import { ComputerUseAppIcon } from "~/components/Icons"; |
| 2 | import { useChatCanvas } from "./ChatCanvasContext"; | 5 | import { useChatCanvas } from "./ChatCanvasContext"; |
| 3 | import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; | 6 | import { 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"; |
| 98 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 102 | import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; |
| 99 | import { | 103 | import { |
| 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"; |
| 231 | import { type ThreadFindMatch } from "./threadFind"; | ||
| 232 | import { useThreadFindNavigation } from "./useThreadFindNavigation"; | ||
| 233 | import { readThreadFindPosition } from "./threadFindPosition"; | ||
| 234 | import type { ThreadFindPositionReader } from "./threadFind"; | ||
| 227 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | 235 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 228 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 236 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 229 | import { Spinner } from "../ui/spinner"; | 237 | import { 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 | |||
| 405 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | 415 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 406 | const TIMELINE_MAINTAIN_SCROLL_AT_END = { | 416 | const 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 | } |
| 438 | 448 | ||
| 439 | interface MessagesTimelineProps { | 449 | interface 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 | } |
| 529 | 545 | ||
| 530 | // --------------------------------------------------------------------------- | 546 | // --------------------------------------------------------------------------- |
| 531 | // MessagesTimeline — list owner | 547 | // MessagesTimeline — list owner |
| 532 | // --------------------------------------------------------------------------- | 548 | // --------------------------------------------------------------------------- |
| 533 | 549 | ||
| 534 | export const MessagesTimeline = memo(function MessagesTimeline({ | 550 | export 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 | |||
| 566 | const 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]); |
| 786 | 826 | ||
| 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 | ); |
| 1353 | 1448 | ||
| 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 state | 1529 | // 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 | } |
| 1388 | 1563 | ||
| 1389 | return ( | 1564 | return ( |
| 1390 | <TimelineRowCtx value={sharedState}> | 1565 | <MarkdownFindContext value={findActive}> |
| 1391 | <TimelineRowActivityCtx value={activityState}> | 1566 | <TimelineRowCtx value={sharedState}> |
| 1392 | <TooltipScrollDismissArea | 1567 | <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 | <AssistantSelectionToolbar | 1573 | {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 | }); |
| 1467 | 1647 | ||
| ... | @@ -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 | <UserMessageContextReferenceChip | 2327 | <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 | <AssistantCitationSource | 2753 | <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 | <ChatMarkdown | 2760 | > |
| 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 | <AssistantChangedFilesSection | 2775 | <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; | ||
| 4540 | 4729 | ||
| 4541 | return ( | 4730 | return ( |
| 4542 | <div> | 4731 | <div> |
| 4543 | {hasVisibleBody ? ( | 4732 | {hasVisibleBody ? ( |
| 4544 | <div | 4733 | <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 | <div | 4760 | <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 | <Button | 4768 | <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 @@ |
| 1 | import { memo, useState, useId } from "react"; | 1 | import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "@t3tools/shared/proposedPlanText"; |
| 2 | import { memo, useCallback, useState, useId } from "react"; | ||
| 3 | import { useFindRevealRef } from "./markdownFindContext"; | ||
| 2 | import { | 4 | import { |
| 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"; |
| 19 | import ChatMarkdown from "../ChatMarkdown"; | 19 | import ChatMarkdown from "../ChatMarkdown"; |
| 20 | import { EllipsisIcon } from "lucide-react"; | 20 | import { 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 = canCollapse | 81 | 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); |
| 84 | 92 | ||
| ... | @@ -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 | <MenuTrigger | 175 | <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 | <ChatMarkdown | 202 | <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 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 1 | import { Children, cloneElement, isValidElement, type ReactNode } from "react"; | 2 | import { Children, cloneElement, isValidElement, type ReactNode } from "react"; |
| 2 | import type { ServerProviderSkill } from "@t3tools/contracts"; | 3 | import { matchInlineSkills, type InlineSkill } from "@t3tools/shared/inlineSkills"; |
| 3 | import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; | ||
| 4 | 4 | ||
| 5 | import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip"; | 5 | import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip"; |
| 6 | import { ContextChip, ContextChipLabel } from "../ContextChip"; | 6 | import { ContextChip, ContextChipLabel } from "../ContextChip"; |
| 7 | 7 | ||
| 8 | const 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 | |||
| 11 | type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; | ||
| 12 | |||
| 13 | export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) { | 8 | export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) { |
| 14 | const nodes: ReactNode[] = []; | 9 | const nodes: ReactNode[] = []; |
| 15 | let cursor = 0; | 10 | let cursor = 0; |
| 16 | 11 | ||
| 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 | } |
| 33 | 19 | ||
| 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 = canvas | 37 | 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 @@ | ||
| 1 | import { useEffect, useRef, type KeyboardEvent } from "react"; | ||
| 2 | import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; | ||
| 3 | import { Button } from "../ui/button"; | ||
| 4 | import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; | ||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | import { formatThreadFindCount } from "./threadFind"; | ||
| 7 | import { THREAD_DETAILS_CARD_GAP } from "./threadDetailsCardLayout"; | ||
| 8 | |||
| 9 | interface 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 | */ | ||
| 28 | export const THREAD_FIND_BAR_RESERVED_HEIGHT = 36 + THREAD_DETAILS_CARD_GAP; | ||
| 29 | |||
| 30 | export 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 @@ | ||
| 1 | import { act, createRef, useContext, useState } from "react"; | ||
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 3 | import { afterEach, expect, it, vi } from "vite-plus/test"; | ||
| 4 | import { | ||
| 5 | EnvironmentId, | ||
| 6 | ThreadId, | ||
| 7 | type OrchestrationV2SearchThreadResult, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { | ||
| 10 | ThreadFind, | ||
| 11 | ThreadFindProvider, | ||
| 12 | ThreadFindTimelineContext, | ||
| 13 | type ThreadFindControls, | ||
| 14 | } from "./ThreadFindProvider"; | ||
| 15 | import type { ThreadFindBar } from "./ThreadFindBar"; | ||
| 16 | |||
| 17 | const 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 | })); | ||
| 26 | let bar: Parameters<typeof ThreadFindBar>[0]; | ||
| 27 | vi.mock("./ThreadFindBar", () => ({ | ||
| 28 | ThreadFindBar: (props: typeof bar) => { | ||
| 29 | bar = props; | ||
| 30 | return null; | ||
| 31 | }, | ||
| 32 | })); | ||
| 33 | vi.mock("~/state/orchestration", () => ({ | ||
| 34 | orchestrationEnvironment: { threadFind: (target: unknown) => target }, | ||
| 35 | })); | ||
| 36 | vi.mock("~/state/queries", () => ({ useDebouncedValue: <T,>(value: T) => value })); | ||
| 37 | vi.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 | }); | ||
| 52 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); | ||
| 53 | |||
| 54 | let renderer: ReactTestRenderer | undefined; | ||
| 55 | afterEach(async () => { | ||
| 56 | if (renderer) await act(async () => renderer?.unmount()); | ||
| 57 | renderer = undefined; | ||
| 58 | query.listeners.clear(); | ||
| 59 | vi.unstubAllGlobals(); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("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 @@ | ||
| 1 | import { | ||
| 2 | createContext, | ||
| 3 | use, | ||
| 4 | useEffect, | ||
| 5 | useImperativeHandle, | ||
| 6 | type ReactNode, | ||
| 7 | type ComponentProps, | ||
| 8 | type Ref, | ||
| 9 | } from "react"; | ||
| 10 | import { useThreadFind } from "./useThreadFind"; | ||
| 11 | import { ThreadFindBar } from "./ThreadFindBar"; | ||
| 12 | import { ChatCanvas } from "./ChatCanvas"; | ||
| 13 | |||
| 14 | type Find = ReturnType<typeof useThreadFind>; | ||
| 15 | export type ThreadFindControls = Pick<Find, "open" | "close">; | ||
| 16 | export const ThreadFindTimelineContext = createContext<Find["timelineProps"] | null>(null); | ||
| 17 | const ThreadFindBarContext = createContext<Find["barProps"] | null>(null); | ||
| 18 | |||
| 19 | export 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. */ | ||
| 37 | export 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 | |||
| 60 | export 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 @@ | ||
| 1 | import { 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. | ||
| 5 | export 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 | */ | ||
| 11 | export 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 @@ |
| 1 | import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout"; | 1 | import type { PreviewMiniPlayerFrame } from "../preview/previewMiniPlayerLayout"; |
| 2 | import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout"; | 2 | import { DETAILS_CARD_CLEARANCE } from "./chatCanvasLayout"; |
| 3 | 3 | ||
| 4 | /** Inset of the card from the canvas edges; the find bar shares it to line up. */ | ||
| 5 | export const THREAD_DETAILS_CARD_GAP = 12; | ||
| 6 | |||
| 4 | export function resolveThreadDetailsCardDensity( | 7 | export 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 to | 40 | // 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 + gap | 43 | 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { formatThreadFindCount, stepThreadFindIndex } from "./threadFind"; | ||
| 3 | |||
| 4 | describe("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 @@ | ||
| 1 | import type { OrchestrationV2SearchThreadInput, RunId } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type ThreadFindStart = NonNullable<OrchestrationV2SearchThreadInput["start"]>; | ||
| 4 | export type ThreadFindPositionReader = (query: string) => ThreadFindStart | undefined; | ||
| 5 | |||
| 6 | /** One occurrence of the query inside a searchable timeline entry. */ | ||
| 7 | export 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 | |||
| 15 | function 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 | |||
| 20 | export 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 | |||
| 26 | export 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 @@ | ||
| 1 | const THREAD_FIND_OPEN_EVENT = "t3:thread-find-open"; | ||
| 2 | |||
| 3 | export function requestThreadFindOpen(): void { | ||
| 4 | window.dispatchEvent(new Event(THREAD_FIND_OPEN_EVENT)); | ||
| 5 | } | ||
| 6 | |||
| 7 | export 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 | |||
| 3 | import { act } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 6 | import { collectThreadFindRanges, useThreadFindHighlights } from "./threadFindHighlights"; | ||
| 7 | |||
| 8 | function Probe(props: Parameters<typeof useThreadFindHighlights>[0]) { | ||
| 9 | useThreadFindHighlights(props); | ||
| 10 | return null; | ||
| 11 | } | ||
| 12 | |||
| 13 | let root: Root; | ||
| 14 | let host: HTMLElement; | ||
| 15 | let container: HTMLElement; | ||
| 16 | const onActiveRange = vi.fn(); | ||
| 17 | const activeRanges = () => [ | ||
| 18 | ...(CSS.highlights.get("t3-thread-find-active") as unknown as Set<Range>), | ||
| 19 | ]; | ||
| 20 | |||
| 21 | beforeEach(() => { | ||
| 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 | }); | ||
| 40 | afterEach(async () => { | ||
| 41 | await act(async () => root.unmount()); | ||
| 42 | host.remove(); | ||
| 43 | container.remove(); | ||
| 44 | vi.restoreAllMocks(); | ||
| 45 | vi.clearAllMocks(); | ||
| 46 | vi.unstubAllGlobals(); | ||
| 47 | }); | ||
| 48 | |||
| 49 | describe("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 | |||
| 248 | function 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 | |||
| 254 | describe("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 @@ | ||
| 1 | import { findThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; | ||
| 2 | import { useEffect, useLayoutEffect, useRef } from "react"; | ||
| 3 | import { THREAD_FIND_BLOCK_TAGS } from "@t3tools/shared/threadFindText"; | ||
| 4 | |||
| 5 | const THREAD_FIND_HIGHLIGHT_NAME = "t3-thread-find"; | ||
| 6 | const THREAD_FIND_ACTIVE_HIGHLIGHT_NAME = "t3-thread-find-active"; | ||
| 7 | |||
| 8 | const THREAD_FIND_TEXT_SELECTOR = "[data-thread-find-text]"; | ||
| 9 | const THREAD_FIND_IGNORE_SELECTOR = "[data-thread-find-ignore]"; | ||
| 10 | |||
| 11 | interface ThreadFindRange { | ||
| 12 | readonly rowId: string; | ||
| 13 | readonly occurrence: number; | ||
| 14 | readonly range: Range; | ||
| 15 | } | ||
| 16 | |||
| 17 | /** Collects visible occurrences without modifying rendered markdown. */ | ||
| 18 | export 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. */ | ||
| 78 | const FOLD_SELECTOR = "[hidden], [data-thread-find-fold]"; | ||
| 79 | |||
| 80 | /** Folds around the range that currently keep it out of sight, innermost first. */ | ||
| 81 | function 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. */ | ||
| 99 | function 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 | |||
| 105 | function 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 | |||
| 118 | export 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. */ | ||
| 226 | function 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 | ||
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | import { readThreadFindPosition } from "./threadFindPosition"; | ||
| 4 | |||
| 5 | afterEach(() => vi.restoreAllMocks()); | ||
| 6 | beforeEach(() => { | ||
| 7 | Object.defineProperty(Range.prototype, "getBoundingClientRect", { | ||
| 8 | configurable: true, | ||
| 9 | writable: true, | ||
| 10 | value: () => new DOMRect(), | ||
| 11 | }); | ||
| 12 | }); | ||
| 13 | |||
| 14 | function 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 | |||
| 28 | describe("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 @@ | ||
| 1 | import type { ThreadFindStart } from "./threadFind"; | ||
| 2 | import { collectThreadFindRanges } from "./threadFindHighlights"; | ||
| 3 | |||
| 4 | /** Anchor to the first row being read, skipping occurrences above its viewport. */ | ||
| 5 | export 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 @@ | ||
| 1 | import { act, useLayoutEffect } from "react"; | ||
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | import { | ||
| 5 | EnvironmentId, | ||
| 6 | RunId, | ||
| 7 | ThreadId, | ||
| 8 | type OrchestrationV2SearchThreadInput, | ||
| 9 | type OrchestrationV2SearchThreadResult, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import { useThreadFind } from "./useThreadFind"; | ||
| 12 | import { orchestrationEnvironment } from "~/state/orchestration"; | ||
| 13 | import { requestThreadFindOpen } from "./threadFindActionBus"; | ||
| 14 | |||
| 15 | const queries = vi.hoisted(() => ({ | ||
| 16 | results: new Map<string, OrchestrationV2SearchThreadResult>(), | ||
| 17 | pending: false, | ||
| 18 | refresh: vi.fn(), | ||
| 19 | })); | ||
| 20 | vi.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 | })); | ||
| 30 | vi.mock("~/state/queries", () => ({ useDebouncedValue: <T,>(value: T) => value })); | ||
| 31 | vi.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 | |||
| 40 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); | ||
| 41 | |||
| 42 | let renderer: ReactTestRenderer | undefined; | ||
| 43 | let find: ReturnType<typeof useThreadFind>; | ||
| 44 | const a = EnvironmentId.make("environment:a"); | ||
| 45 | const b = EnvironmentId.make("environment:b"); | ||
| 46 | const threadId = ThreadId.make("shared-thread"); | ||
| 47 | const runId = RunId.make("run:plan"); | ||
| 48 | function 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 | } | ||
| 70 | beforeEach(() => { | ||
| 71 | vi.stubGlobal("window", new EventTarget()); | ||
| 72 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 73 | }); | ||
| 74 | afterEach(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 | |||
| 84 | describe("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 @@ | ||
| 1 | import type { InlineSkill } from "@t3tools/shared/inlineSkills"; | ||
| 2 | import type { | ||
| 3 | OrchestrationV2ThreadProjection, | ||
| 4 | OrchestrationV2SearchThreadResult, | ||
| 5 | ScopedThreadRef, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; | ||
| 8 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | ||
| 9 | import { orchestrationEnvironment } from "~/state/orchestration"; | ||
| 10 | import { useEnvironmentQuery } from "~/state/query"; | ||
| 11 | import { useDebouncedValue } from "~/state/queries"; | ||
| 12 | import { | ||
| 13 | type ThreadFindPositionReader, | ||
| 14 | type ThreadFindStart, | ||
| 15 | stepThreadFindIndex, | ||
| 16 | } from "./threadFind"; | ||
| 17 | import { subscribeThreadFindOpen } from "./threadFindActionBus"; | ||
| 18 | import { toastManager } from "../ui/toast"; | ||
| 19 | |||
| 20 | const EMPTY_SKILLS: readonly InlineSkill[] = []; | ||
| 21 | |||
| 22 | const 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. */ | ||
| 40 | export 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. */ | ||
| 296 | function 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. */ | ||
| 311 | function 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 | |||
| 3 | import type { LegendListRef } from "@legendapp/list/react"; | ||
| 4 | import { act, type ComponentProps } from "react"; | ||
| 5 | import { createRoot, type Root } from "react-dom/client"; | ||
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 7 | import { useThreadFindNavigation } from "./useThreadFindNavigation"; | ||
| 8 | |||
| 9 | const renders = vi.fn(); | ||
| 10 | function Probe(props: Parameters<typeof useThreadFindNavigation>[0]) { | ||
| 11 | renders(); | ||
| 12 | useThreadFindNavigation(props); | ||
| 13 | return null; | ||
| 14 | } | ||
| 15 | |||
| 16 | let root: Root; | ||
| 17 | let host: HTMLDivElement; | ||
| 18 | let container: HTMLDivElement; | ||
| 19 | let rect: DOMRect; | ||
| 20 | const scrollToIndex = vi.fn<LegendListRef["scrollToIndex"]>(); | ||
| 21 | const scrollToOffset = vi.fn<LegendListRef["scrollToOffset"]>(); | ||
| 22 | let props: ComponentProps<typeof Probe>; | ||
| 23 | |||
| 24 | beforeEach(() => { | ||
| 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 | |||
| 67 | afterEach(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 | |||
| 76 | describe("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 @@ | ||
| 1 | import type { LegendListRef } from "@legendapp/list/react"; | ||
| 2 | import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from "react"; | ||
| 3 | import type { ThreadFindMatch } from "./threadFind"; | ||
| 4 | import { useThreadFindHighlights } from "./threadFindHighlights"; | ||
| 5 | |||
| 6 | const FIND_MATCH_VIEW_MARGIN = 96; | ||
| 7 | |||
| 8 | /** Materializes missing results and otherwise scrolls only enough to reveal the active text. */ | ||
| 9 | export 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"; |
| 19 | import { useTheme } from "~/hooks/useTheme"; | 19 | import { useTheme } from "~/hooks/useTheme"; |
| 20 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; | 20 | import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; |
| 21 | import { cn } from "~/lib/utils"; | 21 | import { cn } from "~/lib/utils"; |
| 22 | import { isAbsolutePath } from "~/terminal-links"; | 22 | import { isAbsolutePath } from "@t3tools/shared/path"; |
| 23 | 23 | ||
| 24 | import { | 24 | import { |
| 25 | type FileBreadcrumb, | 25 | type FileBreadcrumb, |
apps/web/src/components/files/FileMarkdownPreview.tsx+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import ChatMarkdown from "~/components/ChatMarkdown"; | 3 | import ChatMarkdown from "~/components/ChatMarkdown"; |
| 4 | import { resolvePathLinkTarget } from "~/terminal-links"; | 4 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 5 | 5 | ||
| 6 | export function FileMarkdownPreview(props: { | 6 | export 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"; |
| 57 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; | 57 | import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; |
| 58 | import { cn } from "~/lib/utils"; | 58 | import { cn } from "~/lib/utils"; |
| 59 | import type { ChatFileAttachment } from "~/types"; | 59 | import type { ChatFileAttachment } from "~/types"; |
| 60 | import { isAbsolutePath, resolvePathLinkTarget } from "~/terminal-links"; | 60 | import { isAbsolutePath } from "@t3tools/shared/path"; |
| 61 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; | ||
| 61 | import { ScrollArea } from "~/components/ui/scroll-area"; | 62 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 62 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; | 63 | import { stackedThreadToast, toastManager } from "~/components/ui/toast"; |
| 63 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 64 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
apps/web/src/components/files/filePath.ts+1-3| ... | @@ -1,7 +1,5 @@ | ... | @@ -1,7 +1,5 @@ |
| 1 | import type { ProjectEntry } from "@t3tools/contracts"; | 1 | import type { ProjectEntry } from "@t3tools/contracts"; |
| 2 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 2 | import { isWindowsAbsolutePath, isAbsolutePath } from "@t3tools/shared/path"; |
| 3 | |||
| 4 | import { isAbsolutePath } from "~/terminal-links"; | ||
| 5 | 3 | ||
| 6 | export interface FileBreadcrumb { | 4 | export 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 @@ |
| 1 | import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links"; | ||
| 2 | import type { ProjectReadFileError } from "@t3tools/contracts"; | 1 | import type { ProjectReadFileError } from "@t3tools/contracts"; |
| 3 | import { isAbsolutePath } from "~/terminal-links"; | 2 | import { isAbsolutePath, workspaceRelativeFilePath } from "@t3tools/shared/path"; |
| 4 | 3 | ||
| 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. */ |
| 6 | export function resolveFilePreviewPath(path: string | null, cwd: string): string | null { | 5 | export 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( |
| 26 | function InputGroup({ | 34 | function InputGroup({ |
| 27 | className, | 35 | className, |
| 28 | variant, | 36 | variant, |
| 37 | size, | ||
| 29 | ...props | 38 | ...props |
| 30 | }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) { | 39 | }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) { |
| 31 | return ( | 40 | return ( |
| 32 | <div | 41 | <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( |
| 62 | function InputGroupAddon({ | 88 | function InputGroupAddon({ |
| 63 | className, | 89 | className, |
| 64 | align = "inline-start", | 90 | align = "inline-start", |
| 91 | inset, | ||
| 65 | ...props | 92 | ...props |
| 66 | }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) { | 93 | }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) { |
| 67 | return ( | 94 | return ( |
| 68 | <div | 95 | <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"; |
| 2 | import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; | 2 | import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; |
| 3 | 3 | ||
| 4 | import { useRightPanelStore } from "./rightPanelStore"; | 4 | import { useRightPanelStore } from "./rightPanelStore"; |
| 5 | import { resolvePathLinkTarget } from "./terminal-links"; | 5 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 6 | 6 | ||
| 7 | interface OpenDiffFilePrimaryActionInput { | 7 | interface 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"; |
| 22 | import { serverEnvironment } from "./state/server"; | 22 | import { serverEnvironment } from "./state/server"; |
| 23 | import { shellEnvironment } from "./state/shell"; | 23 | import { shellEnvironment } from "./state/shell"; |
| 24 | import { useAtomCommand } from "./state/use-atom-command"; | 24 | import { useAtomCommand } from "./state/use-atom-command"; |
| 25 | import { resolvePathLinkTarget } from "./terminal-links"; | 25 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 26 | import { toastManager } from "./components/ui/toast"; | 26 | import { toastManager } from "./components/ui/toast"; |
| 27 | import { useAtomValue } from "@effect/atom-react"; | 27 | import { useAtomValue } from "@effect/atom-react"; |
| 28 | 28 |
apps/web/src/filePathDisplay.ts+3-4| ... | @@ -1,10 +1,9 @@ | ... | @@ -1,10 +1,9 @@ |
| 1 | import { | 1 | import { |
| 2 | fileBasename, | 2 | fileBasename, |
| 3 | formatFilePathPosition, | ||
| 4 | splitFilePathPosition, | ||
| 5 | stripSlashPrefixedWindowsDrive, | 3 | stripSlashPrefixedWindowsDrive, |
| 6 | } from "@t3tools/client-runtime/markdown-links"; | 4 | isWindowsAbsolutePath, |
| 7 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 5 | } from "@t3tools/shared/path"; |
| 6 | import { formatFilePathPosition, splitFilePathPosition } from "@t3tools/shared/fileLinks"; | ||
| 8 | 7 | ||
| 9 | function normalizePathSeparators(path: string): string { | 8 | function 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 | } |
| 375 | 375 | ||
| 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 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import ReactMarkdown from "react-markdown"; | ||
| 3 | import remarkGfm from "remark-gfm"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { remarkGithubAlerts } from "./markdown-github-alerts"; | ||
| 7 | |||
| 8 | function renderMarkdown(markdown: string): string { | ||
| 9 | return renderToStaticMarkup( | ||
| 10 | <ReactMarkdown remarkPlugins={[remarkGfm, remarkGithubAlerts]}>{markdown}</ReactMarkdown>, | ||
| 11 | ); | ||
| 12 | } | ||
| 13 | |||
| 14 | describe("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 | |||
| 12 | interface MarkdownAstNode { | ||
| 13 | type?: string; | ||
| 14 | value?: unknown; | ||
| 15 | data?: { | ||
| 16 | hProperties?: Record<string, unknown>; | ||
| 17 | }; | ||
| 18 | children?: MarkdownAstNode[]; | ||
| 19 | } | ||
| 20 | |||
| 21 | const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i; | ||
| 22 | |||
| 23 | function 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 | |||
| 64 | export 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 @@ |
| 1 | import { CHAT_MARKDOWN_REHYPE_PLUGINS } from "@t3tools/shared/markdownPipeline"; | ||
| 1 | import type { Root } from "mdast"; | 2 | import type { Root } from "mdast"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import ReactMarkdown from "react-markdown"; | 4 | import ReactMarkdown from "react-markdown"; |
| 4 | import rehypeRaw from "rehype-raw"; | ||
| 5 | import rehypeSanitize from "rehype-sanitize"; | ||
| 6 | import remarkGfm from "remark-gfm"; | 5 | import remarkGfm from "remark-gfm"; |
| 7 | import type { Plugin } from "unified"; | 6 | import type { Plugin } from "unified"; |
| 8 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | 8 | ||
| 10 | import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives"; | 9 | import { remarkCodexDirectives } from "@t3tools/shared/codexMarkdownDirectives"; |
| 11 | import { remarkGithubAlerts } from "./markdown-github-alerts"; | 10 | import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; |
| 12 | import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; | 11 | import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; |
| 13 | import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; | 12 | import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; |
| 14 | 13 | ||
| 15 | function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { | 14 | function 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { createElement } from "react"; | ||
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 4 | import ReactMarkdown from "react-markdown"; | ||
| 5 | 2 | ||
| 6 | import { | 3 | import { |
| 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"; |
| 16 | 10 | ||
| 17 | describe("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 | |||
| 27 | function 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 | |||
| 46 | describe("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 | |||
| 64 | describe("shouldOpenMarkdownFileLinkInEditor", () => { | 11 | describe("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 | }); |
| 132 | 79 | ||
| 133 | describe("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 | |||
| 340 | describe("relative links inside a rendered host file", () => { | 80 | describe("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 @@ |
| 1 | import { fileBasename, workspaceRelativeFilePath } from "@t3tools/shared/path"; | ||
| 1 | import { | 2 | import { |
| 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, | 7 | import { parseFileUrlHref, splitFilePathPosition } from "@t3tools/shared/fileLinks"; |
| 10 | splitFilePathPosition, | ||
| 11 | workspaceRelativeFilePath, | ||
| 12 | } from "@t3tools/client-runtime/markdown-links"; | ||
| 13 | 8 | ||
| 14 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; | 9 | import { formatWorkspaceRelativePath } from "./filePathDisplay"; |
| 15 | import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links"; | 10 | import { isTerminalLinkActivation } from "./terminal-links"; |
| 16 | |||
| 17 | export { normalizeMarkdownLinkDestination }; | ||
| 18 | |||
| 19 | const MARKDOWN_LINK_HREF_PATTERN = | ||
| 20 | /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; | ||
| 21 | 11 | ||
| 22 | export interface MarkdownFileLinkMeta { | 12 | export 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 | } |
| 31 | 21 | ||
| 32 | export 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 | |||
| 41 | export function shouldOpenMarkdownFileLinkInEditor( | 22 | export 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 | } |
| 51 | 32 | ||
| 52 | export function isWindowsDrivePathHref(href: string): boolean { | ||
| 53 | return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); | ||
| 54 | } | ||
| 55 | |||
| 56 | export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { | 33 | export 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 | } |
| 61 | 38 | ||
| 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 | */ | ||
| 67 | export 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-linking | 41 | * `origin/main`) rather than deliberate link destinations, so auto-linking |
apps/web/src/markdown-list-indentation.test.tsx deleted-82| ... | @@ -1,82 +0,0 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import ReactMarkdown from "react-markdown"; | ||
| 3 | import remarkGfm from "remark-gfm"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; | ||
| 7 | |||
| 8 | function renderMarkdown(markdown: string): string { | ||
| 9 | return renderToStaticMarkup( | ||
| 10 | <ReactMarkdown remarkPlugins={[remarkGfm, remarkNormalizeListItemIndentation]}> | ||
| 11 | {markdown} | ||
| 12 | </ReactMarkdown>, | ||
| 13 | ); | ||
| 14 | } | ||
| 15 | |||
| 16 | describe("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 @@ | ||
| 1 | interface MarkdownPosition { | ||
| 2 | readonly start?: { | ||
| 3 | readonly line?: number; | ||
| 4 | readonly offset?: number; | ||
| 5 | }; | ||
| 6 | } | ||
| 7 | |||
| 8 | interface MarkdownAstNode { | ||
| 9 | readonly type: string; | ||
| 10 | readonly value?: unknown; | ||
| 11 | readonly position?: MarkdownPosition; | ||
| 12 | children?: MarkdownAstNode[]; | ||
| 13 | } | ||
| 14 | |||
| 15 | interface MarkdownFile { | ||
| 16 | readonly value?: unknown; | ||
| 17 | } | ||
| 18 | |||
| 19 | interface MarkdownParser { | ||
| 20 | parse(markdown: string): unknown; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface RecoveredMarkdown { | ||
| 24 | readonly blocks: MarkdownAstNode[]; | ||
| 25 | readonly source: string; | ||
| 26 | } | ||
| 27 | |||
| 28 | const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:"; | ||
| 29 | |||
| 30 | function 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 | |||
| 59 | function 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 | |||
| 94 | function 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 | */ | ||
| 115 | function 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 | |||
| 143 | export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer; | ||
apps/web/src/markdownGithubAlerts.test.tsx created+60| ... | @@ -0,0 +1,60 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import ReactMarkdown from "react-markdown"; | ||
| 3 | import remarkGfm from "remark-gfm"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { remarkGithubAlerts } from "@t3tools/shared/markdownGithubAlerts"; | ||
| 7 | |||
| 8 | function renderMarkdown(markdown: string): string { | ||
| 9 | return renderToStaticMarkup( | ||
| 10 | <ReactMarkdown remarkPlugins={[remarkGfm, remarkGithubAlerts]}>{markdown}</ReactMarkdown>, | ||
| 11 | ); | ||
| 12 | } | ||
| 13 | |||
| 14 | describe("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 @@ | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 2 | import ReactMarkdown from "react-markdown"; | ||
| 3 | import remarkGfm from "remark-gfm"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { remarkNormalizeListItemIndentation } from "@t3tools/shared/markdownListIndentation"; | ||
| 7 | |||
| 8 | function renderMarkdown(markdown: string): string { | ||
| 9 | return renderToStaticMarkup( | ||
| 10 | <ReactMarkdown remarkPlugins={[remarkGfm, remarkNormalizeListItemIndentation]}> | ||
| 11 | {markdown} | ||
| 12 | </ReactMarkdown>, | ||
| 13 | ); | ||
| 14 | } | ||
| 15 | |||
| 16 | describe("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"); | ||
| 413 | 433 | ||
| 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"; |
| 12 | 10 | ||
| 13 | describe("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 | |||
| 23 | describe("buildPlanImplementationPrompt", () => { | 11 | describe("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 | }); |
| 51 | 39 | ||
| 52 | describe("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 | |||
| 66 | describe("resolvePlanFollowUpSubmission", () => { | 40 | describe("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 @@ |
| 1 | export function proposedPlanTitle(planMarkdown: string): string | null { | 1 | import { 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 | |||
| 6 | export 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 | } | ||
| 21 | 2 | ||
| 22 | export function buildCollapsedProposedPlanPreviewMarkdown( | 3 | export function buildCollapsedProposedPlanPreviewMarkdown( |
| 23 | planMarkdown: string, | 4 | planMarkdown: string, |
apps/web/src/providerSkillSearch.ts+1-1| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | 2 | import type { ServerProviderSkill } from "@t3tools/contracts"; |
| 2 | import { | 3 | import { |
| 3 | dedupeProviderSkillsByName, | 4 | dedupeProviderSkillsByName, |
| 4 | formatProviderSkillDisplayName, | ||
| 5 | isProviderSkillUserInvocable, | 5 | isProviderSkillUserInvocable, |
| 6 | } from "@t3tools/client-runtime/providerSkills"; | 6 | } from "@t3tools/client-runtime/providerSkills"; |
| 7 | import { | 7 | import { |
apps/web/src/terminal-links.test.ts+1-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| ... | @@ -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"; |
| 11 | 11 |
apps/web/src/terminal-links.ts-63| ... | @@ -1,8 +1,3 @@ | ... | @@ -1,8 +1,3 @@ |
| 1 | import { | ||
| 2 | formatFilePathPosition, | ||
| 3 | splitFilePathPosition, | ||
| 4 | } from "@t3tools/client-runtime/markdown-links"; | ||
| 5 | |||
| 6 | import { isMacPlatform } from "./lib/utils"; | 1 | import { isMacPlatform } from "./lib/utils"; |
| 7 | 2 | ||
| 8 | export type TerminalLinkKind = "url" | "path"; | 3 | export type TerminalLinkKind = "url" | "path"; |
| ... | @@ -98,45 +93,6 @@ function collectMatches( | ... | @@ -98,45 +93,6 @@ function collectMatches( |
| 98 | return matches; | 93 | return matches; |
| 99 | } | 94 | } |
| 100 | 95 | ||
| 101 | function isWindowsAbsolutePath(value: string): boolean { | ||
| 102 | return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); | ||
| 103 | } | ||
| 104 | |||
| 105 | export function isAbsolutePath(value: string): boolean { | ||
| 106 | return value.startsWith("/") || isWindowsAbsolutePath(value); | ||
| 107 | } | ||
| 108 | |||
| 109 | function isWindowsPathStyle(value: string): boolean { | ||
| 110 | return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); | ||
| 111 | } | ||
| 112 | |||
| 113 | function 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 | |||
| 121 | function 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 | |||
| 140 | export function extractTerminalLinks(line: string): TerminalLinkMatch[] { | 96 | export 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.ctrlKey | 159 | ? event.metaKey && !event.ctrlKey |
| 204 | : event.ctrlKey && !event.metaKey; | 160 | : event.ctrlKey && !event.metaKey; |
| 205 | } | 161 | } |
| 206 | |||
| 207 | export 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 | }); |
| 92 | 93 | ||
| 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)); |
| 745 | 745 | ||
| 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) }; |
| 747 | 751 | ||
| 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 | ); | ||
| 295 | 389 | ||
| 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; | ||
| 324 | 330 | ||
| 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 | ``` |
| 123 | 123 | ||
| 124 | ## Find in the current thread | ||
| 125 | |||
| 126 | `chat.find` searches conversation messages and proposed plans in the active thread. Entering a | ||
| 127 | query searches the entire thread, including older messages. Thread search requires server support; | ||
| 128 | update 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, | ||
| 130 | and **Escape** to close find. | ||
| 131 | |||
| 124 | ## Precedence | 132 | ## Precedence |
| 125 | 133 | ||
| 126 | The last rule whose key and condition both match wins, even if it belongs to a | 134 | The 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | appendCodexArtifactTemplateUsePrompt, | ||
| 5 | codexArtifactTemplatePresentationLabel, | ||
| 6 | codexArtifactTemplateUsePrompt, | ||
| 7 | resolveCodexArtifactTemplate, | ||
| 8 | type CodexArtifactTemplate, | ||
| 9 | } from "./codexArtifactTemplates.js"; | ||
| 10 | |||
| 11 | const 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 | |||
| 18 | describe("artifact template presentation", () => { | ||
| 19 | it("shares labels and copy text across clients", () => { | ||
| 20 | expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template"); | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | describe("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 | |||
| 76 | describe("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 | |||
| 93 | describe("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 @@ | ||
| 1 | const 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 | |||
| 14 | export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number]; | ||
| 15 | |||
| 16 | const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; | ||
| 17 | |||
| 18 | export type CodexArtifactTemplateGalleryKind = | ||
| 19 | (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number]; | ||
| 20 | |||
| 21 | export interface CodexArtifactTemplate { | ||
| 22 | readonly artifactKind: CodexArtifactTemplateKind; | ||
| 23 | readonly displayName: string; | ||
| 24 | readonly galleryKind?: CodexArtifactTemplateGalleryKind; | ||
| 25 | readonly skillDirectory: string; | ||
| 26 | readonly skillName: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | const 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 | |||
| 42 | export type CodexArtifactTemplateAttributes = Readonly<Record<string, string | null | undefined>>; | ||
| 43 | |||
| 44 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | ||
| 45 | const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/; | ||
| 46 | |||
| 47 | function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind { | ||
| 48 | return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value); | ||
| 49 | } | ||
| 50 | |||
| 51 | function isCodexArtifactTemplateGalleryKind( | ||
| 52 | value: unknown, | ||
| 53 | ): value is CodexArtifactTemplateGalleryKind { | ||
| 54 | return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value); | ||
| 55 | } | ||
| 56 | |||
| 57 | function 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. */ | ||
| 66 | export 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 | |||
| 97 | const 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 | |||
| 110 | export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string { | ||
| 111 | return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`); | ||
| 112 | } | ||
| 113 | |||
| 114 | export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string { | ||
| 115 | return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind]; | ||
| 116 | } | ||
| 117 | |||
| 118 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.js"; | ||
| 4 | |||
| 5 | describe("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 | |||
| 61 | describe("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 @@ | ||
| 1 | export interface CodexFileCitationLink { | ||
| 2 | readonly path: string; | ||
| 3 | readonly href: string; | ||
| 4 | readonly label: string; | ||
| 5 | readonly lineRangeStart?: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | export type CodexFileCitationAttributes = Readonly<Record<string, string | null | undefined>>; | ||
| 9 | |||
| 10 | function 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 | |||
| 16 | function citationLabel(path: string): string { | ||
| 17 | const normalized = path.replaceAll("\\", "/").replace(/\/+$/, ""); | ||
| 18 | return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File"; | ||
| 19 | } | ||
| 20 | |||
| 21 | function markdownDestinationPath(path: string): string { | ||
| 22 | return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F"); | ||
| 23 | } | ||
| 24 | |||
| 25 | export 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 | |||
| 41 | function markdownLabel(value: string): string { | ||
| 42 | return value.replace(/[\\[\]*_`<&]/g, "\\$&"); | ||
| 43 | } | ||
| 44 | |||
| 45 | function 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 | |||
| 54 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import remarkParse from "remark-parse"; | ||
| 3 | import { unified } from "unified"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | remarkCodexDirectives, | ||
| 7 | renderCodexDirectivesForCopy, | ||
| 8 | renderCodexFileCitationsAsMarkdown, | ||
| 9 | splitCodexArtifactTemplateMarkdown, | ||
| 10 | } from "./codexMarkdownDirectives.js"; | ||
| 11 | import { parseMarkdownFileLink } from "./markdownLinks.js"; | ||
| 12 | |||
| 13 | interface 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 | |||
| 28 | const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}'; | ||
| 29 | const 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 | |||
| 32 | function 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 | |||
| 37 | function parseOrdinaryMarkdown(markdown: string): TestNode { | ||
| 38 | return unified().use(remarkParse).parse(markdown) as TestNode; | ||
| 39 | } | ||
| 40 | |||
| 41 | describe("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 | |||
| 93 | describe.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 | |||
| 111 | describe("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 | |||
| 160 | describe("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 @@ | ||
| 1 | import { directiveFromMarkdown } from "mdast-util-directive"; | ||
| 2 | import { directive } from "micromark-extension-directive"; | ||
| 3 | import { | ||
| 4 | markdownLineEnding, | ||
| 5 | unicodePunctuation, | ||
| 6 | unicodeWhitespace, | ||
| 7 | } from "micromark-util-character"; | ||
| 8 | import type { Construct, Extension, Tokenizer } from "micromark-util-types"; | ||
| 9 | import remarkParse from "remark-parse"; | ||
| 10 | import { unified, type Processor } from "unified"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | codexArtifactTemplatePresentationLabel, | ||
| 14 | resolveCodexArtifactTemplate, | ||
| 15 | type CodexArtifactTemplate, | ||
| 16 | } from "./codexArtifactTemplates.ts"; | ||
| 17 | import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts"; | ||
| 18 | |||
| 19 | const COLON = 58; | ||
| 20 | const DASH = 45; | ||
| 21 | const UNDERSCORE = 95; | ||
| 22 | const CODEX_FILE_CITATION_NAME = "codex-file-citation"; | ||
| 23 | const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template"; | ||
| 24 | |||
| 25 | export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [ | ||
| 26 | "dataCodexArtifactTemplate", | ||
| 27 | "dataArtifactKind", | ||
| 28 | "dataDisplayName", | ||
| 29 | "dataGalleryKind", | ||
| 30 | "dataSkillDirectory", | ||
| 31 | "dataSkillName", | ||
| 32 | ] as const; | ||
| 33 | |||
| 34 | interface MarkdownPosition { | ||
| 35 | readonly start: { readonly offset?: number }; | ||
| 36 | readonly end: { readonly offset?: number }; | ||
| 37 | } | ||
| 38 | |||
| 39 | interface 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 | |||
| 55 | interface MarkdownFile { | ||
| 56 | readonly value: unknown; | ||
| 57 | } | ||
| 58 | |||
| 59 | export 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 | |||
| 67 | function 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 | |||
| 73 | function 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 | |||
| 82 | function 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 | |||
| 113 | function 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 | |||
| 123 | function 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 | |||
| 143 | const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax(); | ||
| 144 | const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown(); | ||
| 145 | |||
| 146 | function 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 | |||
| 152 | function 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 | |||
| 163 | function 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 | |||
| 173 | function 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 | |||
| 186 | function 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 | |||
| 202 | function 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 | |||
| 229 | function 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. */ | ||
| 246 | function 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 | |||
| 258 | export const remarkCodexDirectives = attachCodexDirectives; | ||
| 259 | |||
| 260 | const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); | ||
| 261 | |||
| 262 | function parseCodexMarkdown(markdown: string): MarkdownAstNode { | ||
| 263 | return directiveParser.runSync(directiveParser.parse(markdown), { | ||
| 264 | value: markdown, | ||
| 265 | }) as MarkdownAstNode; | ||
| 266 | } | ||
| 267 | |||
| 268 | interface DirectiveMatch { | ||
| 269 | readonly start: number; | ||
| 270 | readonly end: number; | ||
| 271 | readonly markdown?: string; | ||
| 272 | readonly template?: CodexArtifactTemplate; | ||
| 273 | } | ||
| 274 | |||
| 275 | function 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 | |||
| 291 | function 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. */ | ||
| 308 | export 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. */ | ||
| 315 | export 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. */ | ||
| 331 | export 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 | |||
| 373 | export 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 @@ |
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 1 | import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "@t3tools/shared/path"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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"; | 8 | import { parseFileUrlHref } from "@t3tools/shared/fileLinks"; |
| 10 | 9 | ||
| 11 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; | 10 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; |
| 12 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; | 11 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; |
packages/client-runtime/src/markdownLinks.test.ts deleted-208| ... | @@ -1,208 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | fileBasename, | ||
| 5 | inlineCodeFilePathCandidate, | ||
| 6 | isMarkdownFileLinkLabel, | ||
| 7 | parseFileUrlHref, | ||
| 8 | parseMarkdownFileLink, | ||
| 9 | splitFilePathPosition, | ||
| 10 | workspaceRelativeFilePath, | ||
| 11 | } from "./markdownLinks.ts"; | ||
| 12 | |||
| 13 | describe("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 | |||
| 36 | describe("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 | |||
| 63 | describe("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 | |||
| 89 | describe("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 | |||
| 103 | describe("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 | |||
| 168 | describe("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 | |||
| 180 | describe("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 @@ | ||
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | ||
| 2 | |||
| 3 | const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; | ||
| 4 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | ||
| 5 | const RELATIVE_FILE_PATH_PATTERN = | ||
| 6 | /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; | ||
| 7 | const RELATIVE_FILE_NAME_PATTERN = | ||
| 8 | /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | ||
| 9 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | ||
| 10 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | ||
| 11 | const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; | ||
| 12 | const POSITION_HASH_PATTERN = /^#L(\d+)(?:C(\d+))?$/i; | ||
| 13 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | ||
| 14 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; | ||
| 15 | const PATH_SEPARATOR_PATTERN = /[\\/]/; | ||
| 16 | const 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. | ||
| 19 | const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; | ||
| 20 | const 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. | ||
| 23 | const 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`. | ||
| 50 | const 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 | ]); | ||
| 79 | const 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. | ||
| 82 | const 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. | ||
| 111 | const 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 | |||
| 145 | function 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 | */ | ||
| 161 | export 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 | |||
| 188 | export function safeDecodeURIComponent(value: string): string { | ||
| 189 | try { | ||
| 190 | return decodeURIComponent(value); | ||
| 191 | } catch { | ||
| 192 | return value; | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | export 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. */ | ||
| 202 | export function stripSlashPrefixedWindowsDrive(path: string): string { | ||
| 203 | return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; | ||
| 204 | } | ||
| 205 | |||
| 206 | export 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 | */ | ||
| 225 | export 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 | |||
| 243 | export interface FilePathPosition { | ||
| 244 | readonly path: string; | ||
| 245 | readonly line?: number; | ||
| 246 | readonly column?: number; | ||
| 247 | } | ||
| 248 | |||
| 249 | export 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 | |||
| 263 | export 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. */ | ||
| 269 | export 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 | |||
| 291 | export function isRelativeFilePath(path: string): boolean { | ||
| 292 | return ( | ||
| 293 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || | ||
| 294 | (!path.startsWith("/") && !isWindowsAbsolutePath(path)) | ||
| 295 | ); | ||
| 296 | } | ||
| 297 | |||
| 298 | function 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 | */ | ||
| 311 | function 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 | |||
| 318 | function 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 | |||
| 327 | export 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 | |||
| 345 | export 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 | |||
| 354 | export 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 @@ |
| 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 1 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| 2 | 2 | ||
| 3 | import { safeDecodeURIComponent } from "./markdownLinks.ts"; | 3 | import { safeDecodeURIComponent } from "@t3tools/shared/markdownLinks"; |
| 4 | 4 | ||
| 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. */ |
| 6 | export type MediaReference = | 6 | export 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"; |
| 9 | import { | 9 | import { fileBasename } from "@t3tools/shared/path"; |
| 10 | fileBasename, | 10 | import { splitFilePathPosition } from "@t3tools/shared/fileLinks"; |
| 11 | splitFilePathPosition, | 11 | import { splitMarkdownLinkSearchAndHash } from "@t3tools/shared/markdownLinks"; |
| 12 | splitMarkdownLinkSearchAndHash, | ||
| 13 | } from "./markdownLinks.ts"; | ||
| 14 | import { | 12 | import { |
| 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"; |
| 7 | import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; | ||
| 7 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 8 | 9 | ||
| 9 | import { | 10 | import { |
| 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 { |
| 7 | 7 | ||
| 8 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; | 8 | export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; |
| 9 | 9 | ||
| 10 | function 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 | |||
| 19 | function normalizePathSeparators(pathValue: string): string { | 10 | function normalizePathSeparators(pathValue: string): string { |
| 20 | return pathValue.replaceAll("\\", "/"); | 11 | return pathValue.replaceAll("\\", "/"); |
| 21 | } | 12 | } |
| 22 | 13 | ||
| 23 | export 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 | |||
| 33 | export function dedupeProviderSkillsByName( | 14 | export 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.providerInstallSubscribe | 53 | | typeof WS_METHODS.providerInstallSubscribe |
| 54 | | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell | 54 | | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell |
| 55 | | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread | 55 | | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread |
| 56 | | typeof ORCHESTRATION_V2_WS_METHODS.searchThreadStream | ||
| 56 | | typeof WS_METHODS.subscribeAuthAccess | 57 | | typeof WS_METHODS.subscribeAuthAccess |
| 57 | | typeof WS_METHODS.subscribeServerConfig | 58 | | typeof WS_METHODS.subscribeServerConfig |
| 58 | | typeof WS_METHODS.subscribeServerLifecycle | 59 | | 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 | }); |
| 293 | 294 | ||
| 295 | describe("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 | |||
| 294 | describe("environment query lifecycle", () => { | 364 | describe("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 | } |
| 83 | 84 | ||
| ... | @@ -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 runtime | 621 | 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"; |
| 7 | import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; | 7 | import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model"; |
| 8 | import { fileBasename } from "../markdownLinks.ts"; | 8 | import { fileBasename } from "@t3tools/shared/path"; |
| 9 | import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; | 9 | import { isTerminalSubagentStatus } from "./subagentRuntime.ts"; |
| 10 | 10 | ||
| 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"; |
| 97 | 97 | ||
| 98 | export type LoadEarlierThreadHistoryInput = { | 98 | export type LoadEarlierThreadHistoryInput = { |
| 99 | readonly threadId: ThreadId; | 99 | readonly threadId: ThreadId; |
| 100 | readonly throughEntryId?: string; | ||
| 100 | }; | 101 | }; |
| 101 | 102 | ||
| 102 | export type { | 103 | export 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 }; |
| 14 | 14 | ||
| 15 | export type ThreadHistoryHandler = { | 15 | export type ThreadHistoryHandler = { |
| 16 | readonly loadEarlier: () => Effect.Effect<ThreadHistoryLoadEarlierResult>; | 16 | readonly loadEarlier: (throughEntryId?: string) => Effect.Effect<ThreadHistoryLoadEarlierResult>; |
| 17 | }; | 17 | }; |
| 18 | 18 | ||
| 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 | } |
| 46 | 46 | ||
| 47 | describe("threadHistoryMerge", () => { | 47 | describe("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 | }); |
| 71 | 116 | ||
| 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 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationV2ProjectedTurnItem, | 2 | OrchestrationV2ProjectedTurnItem, |
| 3 | OrchestrationV2ThreadProjection, | 3 | OrchestrationV2ThreadProjection, |
| 4 | OrchestrationV2TurnItem, | ||
| 5 | } from "@t3tools/contracts"; | 4 | } from "@t3tools/contracts"; |
| 6 | import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline"; | 5 | import { isOrchestrationV2TurnItemVisible } from "@t3tools/shared/orchestrationV2Timeline"; |
| 7 | 6 | ||
| ... | @@ -73,62 +72,61 @@ export function mergeOlderHistoryIntoProjection( | ... | @@ -73,62 +72,61 @@ export function mergeOlderHistoryIntoProjection( |
| 73 | return projection; | 72 | return projection; |
| 74 | } | 73 | } |
| 75 | 74 | ||
| 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 | } |
| 134 | 132 |
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 |
| 254 | 254 | ||
| 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 | ); |
| 848 | 850 | ||
| 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 | }); |
| 636 | 636 | ||
| 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 | ); |
| 659 | 661 | ||
| 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, |
| 687 | 709 | ( | |
| 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]; |
| 695 | 717 | 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 | }, |
| 752 | 731 | ), | |
| 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 | }); |
| 772 | 738 | ||
| 773 | // On Effect interruption only: clear loading when this request cursor is | 739 | // 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({ |
| 594 | 594 | ||
| 595 | const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ | 595 | const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ |
| 596 | cursor: TrimmedNonEmptyString, | 596 | cursor: TrimmedNonEmptyString, |
| 597 | throughEntryId: Schema.optional(TrimmedNonEmptyString), | ||
| 598 | view: Schema.optional(Schema.Literals(["conversation", "activity"])), | ||
| 597 | }); | 599 | }); |
| 598 | 600 | ||
| 599 | const EnvironmentOrchestrationThreadHistoryErrors = [ | 601 | const 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"); |
| 103 | 103 | ||
| 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 | } |
| 3500 | 3502 | ||
| 3503 | export 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 | }); | ||
| 3522 | export type OrchestrationV2SearchThreadInput = typeof OrchestrationV2SearchThreadInput.Type; | ||
| 3523 | |||
| 3524 | export const OrchestrationV2ThreadFindMatch = Schema.Struct({ | ||
| 3525 | entryId: TrimmedNonEmptyString, | ||
| 3526 | runId: Schema.NullOr(RunId), | ||
| 3527 | occurrence: NonNegativeInt, | ||
| 3528 | }); | ||
| 3529 | export 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 | }); | ||
| 3548 | export type OrchestrationV2SearchThreadResult = typeof OrchestrationV2SearchThreadResult.Type; | ||
| 3549 | |||
| 3550 | export 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 | |||
| 3501 | export const OrchestrationV2RpcSchemas = { | 3559 | export 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 @@ |
| 1 | import { | ||
| 2 | OrchestrationV2SearchThreadError, | ||
| 3 | OrchestrationV2SearchThreadInput, | ||
| 4 | OrchestrationV2SearchThreadResult, | ||
| 5 | } from "./orchestrationV2.ts"; | ||
| 1 | import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; | 6 | import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; |
| 2 | import { | 7 | import { |
| 3 | McpAppCallToolInput, | 8 | McpAppCallToolInput, |
| ... | @@ -1565,6 +1570,22 @@ const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make( | ... | @@ -1565,6 +1570,22 @@ const WsOrchestrationV2GetFullThreadDiffRpc = Rpc.make( |
| 1565 | }, | 1570 | }, |
| 1566 | ); | 1571 | ); |
| 1567 | 1572 | ||
| 1573 | const WsOrchestrationV2SearchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThread, { | ||
| 1574 | payload: OrchestrationV2SearchThreadInput, | ||
| 1575 | success: OrchestrationV2SearchThreadResult, | ||
| 1576 | error: Schema.Union([OrchestrationV2SearchThreadError, EnvironmentAuthorizationError]), | ||
| 1577 | }); | ||
| 1578 | |||
| 1579 | const 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 | |||
| 1568 | const WsOrchestrationV2SearchThreadsRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.searchThreads, { | 1589 | const 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 need | 675 | * Folder behind this environment's Scratch project, for threads that need |
| 674 | * no repository. Present only on servers that answer projects.ensureScratch | 676 | * 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 | } |
| 100 | 100 | ||
| 101 | /** Visible chip text: the comment, else the quoted text, collapsed and capped. */ | ||
| 101 | export function assistantCitationLabel(citation: AssistantCitation): string { | 102 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | appendCodexArtifactTemplateUsePrompt, | ||
| 5 | codexArtifactTemplatePresentationLabel, | ||
| 6 | codexArtifactTemplateUsePrompt, | ||
| 7 | resolveCodexArtifactTemplate, | ||
| 8 | type CodexArtifactTemplate, | ||
| 9 | } from "./codexArtifactTemplates.ts"; | ||
| 10 | |||
| 11 | const 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 | |||
| 18 | describe("artifact template presentation", () => { | ||
| 19 | it("shares labels and copy text across clients", () => { | ||
| 20 | expect(codexArtifactTemplatePresentationLabel("document")).toBe("Document template"); | ||
| 21 | }); | ||
| 22 | }); | ||
| 23 | |||
| 24 | describe("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 | |||
| 76 | describe("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 | |||
| 93 | describe("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 @@ | ||
| 1 | const 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 | |||
| 14 | export type CodexArtifactTemplateKind = (typeof CODEX_ARTIFACT_TEMPLATE_KINDS)[number]; | ||
| 15 | |||
| 16 | const CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS = ["imagegen", "product-design"] as const; | ||
| 17 | |||
| 18 | export type CodexArtifactTemplateGalleryKind = | ||
| 19 | (typeof CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS)[number]; | ||
| 20 | |||
| 21 | export interface CodexArtifactTemplate { | ||
| 22 | readonly artifactKind: CodexArtifactTemplateKind; | ||
| 23 | readonly displayName: string; | ||
| 24 | readonly galleryKind?: CodexArtifactTemplateGalleryKind; | ||
| 25 | readonly skillDirectory: string; | ||
| 26 | readonly skillName: string; | ||
| 27 | } | ||
| 28 | |||
| 29 | const 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 | |||
| 42 | export type CodexArtifactTemplateAttributes = Readonly<Record<string, string | null | undefined>>; | ||
| 43 | |||
| 44 | const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; | ||
| 45 | const WINDOWS_UNC_PATH_REGEX = /^(?:\\\\[^\\]+\\[^\\]+|\/\/[^/]+\/[^/]+)/; | ||
| 46 | |||
| 47 | function isCodexArtifactTemplateKind(value: unknown): value is CodexArtifactTemplateKind { | ||
| 48 | return CODEX_ARTIFACT_TEMPLATE_KINDS.some((kind) => kind === value); | ||
| 49 | } | ||
| 50 | |||
| 51 | function isCodexArtifactTemplateGalleryKind( | ||
| 52 | value: unknown, | ||
| 53 | ): value is CodexArtifactTemplateGalleryKind { | ||
| 54 | return CODEX_ARTIFACT_TEMPLATE_GALLERY_KINDS.some((kind) => kind === value); | ||
| 55 | } | ||
| 56 | |||
| 57 | function 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. */ | ||
| 66 | export 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 | |||
| 97 | const 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 | |||
| 110 | export function codexArtifactTemplateUsePrompt(template: CodexArtifactTemplate): string { | ||
| 111 | return USE_PROMPT_BY_KIND[template.artifactKind](`$${template.skillName}`); | ||
| 112 | } | ||
| 113 | |||
| 114 | export function codexArtifactTemplatePresentationLabel(kind: CodexArtifactTemplateKind): string { | ||
| 115 | return CODEX_ARTIFACT_TEMPLATE_LABEL_BY_KIND[kind]; | ||
| 116 | } | ||
| 117 | |||
| 118 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts"; | ||
| 4 | |||
| 5 | describe("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 | |||
| 61 | describe("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 @@ | ||
| 1 | export interface CodexFileCitationLink { | ||
| 2 | readonly path: string; | ||
| 3 | readonly href: string; | ||
| 4 | readonly label: string; | ||
| 5 | readonly lineRangeStart?: number; | ||
| 6 | } | ||
| 7 | |||
| 8 | export type CodexFileCitationAttributes = Readonly<Record<string, string | null | undefined>>; | ||
| 9 | |||
| 10 | function 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 | |||
| 16 | function citationLabel(path: string): string { | ||
| 17 | const normalized = path.replaceAll("\\", "/").replace(/\/+$/, ""); | ||
| 18 | return normalized.slice(normalized.lastIndexOf("/") + 1) || normalized || "File"; | ||
| 19 | } | ||
| 20 | |||
| 21 | function markdownDestinationPath(path: string): string { | ||
| 22 | return path.replaceAll("%", "%25").replaceAll("#", "%23").replaceAll("?", "%3F"); | ||
| 23 | } | ||
| 24 | |||
| 25 | export 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 | |||
| 41 | function markdownLabel(value: string): string { | ||
| 42 | return value.replace(/[\\[\]*_`<&]/g, "\\$&"); | ||
| 43 | } | ||
| 44 | |||
| 45 | function 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 | |||
| 54 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import remarkParse from "remark-parse"; | ||
| 3 | import { unified } from "unified"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | remarkCodexDirectives, | ||
| 7 | renderCodexDirectivesForCopy, | ||
| 8 | renderCodexFileCitationsAsMarkdown, | ||
| 9 | splitCodexArtifactTemplateMarkdown, | ||
| 10 | } from "./codexMarkdownDirectives.ts"; | ||
| 11 | import { parseMarkdownFileLink } from "./markdownLinks.ts"; | ||
| 12 | |||
| 13 | interface 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 | |||
| 28 | const FILE_CITATION = ':codex-file-citation{path="outputs/report.xlsx" purpose="output"}'; | ||
| 29 | const 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 | |||
| 32 | function 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 | |||
| 37 | function parseOrdinaryMarkdown(markdown: string): TestNode { | ||
| 38 | return unified().use(remarkParse).parse(markdown) as TestNode; | ||
| 39 | } | ||
| 40 | |||
| 41 | describe("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 | |||
| 93 | describe.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 | |||
| 111 | describe("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 | |||
| 160 | describe("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 @@ | ||
| 1 | import { directiveFromMarkdown } from "mdast-util-directive"; | ||
| 2 | import { directive } from "micromark-extension-directive"; | ||
| 3 | import { | ||
| 4 | markdownLineEnding, | ||
| 5 | unicodePunctuation, | ||
| 6 | unicodeWhitespace, | ||
| 7 | } from "micromark-util-character"; | ||
| 8 | import type { Construct, Extension, Tokenizer } from "micromark-util-types"; | ||
| 9 | import remarkParse from "remark-parse"; | ||
| 10 | import { unified, type Processor } from "unified"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | codexArtifactTemplatePresentationLabel, | ||
| 14 | resolveCodexArtifactTemplate, | ||
| 15 | type CodexArtifactTemplate, | ||
| 16 | } from "./codexArtifactTemplates.ts"; | ||
| 17 | import { codexFileCitationMarkdown, resolveCodexFileCitationLink } from "./codexFileCitations.ts"; | ||
| 18 | |||
| 19 | const COLON = 58; | ||
| 20 | const DASH = 45; | ||
| 21 | const UNDERSCORE = 95; | ||
| 22 | const CODEX_FILE_CITATION_NAME = "codex-file-citation"; | ||
| 23 | const CODEX_ARTIFACT_TEMPLATE_NAME = "artifact-template"; | ||
| 24 | |||
| 25 | export const CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES = [ | ||
| 26 | "dataCodexArtifactTemplate", | ||
| 27 | "dataArtifactKind", | ||
| 28 | "dataDisplayName", | ||
| 29 | "dataGalleryKind", | ||
| 30 | "dataSkillDirectory", | ||
| 31 | "dataSkillName", | ||
| 32 | ] as const; | ||
| 33 | |||
| 34 | interface MarkdownPosition { | ||
| 35 | readonly start: { readonly offset?: number }; | ||
| 36 | readonly end: { readonly offset?: number }; | ||
| 37 | } | ||
| 38 | |||
| 39 | interface 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 | |||
| 55 | interface MarkdownFile { | ||
| 56 | readonly value: unknown; | ||
| 57 | } | ||
| 58 | |||
| 59 | export 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 | |||
| 67 | function 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 | |||
| 73 | function 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 | |||
| 82 | function 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 | |||
| 113 | function 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 | |||
| 123 | function 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 | |||
| 143 | const CODEX_DIRECTIVE_SYNTAX = codexDirectiveSyntax(); | ||
| 144 | const CODEX_DIRECTIVE_FROM_MARKDOWN = directiveFromMarkdown(); | ||
| 145 | |||
| 146 | function 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 | |||
| 152 | function 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 | |||
| 163 | function 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 | |||
| 173 | function 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 | |||
| 186 | function 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 | |||
| 202 | function 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 | |||
| 229 | function 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. */ | ||
| 246 | function 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 | |||
| 258 | export const remarkCodexDirectives = attachCodexDirectives; | ||
| 259 | |||
| 260 | const directiveParser = unified().use(remarkParse).use(remarkCodexDirectives).freeze(); | ||
| 261 | |||
| 262 | function parseCodexMarkdown(markdown: string): MarkdownAstNode { | ||
| 263 | return directiveParser.runSync(directiveParser.parse(markdown), { | ||
| 264 | value: markdown, | ||
| 265 | }) as MarkdownAstNode; | ||
| 266 | } | ||
| 267 | |||
| 268 | interface DirectiveMatch { | ||
| 269 | readonly start: number; | ||
| 270 | readonly end: number; | ||
| 271 | readonly markdown?: string; | ||
| 272 | readonly template?: CodexArtifactTemplate; | ||
| 273 | } | ||
| 274 | |||
| 275 | function 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 | |||
| 291 | function 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. */ | ||
| 308 | export 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. */ | ||
| 315 | export 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. */ | ||
| 331 | export 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 | |||
| 373 | export 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 | } |
| 25 | 25 | ||
| 26 | const PLACEHOLDER = ""; | 26 | const PLACEHOLDER = ""; |
| 27 | const TRAILING_TERMINAL = /\n*<terminal_context>\n([\s\S]*?)\n<\/terminal_context>\s*$/; | 27 | const TRAILING_TERMINAL = |
| 28 | const 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*$/; |
| 29 | const TRAILING_ELEMENT = | ||
| 30 | /\n*<element_context>\n((?:(?!\n<\/element_context>)[\s\S])*)\n<\/element_context>\s*$/; | ||
| 29 | const TRAILING_PREVIEW = | 31 | const 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*$/; |
| 31 | const REVIEW_OR_CONTEXT_BLOCK = | 33 | const REVIEW_OR_CONTEXT_BLOCK = |
packages/shared/src/fileLinks.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { parseFileUrlHref, splitFilePathPosition, resolvePathLinkTarget } from "./fileLinks.ts"; | ||
| 3 | |||
| 4 | describe("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 | |||
| 30 | describe("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 | |||
| 44 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | fileBasename, | ||
| 3 | isAbsolutePath, | ||
| 4 | isWindowsAbsolutePath, | ||
| 5 | stripSlashPrefixedWindowsDrive, | ||
| 6 | } from "./path.ts"; | ||
| 7 | |||
| 8 | const POSITION_SUFFIX_CAPTURE_PATTERN = /:(\d+)(?::(\d+))?$/; | ||
| 9 | |||
| 10 | const 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 | */ | ||
| 17 | export 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 | |||
| 35 | export interface FilePathPosition { | ||
| 36 | readonly path: string; | ||
| 37 | readonly line?: number; | ||
| 38 | readonly column?: number; | ||
| 39 | } | ||
| 40 | |||
| 41 | export 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 | |||
| 55 | export function formatFilePathPosition(position: FilePathPosition): string { | ||
| 56 | if (!position.line) return position.path; | ||
| 57 | return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; | ||
| 58 | } | ||
| 59 | |||
| 60 | function isWindowsPathStyle(value: string): boolean { | ||
| 61 | return isWindowsAbsolutePath(value) || /[A-Za-z]:\\/.test(value); | ||
| 62 | } | ||
| 63 | |||
| 64 | function 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 | |||
| 72 | function 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 | |||
| 91 | export 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 | |||
| 110 | function 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 | |||
| 116 | export 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. */ | ||
| 171 | export 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 @@ | ||
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; | ||
| 4 | |||
| 5 | function 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 | |||
| 14 | export 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 | |||
| 24 | const 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 | |||
| 27 | export 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 | |||
| 12 | interface MarkdownAstNode { | ||
| 13 | type?: string; | ||
| 14 | value?: unknown; | ||
| 15 | data?: { | ||
| 16 | hProperties?: Record<string, unknown>; | ||
| 17 | }; | ||
| 18 | children?: MarkdownAstNode[]; | ||
| 19 | } | ||
| 20 | |||
| 21 | const GITHUB_ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:\r?\n|$)/i; | ||
| 22 | |||
| 23 | function 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 | |||
| 64 | export 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 @@ | ||
| 1 | import { splitFilePathPosition } from "./fileLinks.ts"; | ||
| 2 | import { parseFileUrlHref } from "./fileLinks.ts"; | ||
| 3 | import { workspaceRelativeFilePath, fileBasename } from "./path.ts"; | ||
| 4 | import { describe, expect, it } from "vite-plus/test"; | ||
| 5 | import { | ||
| 6 | extractMarkdownLinkHrefs, | ||
| 7 | resolveMarkdownFileLinkTarget, | ||
| 8 | isWindowsDrivePathHref, | ||
| 9 | } from "./markdownLinks.ts"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | inlineCodeFilePathCandidate, | ||
| 13 | isMarkdownFileLinkLabel, | ||
| 14 | parseMarkdownFileLink, | ||
| 15 | } from "./markdownLinks.ts"; | ||
| 16 | |||
| 17 | describe("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 | |||
| 40 | describe("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 | |||
| 67 | describe("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 | |||
| 93 | describe("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 | |||
| 107 | describe("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 | |||
| 172 | describe("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 | |||
| 184 | describe("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 | |||
| 214 | describe("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 | |||
| 224 | describe("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 | |||
| 242 | describe("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 @@ | ||
| 1 | import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "./path.ts"; | ||
| 2 | import { | ||
| 3 | type FilePathPosition, | ||
| 4 | formatFilePathPosition, | ||
| 5 | parseFileUrlHref, | ||
| 6 | resolvePathLinkTarget, | ||
| 7 | splitFilePathPosition, | ||
| 8 | } from "./fileLinks.ts"; | ||
| 9 | |||
| 10 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; | ||
| 11 | |||
| 12 | const 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 | |||
| 15 | const RELATIVE_FILE_NAME_PATTERN = | ||
| 16 | /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; | ||
| 17 | |||
| 18 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; | ||
| 19 | |||
| 20 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; | ||
| 21 | |||
| 22 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; | ||
| 23 | |||
| 24 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; | ||
| 25 | |||
| 26 | const PATH_SEPARATOR_PATTERN = /[\\/]/; | ||
| 27 | |||
| 28 | const 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. | ||
| 31 | const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; | ||
| 32 | const 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. | ||
| 36 | const 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`. | ||
| 64 | const 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 | |||
| 94 | const 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. | ||
| 98 | const 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. | ||
| 128 | const 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 | |||
| 162 | function 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 | */ | ||
| 178 | export 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 | |||
| 205 | export function safeDecodeURIComponent(value: string): string { | ||
| 206 | try { | ||
| 207 | return decodeURIComponent(value); | ||
| 208 | } catch { | ||
| 209 | return value; | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 213 | export function normalizeMarkdownLinkDestination(value: string): string { | ||
| 214 | const trimmed = value.trim(); | ||
| 215 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | ||
| 216 | } | ||
| 217 | |||
| 218 | export 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. */ | ||
| 233 | export 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 | |||
| 255 | function isRelativeFilePath(path: string): boolean { | ||
| 256 | return ( | ||
| 257 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || | ||
| 258 | (!path.startsWith("/") && !isWindowsAbsolutePath(path)) | ||
| 259 | ); | ||
| 260 | } | ||
| 261 | |||
| 262 | function 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 | */ | ||
| 275 | function 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 | |||
| 282 | function 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 | |||
| 291 | export 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 | |||
| 309 | const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; | ||
| 310 | |||
| 311 | const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; | ||
| 312 | |||
| 313 | export 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 | |||
| 325 | const MARKDOWN_LINK_HREF_PATTERN = | ||
| 326 | /\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; | ||
| 327 | |||
| 328 | export 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 | */ | ||
| 342 | export 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 | |||
| 357 | export 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 @@ | ||
| 1 | interface MarkdownPosition { | ||
| 2 | readonly start?: { | ||
| 3 | readonly line?: number; | ||
| 4 | readonly offset?: number; | ||
| 5 | }; | ||
| 6 | } | ||
| 7 | |||
| 8 | interface MarkdownAstNode { | ||
| 9 | readonly type: string; | ||
| 10 | readonly value?: unknown; | ||
| 11 | readonly position?: MarkdownPosition; | ||
| 12 | children?: MarkdownAstNode[]; | ||
| 13 | } | ||
| 14 | |||
| 15 | interface MarkdownFile { | ||
| 16 | readonly value?: unknown; | ||
| 17 | } | ||
| 18 | |||
| 19 | interface MarkdownParser { | ||
| 20 | parse(markdown: string): unknown; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface RecoveredMarkdown { | ||
| 24 | readonly blocks: MarkdownAstNode[]; | ||
| 25 | readonly source: string; | ||
| 26 | } | ||
| 27 | |||
| 28 | const INLINE_PARSE_PREFIX = "t3-markdown-inline-prefix:"; | ||
| 29 | |||
| 30 | function 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 | |||
| 59 | function 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 | |||
| 94 | function 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 | */ | ||
| 115 | function 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 | |||
| 143 | export const remarkNormalizeListItemIndentation = attachListItemIndentationNormalizer; | ||
packages/shared/src/markdownPipeline.test.ts created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | import { expect, it } from "vite-plus/test"; | ||
| 2 | import { unified } from "unified"; | ||
| 3 | import remarkParse from "remark-parse"; | ||
| 4 | import remarkRehype from "remark-rehype"; | ||
| 5 | import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS } from "./markdownPipeline.ts"; | ||
| 6 | |||
| 7 | it("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 @@ | ||
| 1 | import type { Processor } from "unified"; | ||
| 2 | import { isWindowsAbsolutePath } from "./path.ts"; | ||
| 3 | import type { PluggableList } from "unified"; | ||
| 4 | import rehypeRaw from "rehype-raw"; | ||
| 5 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | ||
| 6 | import remarkBreaks from "remark-breaks"; | ||
| 7 | import remarkGfm from "remark-gfm"; | ||
| 8 | import { remarkGithubAlerts } from "./markdownGithubAlerts.ts"; | ||
| 9 | import { remarkNormalizeListItemIndentation } from "./markdownListIndentation.ts"; | ||
| 10 | import { | ||
| 11 | CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, | ||
| 12 | remarkCodexDirectives, | ||
| 13 | } from "./codexMarkdownDirectives.ts"; | ||
| 14 | import { isWindowsDrivePathHref } from "./markdownLinks.ts"; | ||
| 15 | import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts"; | ||
| 16 | |||
| 17 | type MarkdownImageHastNode = { | ||
| 18 | type?: string; | ||
| 19 | value?: string; | ||
| 20 | tagName?: string; | ||
| 21 | properties?: Record<string, unknown>; | ||
| 22 | children?: MarkdownImageHastNode[]; | ||
| 23 | }; | ||
| 24 | |||
| 25 | function 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. */ | ||
| 39 | const STANDALONE_IMAGE_BLOCKS = new Set([ | ||
| 40 | "p", | ||
| 41 | "div", | ||
| 42 | "li", | ||
| 43 | "td", | ||
| 44 | "th", | ||
| 45 | "figure", | ||
| 46 | "center", | ||
| 47 | "blockquote", | ||
| 48 | ]); | ||
| 49 | |||
| 50 | function 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 | |||
| 63 | function 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. */ | ||
| 75 | function 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. */ | ||
| 123 | function 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 | |||
| 143 | const 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 | |||
| 172 | export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [ | ||
| 173 | remarkGfm, | ||
| 174 | remarkKeepWindowsPathDestinations, | ||
| 175 | remarkGithubAlerts, | ||
| 176 | remarkNormalizeListItemIndentation, | ||
| 177 | remarkCodexDirectives, | ||
| 178 | remarkPreserveCodeMeta, | ||
| 179 | remarkNormalizeLinksAndTagInlineCode, | ||
| 180 | ]; | ||
| 181 | |||
| 182 | export 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 | |||
| 193 | export const CHAT_MARKDOWN_REHYPE_PLUGINS: PluggableList = [ | ||
| 194 | rehypePreserveBareAnchorPlaceholders, | ||
| 195 | rehypeRaw, | ||
| 196 | rehypePreserveImageSourceMeta, | ||
| 197 | [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], | ||
| 198 | ]; | ||
| 199 | |||
| 200 | type MarkdownAstNode = { | ||
| 201 | type?: string; | ||
| 202 | meta?: unknown; | ||
| 203 | url?: string; | ||
| 204 | data?: { | ||
| 205 | hProperties?: Record<string, unknown>; | ||
| 206 | }; | ||
| 207 | children?: MarkdownAstNode[]; | ||
| 208 | }; | ||
| 209 | |||
| 210 | function 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 | */ | ||
| 234 | function 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 | |||
| 261 | interface DestinationCompileContext { | ||
| 262 | readonly stack: ReadonlyArray<{ readonly type: string; url?: string }>; | ||
| 263 | resume(): string; | ||
| 264 | sliceSerialize(token: unknown): string; | ||
| 265 | } | ||
| 266 | |||
| 267 | function 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 | */ | ||
| 281 | function remarkKeepWindowsPathDestinations(this: Processor) { | ||
| 282 | const data = this.data(); | ||
| 283 | (data.fromMarkdownExtensions ??= []).push({ | ||
| 284 | exit: { | ||
| 285 | resourceDestinationString: keepWindowsPathDestination, | ||
| 286 | definitionDestinationString: keepWindowsPathDestination, | ||
| 287 | }, | ||
| 288 | }); | ||
| 289 | } | ||
| 290 | |||
| 291 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { | 2 | import { |
| 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 | |||
| 64 | describe("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 | |||
| 76 | describe("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 | |||
| 78 | const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/; | ||
| 79 | |||
| 80 | /** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */ | ||
| 81 | export function stripSlashPrefixedWindowsDrive(path: string): string { | ||
| 82 | return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path; | ||
| 83 | } | ||
| 84 | |||
| 85 | export 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 | |||
| 94 | export 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 | |||
| 111 | export function isAbsolutePath(value: string): boolean { | ||
| 112 | return value.startsWith("/") || isWindowsAbsolutePath(value); | ||
| 113 | } |
packages/shared/src/proposedPlanText.test.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; | ||
| 3 | |||
| 4 | describe("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 | |||
| 14 | describe("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 @@ | ||
| 1 | export 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 | |||
| 6 | export 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 | */ |
| 131 | export const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { | 135 | export 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 | }; |
| 190 | 198 | ||
| 191 | export const T3_CODE_DARK_THEME_COLORS: ThemeColors = { | 199 | export 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 @@ | ||
| 1 | import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { expect, it } from "vite-plus/test"; | ||
| 3 | import { serializeAssistantCitation } from "./assistantCitations.ts"; | ||
| 4 | import { searchableMessageSegments } from "./threadFindText.ts"; | ||
| 5 | |||
| 6 | it("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 | |||
| 23 | it("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 | |||
| 28 | it("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 | |||
| 39 | it("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 | |||
| 53 | it("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 | |||
| 63 | it("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 | |||
| 75 | const assistantSegments = (text: string, cwd?: string) => | ||
| 76 | searchableMessageSegments({ role: "assistant", text, streaming: false }, cwd); | ||
| 77 | |||
| 78 | it("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 | |||
| 88 | it("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 | |||
| 103 | it("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 | |||
| 110 | it("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 | |||
| 118 | it("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 | |||
| 127 | it("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 | |||
| 141 | it("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 @@ | ||
| 1 | import { | ||
| 2 | matchInlineSkills, | ||
| 3 | formatProviderSkillDisplayName, | ||
| 4 | type InlineSkill, | ||
| 5 | } from "./inlineSkills.ts"; | ||
| 6 | import { | ||
| 7 | artifactTemplateFromHastProperties, | ||
| 8 | renderCodexFileCitationsAsMarkdown, | ||
| 9 | } from "./codexMarkdownDirectives.ts"; | ||
| 10 | import { codexArtifactTemplatePresentationLabel } from "./codexArtifactTemplates.ts"; | ||
| 11 | import { | ||
| 12 | buildFileLinkParentSuffixByPath, | ||
| 13 | splitFilePathPosition, | ||
| 14 | fileLinkLabel, | ||
| 15 | } from "./fileLinks.ts"; | ||
| 16 | import { | ||
| 17 | isMarkdownFileLinkLabel, | ||
| 18 | extractMarkdownLinkHrefs, | ||
| 19 | extractInlineCodeSpans, | ||
| 20 | inlineCodeFilePathCandidate, | ||
| 21 | resolveMarkdownFileLinkTarget, | ||
| 22 | } from "./markdownLinks.ts"; | ||
| 23 | import { upgradeLegacyContextMessage } from "./composerContextLegacy.ts"; | ||
| 24 | import { parseComposerContextHref } from "./composerContextReferences.ts"; | ||
| 25 | import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; | ||
| 26 | import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; | ||
| 27 | import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; | ||
| 28 | import { unified } from "unified"; | ||
| 29 | import remarkParse from "remark-parse"; | ||
| 30 | import remarkRehype from "remark-rehype"; | ||
| 31 | import { | ||
| 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. | ||
| 39 | export 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 | |||
| 78 | const assistantProcessor = unified() | ||
| 79 | .use(remarkParse) | ||
| 80 | .use(CHAT_MARKDOWN_REMARK_PLUGINS) | ||
| 81 | .use(remarkRehype, { allowDangerousHtml: true }) | ||
| 82 | .use(CHAT_MARKDOWN_REHYPE_PLUGINS); | ||
| 83 | const assistantBreaksProcessor = unified() | ||
| 84 | .use(remarkParse) | ||
| 85 | .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) | ||
| 86 | .use(remarkRehype, { allowDangerousHtml: true }) | ||
| 87 | .use(CHAT_MARKDOWN_REHYPE_PLUGINS); | ||
| 88 | const userProcessor = unified() | ||
| 89 | .use(remarkParse) | ||
| 90 | .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) | ||
| 91 | .use(remarkRehype, { allowDangerousHtml: true }); | ||
| 92 | |||
| 93 | interface 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. */ | ||
| 106 | function 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. */ | ||
| 209 | export function searchablePlanSegments(markdown: string, cwd?: string): readonly string[] { | ||
| 210 | return [ | ||
| 211 | proposedPlanTitle(markdown) ?? "Proposed plan", | ||
| 212 | ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd, []), | ||
| 213 | ]; | ||
| 214 | } | ||
| 215 | |||
| 216 | export 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 @@ | ||
| 1 | import { expect, it } from "vite-plus/test"; | ||
| 2 | import { findThreadSearchOccurrences, splitThreadSearchText } from "./threadSearch.ts"; | ||
| 3 | |||
| 4 | it("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 | |||
| 10 | it("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 | |||
| 19 | it("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 @@ | ||
| 1 | interface ThreadSearchTextPart { | ||
| 2 | readonly text: string; | ||
| 3 | readonly highlighted: boolean; | ||
| 4 | readonly start: number; | ||
| 5 | } | ||
| 6 | |||
| 7 | function 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 | |||
| 19 | function 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 | |||
| 37 | export function countThreadSearchOccurrences(text: string, query: string): number { | ||
| 38 | return scanThreadSearchOccurrences(text, query); | ||
| 39 | } | ||
| 40 | |||
| 41 | export function findThreadSearchOccurrences(text: string, query: string): number[] { | ||
| 42 | const offsets: number[] = []; | ||
| 43 | scanThreadSearchOccurrences(text, query, offsets); | ||
| 44 | return offsets; | ||
| 45 | } | ||
| 46 | |||
| 47 | export 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.2 | 31 | specifier: 6.2.2 |
| 32 | version: 6.2.2 | 32 | 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.2 | 64 | specifier: 7.0.2 |
| 35 | version: 7.0.2 | 65 | version: 7.0.2 |
| 66 | unified: | ||
| 67 | specifier: 11.0.5 | ||
| 68 | version: 11.0.5 | ||
| 36 | 69 | ||
| 37 | overrides: | 70 | overrides: |
| 38 | '@exodus/bytes>@noble/hashes': 1.8.0 | 71 | '@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.0 | 811 | 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.1 | 814 | specifier: 'catalog:' |
| 791 | version: 4.0.1 | 815 | version: 4.0.1 |
| 792 | tailwind-merge: | 816 | tailwind-merge: |
| 793 | specifier: ^3.4.0 | 817 | specifier: ^3.4.0 |
| ... | @@ -857,7 +881,7 @@ importers: | ... | @@ -857,7 +881,7 @@ importers: |
| 857 | specifier: 4.3.3 | 881 | specifier: 4.3.3 |
| 858 | version: 4.3.3 | 882 | version: 4.3.3 |
| 859 | unified: | 883 | unified: |
| 860 | specifier: ^11.0.5 | 884 | specifier: 'catalog:' |
| 861 | version: 11.0.5 | 885 | version: 11.0.5 |
| 862 | vite: | 886 | vite: |
| 863 | specifier: npm:@voidzero-dev/vite-plus-core@1.0.0 | 887 | 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.1 | 982 | 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.0 | 985 | specifier: 0.180.0 |
| 974 | version: 0.180.0 | 986 | 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.1 | 989 | specifier: 4.0.1 |
| ... | @@ -982,9 +991,6 @@ importers: | ... | @@ -982,9 +991,6 @@ importers: |
| 982 | '@types/three': | 991 | '@types/three': |
| 983 | specifier: 0.180.0 | 992 | specifier: 0.180.0 |
| 984 | version: 0.180.0 | 993 | 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.0 | 995 | 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.2 | 1071 | 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.0 | 1103 | specifier: ^2.9.0 |
| 1068 | version: 2.9.0 | 1104 | version: 2.9.0 |
| ... | @@ -1076,6 +1112,9 @@ importers: | ... | @@ -1076,6 +1112,9 @@ importers: |
| 1076 | '@types/node': | 1112 | '@types/node': |
| 1077 | specifier: 24.12.4 | 1113 | specifier: 24.12.4 |
| 1078 | version: 24.12.4 | 1114 | 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.0 | 1119 | 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: |
| 19004 | 19043 | ||
| 19005 | hast-util-raw@9.1.0: | 19044 | hast-util-raw@9.1.0: |
| 19006 | dependencies: | 19045 | dependencies: |
| 19007 | '@types/hast': 3.0.4 | 19046 | '@types/hast': 3.0.5 |
| 19008 | '@types/unist': 3.0.3 | 19047 | '@types/unist': 3.0.3 |
| 19009 | '@ungap/structured-clone': 1.3.1 | 19048 | '@ungap/structured-clone': 1.3.1 |
| 19010 | hast-util-from-parse5: 8.0.3 | 19049 | hast-util-from-parse5: 8.0.3 |
| ... | @@ -19020,7 +19059,7 @@ snapshots: | ... | @@ -19020,7 +19059,7 @@ snapshots: |
| 19020 | 19059 | ||
| 19021 | hast-util-sanitize@5.0.2: | 19060 | hast-util-sanitize@5.0.2: |
| 19022 | dependencies: | 19061 | dependencies: |
| 19023 | '@types/hast': 3.0.4 | 19062 | '@types/hast': 3.0.5 |
| 19024 | '@ungap/structured-clone': 1.3.1 | 19063 | '@ungap/structured-clone': 1.3.1 |
| 19025 | unist-util-position: 5.0.0 | 19064 | unist-util-position: 5.0.0 |
| 19026 | 19065 | ||
| ... | @@ -21445,13 +21484,13 @@ snapshots: | ... | @@ -21445,13 +21484,13 @@ snapshots: |
| 21445 | 21484 | ||
| 21446 | rehype-raw@7.0.0: | 21485 | rehype-raw@7.0.0: |
| 21447 | dependencies: | 21486 | dependencies: |
| 21448 | '@types/hast': 3.0.4 | 21487 | '@types/hast': 3.0.5 |
| 21449 | hast-util-raw: 9.1.0 | 21488 | hast-util-raw: 9.1.0 |
| 21450 | vfile: 6.0.3 | 21489 | vfile: 6.0.3 |
| 21451 | 21490 | ||
| 21452 | rehype-sanitize@6.0.0: | 21491 | rehype-sanitize@6.0.0: |
| 21453 | dependencies: | 21492 | dependencies: |
| 21454 | '@types/hast': 3.0.4 | 21493 | '@types/hast': 3.0.5 |
| 21455 | hast-util-sanitize: 5.0.2 | 21494 | hast-util-sanitize: 5.0.2 |
| 21456 | 21495 | ||
| 21457 | remark-breaks@4.0.0: | 21496 | remark-breaks@4.0.0: |
| ... | @@ -21482,7 +21521,7 @@ snapshots: | ... | @@ -21482,7 +21521,7 @@ snapshots: |
| 21482 | 21521 | ||
| 21483 | remark-rehype@11.1.2: | 21522 | remark-rehype@11.1.2: |
| 21484 | dependencies: | 21523 | dependencies: |
| 21485 | '@types/hast': 3.0.4 | 21524 | '@types/hast': 3.0.5 |
| 21486 | '@types/mdast': 4.0.4 | 21525 | '@types/mdast': 4.0.4 |
| 21487 | mdast-util-to-hast: 13.2.1 | 21526 | mdast-util-to-hast: 13.2.1 |
| 21488 | unified: 11.0.5 | 21527 | unified: 11.0.5 |
pnpm-workspace.yaml+13| ... | @@ -56,6 +56,19 @@ catalog: | ... | @@ -56,6 +56,19 @@ catalog: |
| 56 | vite-plus: 1.0.0 | 56 | vite-plus: 1.0.0 |
| 57 | ws: 8.22.0 | 57 | ws: 8.22.0 |
| 58 | yaml: ^2.9.0 | 58 | 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 | ||
| 59 | 72 | ||
| 60 | minimumReleaseAgeExclude: | 73 | minimumReleaseAgeExclude: |
| 61 | - expo-glass-effect@58.0.3 | 74 | - expo-glass-effect@58.0.3 |