From ed97a41c0c8ac710086ac0d29eee05ffe158bb95 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 6 Oct 2026 15:26:28 -0500 Subject: [PATCH 01/21] feat(web): find messages and plans in the current thread --- .../t3-markdown-text/src/markdownLinks.ts | 6 +- .../src/nativeMarkdownText.ts | 2 +- .../features/threads/ThreadDetailScreen.tsx | 2 +- .../src/features/threads/ThreadFeed.tsx | 6 +- .../src/features/threads/fileChipMenu.ts | 2 +- .../features/threads/thread-search-match.tsx | 43 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 17 + .../src/orchestration-v2/ProjectionStore.ts | 25 + .../ProviderTurnControlService.test.ts | 1 + .../src/orchestration-v2/ThreadFind.test.ts | 388 ++++++++++++++++ .../server/src/orchestration-v2/ThreadFind.ts | 253 +++++++++++ .../ThreadManagementService.ts | 11 + .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.ts | 6 + apps/web/package.json | 3 - apps/web/src/components/ChatMarkdown.test.tsx | 61 +++ apps/web/src/components/ChatMarkdown.tsx | 410 ++--------------- .../web/src/components/ChatView.logic.test.ts | 2 +- apps/web/src/components/ChatView.logic.ts | 2 +- apps/web/src/components/ChatView.tsx | 43 +- apps/web/src/components/CommandPalette.tsx | 15 + apps/web/src/components/GitActionsControl.tsx | 2 +- .../src/components/ThreadTerminalDrawer.tsx | 3 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 6 +- .../components/chat/MessagesTimeline.logic.ts | 2 +- .../src/components/chat/MessagesTimeline.tsx | 427 +++++++++++++----- .../src/components/chat/ProposedPlanCard.tsx | 21 +- .../web/src/components/chat/ThreadFindBar.tsx | 115 +++++ .../components/chat/markdownFindContext.ts | 4 + .../src/components/chat/threadFind.test.ts | 251 ++++++++++ apps/web/src/components/chat/threadFind.ts | 85 ++++ .../components/chat/threadFindActionBus.ts | 10 + .../components/chat/threadFindHighlights.ts | 139 ++++++ .../components/chat/useThreadFind.test.tsx | 176 ++++++++ apps/web/src/components/chat/useThreadFind.ts | 179 ++++++++ .../chat/useThreadFindHistory.test.tsx | 104 +++++ .../components/chat/useThreadFindHistory.ts | 42 ++ .../src/components/files/FileBreadcrumbs.tsx | 2 +- .../components/files/FileMarkdownPreview.tsx | 2 +- .../src/components/files/FilePreviewPanel.tsx | 3 +- apps/web/src/components/files/filePath.ts | 4 +- .../src/components/files/filePreviewMode.ts | 3 +- .../components/settings/ThemeEditorPanel.tsx | 18 + apps/web/src/components/ui/input-group.tsx | 9 +- apps/web/src/diffFileActions.ts | 2 +- apps/web/src/fileContextMenu.ts | 2 +- apps/web/src/filePathDisplay.ts | 7 +- apps/web/src/index.css | 14 + apps/web/src/keybindings.test.ts | 8 + apps/web/src/markdown-incremental.test.tsx | 11 +- apps/web/src/markdown-links.test.ts | 260 ----------- apps/web/src/markdown-links.ts | 53 +-- ...test.tsx => markdownGithubAlerts.test.tsx} | 2 +- ...t.tsx => markdownListIndentation.test.tsx} | 2 +- apps/web/src/openVsxThemes.test.ts | 24 +- apps/web/src/openVsxThemes.ts | 4 + apps/web/src/proposedPlan.test.ts | 26 -- apps/web/src/proposedPlan.ts | 21 +- apps/web/src/terminal-links.test.ts | 2 +- apps/web/src/terminal-links.ts | 63 --- apps/web/src/themePalette.test.ts | 69 +++ apps/web/src/themePalette.ts | 24 + apps/web/src/vscodeThemeImport.test.ts | 94 ++++ apps/web/src/vscodeThemeImport.ts | 22 + docs/user/keybindings.md | 8 + packages/client-runtime/package.json | 24 +- packages/client-runtime/src/markdownImages.ts | 7 +- packages/client-runtime/src/mediaReference.ts | 2 +- packages/client-runtime/src/mediaSource.ts | 8 +- .../client-runtime/src/state/orchestration.ts | 6 + .../src/state/subagentDisplay.ts | 2 +- packages/contracts/src/keybindings.test.ts | 6 + packages/contracts/src/keybindings.ts | 1 + packages/contracts/src/orchestrationV2.ts | 36 ++ packages/contracts/src/rpc.ts | 12 + packages/contracts/src/server.ts | 1 + packages/shared/package.json | 55 +++ .../src/codexArtifactTemplates.test.ts | 2 +- .../src/codexArtifactTemplates.ts | 0 .../src/codexFileCitations.test.ts | 2 +- .../src/codexFileCitations.ts | 0 .../src/codexMarkdownDirectives.test.ts | 4 +- .../src/codexMarkdownDirectives.ts | 0 packages/shared/src/composerContextLegacy.ts | 6 +- packages/shared/src/fileLinks.test.ts | 65 +++ packages/shared/src/fileLinks.ts | 183 ++++++++ packages/shared/src/keybindings.ts | 1 + .../shared/src/markdownGithubAlerts.ts | 0 .../src/markdownLinks.test.ts | 128 +++++- .../src/markdownLinks.ts | 146 +++--- .../shared/src/markdownListIndentation.ts | 0 packages/shared/src/markdownPipeline.ts | 232 ++++++++++ packages/shared/src/path.test.ts | 39 ++ packages/shared/src/path.ts | 37 ++ packages/shared/src/proposedPlanText.test.ts | 26 ++ packages/shared/src/proposedPlanText.ts | 20 + packages/shared/src/themePalettes.ts | 52 +++ packages/shared/src/threadFindText.test.ts | 114 +++++ packages/shared/src/threadFindText.ts | 175 +++++++ packages/shared/src/threadSearch.test.ts | 23 + packages/shared/src/threadSearch.ts | 70 +++ pnpm-lock.yaml | 60 +-- 105 files changed, 4016 insertions(+), 1157 deletions(-) create mode 100644 apps/server/src/orchestration-v2/ThreadFind.test.ts create mode 100644 apps/server/src/orchestration-v2/ThreadFind.ts create mode 100644 apps/web/src/components/chat/ThreadFindBar.tsx create mode 100644 apps/web/src/components/chat/markdownFindContext.ts create mode 100644 apps/web/src/components/chat/threadFind.test.ts create mode 100644 apps/web/src/components/chat/threadFind.ts create mode 100644 apps/web/src/components/chat/threadFindActionBus.ts create mode 100644 apps/web/src/components/chat/threadFindHighlights.ts create mode 100644 apps/web/src/components/chat/useThreadFind.test.tsx create mode 100644 apps/web/src/components/chat/useThreadFind.ts create mode 100644 apps/web/src/components/chat/useThreadFindHistory.test.tsx create mode 100644 apps/web/src/components/chat/useThreadFindHistory.ts rename apps/web/src/{markdown-github-alerts.test.tsx => markdownGithubAlerts.test.tsx} (96%) rename apps/web/src/{markdown-list-indentation.test.tsx => markdownListIndentation.test.tsx} (96%) rename packages/{client-runtime => shared}/src/codexArtifactTemplates.test.ts (99%) rename packages/{client-runtime => shared}/src/codexArtifactTemplates.ts (100%) rename packages/{client-runtime => shared}/src/codexFileCitations.test.ts (98%) rename packages/{client-runtime => shared}/src/codexFileCitations.ts (100%) rename packages/{client-runtime => shared}/src/codexMarkdownDirectives.test.ts (98%) rename packages/{client-runtime => shared}/src/codexMarkdownDirectives.ts (100%) create mode 100644 packages/shared/src/fileLinks.test.ts create mode 100644 packages/shared/src/fileLinks.ts rename apps/web/src/markdown-github-alerts.ts => packages/shared/src/markdownGithubAlerts.ts (100%) rename packages/{client-runtime => shared}/src/markdownLinks.test.ts (66%) rename packages/{client-runtime => shared}/src/markdownLinks.ts (71%) rename apps/web/src/markdown-list-indentation.ts => packages/shared/src/markdownListIndentation.ts (100%) create mode 100644 packages/shared/src/markdownPipeline.ts create mode 100644 packages/shared/src/proposedPlanText.test.ts create mode 100644 packages/shared/src/proposedPlanText.ts create mode 100644 packages/shared/src/threadFindText.test.ts create mode 100644 packages/shared/src/threadFindText.ts create mode 100644 packages/shared/src/threadSearch.test.ts create mode 100644 packages/shared/src/threadSearch.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 8b491c0c77c1..913f7cb37167 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -1,10 +1,10 @@ +import { fileBasename } from "@t3tools/shared/path"; +import { formatFilePathPosition } from "@t3tools/shared/fileLinks"; import { - fileBasename, - formatFilePathPosition, inlineCodeFilePathCandidate, normalizeMarkdownLinkDestination, parseMarkdownFileLink, -} from "@t3tools/client-runtime/markdown-links"; +} from "@t3tools/shared/markdownLinks"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { videoMimeType } from "@t3tools/shared/video"; diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 6ae50075093f..58df022faf1d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,5 +1,5 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; -import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; +import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { imageMimeType } from "@t3tools/shared/image"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ea6353578302..9a2fc45d5e1c 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -29,7 +29,7 @@ import type { import { appendCodexArtifactTemplateUsePrompt, type CodexArtifactTemplate, -} from "@t3tools/client-runtime/codex-artifact-templates"; +} from "@t3tools/shared/codexArtifactTemplates"; import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; import { presentPendingBackgroundWork, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4d81c6f07063..4a91ad7f1000 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -36,9 +36,9 @@ import { writeComposerContextClipboard } from "../../lib/composerContextClipboar import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, -} from "@t3tools/client-runtime/codex-artifact-templates"; +} from "@t3tools/shared/codexArtifactTemplates"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; +import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -50,7 +50,7 @@ import { resolveViewedImageAsset } from "@t3tools/client-runtime/work-log/presen import { renderCodexFileCitationsAsMarkdown, splitCodexArtifactTemplateMarkdown, -} from "@t3tools/client-runtime/codex-markdown-directives"; +} from "@t3tools/shared/codexMarkdownDirectives"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { imageMimeType } from "@t3tools/shared/image"; import { videoMimeType } from "@t3tools/shared/video"; diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 9f82e089444d..9835c0c37cc1 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,4 +1,4 @@ -import { fileBasename } from "@t3tools/client-runtime/markdown-links"; +import { fileBasename } from "@t3tools/shared/path"; import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 553160dbdd8c..1f03e2c2adf0 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -1,48 +1,9 @@ +import { splitThreadSearchText } from "@t3tools/shared/threadSearch"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; -function foldAsciiCase(value: string): string { - return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); -} - -function splitHighlightParts(text: string, query: string) { - const normalizedText = foldAsciiCase(text); - const normalizedQuery = foldAsciiCase(query.trim()); - if (normalizedQuery.length === 0) { - return [{ text, highlighted: false, start: 0 }]; - } - - const parts: Array<{ - readonly text: string; - readonly highlighted: boolean; - readonly start: number; - }> = []; - let cursor = 0; - while (cursor < text.length) { - const matchIndex = normalizedText.indexOf(normalizedQuery, cursor); - if (matchIndex === -1) { - parts.push({ text: text.slice(cursor), highlighted: false, start: cursor }); - break; - } - if (matchIndex > cursor) { - parts.push({ - text: text.slice(cursor, matchIndex), - highlighted: false, - start: cursor, - }); - } - parts.push({ - text: text.slice(matchIndex, matchIndex + normalizedQuery.length), - highlighted: true, - start: matchIndex, - }); - cursor = matchIndex + normalizedQuery.length; - } - return parts; -} - export function ThreadSearchMatchExcerpt(props: { readonly match: EnvironmentThreadSearchMatch; readonly query: string; @@ -51,7 +12,7 @@ export function ThreadSearchMatchExcerpt(props: { readonly sidebar?: boolean; }) { const isUser = props.match.source === "user"; - const parts = splitHighlightParts(props.match.snippet, props.query); + const parts = splitThreadSearchText(props.match.snippet, props.query); return ( Effect.Effect; + readonly searchThread: ( + input: OrchestrationV2SearchThreadInput, + ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, options: ProjectionTimelinePageOptions, @@ -10800,6 +10807,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio recoverDelegatedTask, delegatedTaskResultPending, dispatch: dispatchWithReceipt, + searchThread: (input) => + projectionStore + .searchThread(input) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), + ), + ), getTimelinePage: (threadId, options) => projectionStore .getTimelinePage(threadId, options) @@ -10935,6 +10950,8 @@ const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + searchThread: (input) => + Effect.fail(new OrchestratorProjectionError({ threadId: input.threadId })), getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ec75937a9a62..18ae04d536cf 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,3 +1,8 @@ +import { makeThreadFind, findProjectedThreadItems } from "./ThreadFind.ts"; +import type { + OrchestrationV2SearchThreadInput, + OrchestrationV2SearchThreadResult, +} from "@t3tools/contracts"; import { latestRootProviderFailure, latestUnheldRun, @@ -308,6 +313,9 @@ export interface ProjectionTimelinePage { } export interface ProjectionStoreV2Shape { + readonly searchThread: ( + input: OrchestrationV2SearchThreadInput, + ) => Effect.Effect; readonly getThreadAttachmentIds: ( threadId: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; @@ -4787,6 +4795,10 @@ export const layer: Layer.Layer = ), ); + const searchThread = yield* makeThreadFind((threadId) => + readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), + ); + const getTimelinePage: ProjectionStoreV2Shape["getTimelinePage"] = (threadId, options) => sql .withTransaction( @@ -5701,6 +5713,7 @@ export const layer: Layer.Layer = getThreadSnapshot, getThreadSnapshotWindow, getTimelinePage, + searchThread, getThreadAttachmentIds, } satisfies ProjectionStoreV2Shape; }), @@ -6257,6 +6270,18 @@ export const layerMemory: Layer.Layer = Layer.effect( ), ), ), + searchThread: (input) => + service + .getThreadSnapshot(input.threadId) + .pipe( + Effect.map((snapshot) => + findProjectedThreadItems( + snapshot.projection.visibleTurnItems, + input, + snapshot.snapshotSequence, + ), + ), + ), getTimelinePage: (threadId, options) => service.getThreadProjection(threadId).pipe( Effect.map((projection) => { diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 7918f819eee2..7a670121962b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -224,6 +224,7 @@ it.effect( getPlan: () => Effect.die("unused"), hasUnpairedRunInterruptRequest: () => Effect.die("unused interrupt read"), getThreadAttachmentIds: () => Effect.die("Unused attachment lookup"), + searchThread: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts new file mode 100644 index 000000000000..fc2427f7d00d --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -0,0 +1,388 @@ +import { assert, it, describe } from "@effect/vitest"; +import { + EventId, + MessageId, + PlanId, + RunId, + RuntimeRequestId, + TurnItemId, + type OrchestrationV2TurnItem, + type OrchestrationV2Run, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2DomainEvent, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProjectStore from "./ProjectStore.ts"; +import * as EventStore from "./EventStore.ts"; + +const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), +); + +const providerInstanceId = ProviderInstanceId.make("codex"); +const at = (minute: number) => DateTime.makeUnsafe(Date.UTC(2026, 8, 27, 0, minute)); + +const createProject = (projectId: ProjectId) => + Effect.flatMap(ProjectStore.ProjectStoreV2, (projects) => + projects.apply({ + sequence: 0, + eventId: EventId.make(`created:${projectId}`), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: DateTime.formatIso(at(0)), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId, + title: projectId, + workspaceRoot: `/work/${projectId}`, + defaultModelSelection: null, + scripts: [], + createdAt: DateTime.formatIso(at(0)), + updatedAt: DateTime.formatIso(at(0)), + }, + }), + ); + +const thread = ( + threadId: ThreadId, + projectId: ProjectId, + overrides: { readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc } = {}, +): OrchestrationV2DomainEvent => ({ + id: EventId.make(`created:${threadId}`), + type: "thread.created", + threadId, + providerInstanceId, + occurredAt: at(0), + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: threadId, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: at(0), + updatedAt: at(0), + archivedAt: overrides.archivedAt ?? null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: overrides.deletedAt ?? null, + }, +}); + +const threadId = ThreadId.make("thread:find"); +const projectId = ProjectId.make("project:find"); +const runId = RunId.make("run:find"); + +function item( + id: string, + ordinal: number, + text: string, + kind: "user_message" | "assistant_message" | "proposed_plan" = "assistant_message", + owner = threadId, +): Extract< + OrchestrationV2TurnItem, + { type: "user_message" | "assistant_message" | "proposed_plan" } +> { + const base = { + id: TurnItemId.make(id), + threadId: owner, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title: null, + // Creation time deliberately opposes the canonical item order. + startedAt: at(100 - ordinal), + completedAt: at(100 - ordinal), + updatedAt: at(1), + }; + if (kind === "proposed_plan") + return { ...base, type: kind, planId: PlanId.make(id), markdown: text, streaming: false }; + if (kind === "user_message") + return { + ...base, + type: kind, + messageId: MessageId.make(id), + text, + inputIntent: "turn_start", + attachments: [], + createdBy: "user", + creationSource: "web", + }; + return { ...base, type: kind, messageId: MessageId.make(id), text, streaming: false }; +} + +function run( + owner = threadId, + id = runId, + ordinal = 1, + status: OrchestrationV2Run["status"] = "completed", +): Extract { + return { + id: EventId.make(`run:${owner}:${id}:${status}`), + threadId: owner, + occurredAt: at(1), + type: "run.created", + payload: { + id, + threadId: owner, + ordinal, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-6" }, + providerThreadId: null, + userMessageId: MessageId.make("prompt"), + rootNodeId: null, + activeAttemptId: null, + status, + requestedAt: at(1), + startedAt: at(1), + completedAt: at(2), + checkpointId: null, + contextHandoffId: null, + }, + }; +} + +const commit = Effect.fn("ThreadFindTest.commit")(function* ( + events: readonly OrchestrationV2DomainEvent[], +) { + const store = yield* EventStore.EventStoreV2; + const projection = yield* ProjectionStore.ProjectionStoreV2; + yield* store.append({ events }); + yield* Effect.forEach(events, projection.apply, { discard: true }); +}); +const putItems = (items: readonly OrchestrationV2TurnItem[], version = "initial") => + commit( + items.map((payload) => ({ + id: EventId.make(`${payload.threadId}:${payload.id}:${version}`), + threadId: payload.threadId, + occurredAt: at(1), + type: "turn-item.updated" as const, + payload, + })), + ); +const setup = Effect.gen(function* () { + yield* createProject(projectId); + yield* commit([thread(threadId, projectId), run()]); + return yield* ProjectionStore.ProjectionStoreV2; +}); + +describe("V2 thread find", () => { + it.effect("searches visible rendered messages and plans in canonical item order", () => + Effect.gen(function* () { + const projection = yield* setup; + yield* putItems([ + item("user", 1, "Check **the build**", "user_message"), + item("assistant", 2, "check the build twice: check the build"), + item("plan-item", 3, "# Check the build\nShip it", "proposed_plan"), + item("url", 4, "[documentation](https://hidden.example/needle)"), + ]); + const matches = []; + for (let index = 0; index < 4; index++) { + const result = yield* projection.searchThread({ + threadId, + query: "CHECK THE BUILD", + index, + }); + assert.equal(result.totalMatches, 4); + matches.push(result.match); + } + assert.deepEqual(matches, [ + { entryId: "user", runId, occurrence: 0 }, + { entryId: "assistant", runId, occurrence: 0 }, + { entryId: "assistant", runId, occurrence: 1 }, + { entryId: "plan-item", runId, occurrence: 0 }, + ]); + assert.equal( + (yield* projection.searchThread({ threadId, query: "hidden.example" })).totalMatches, + 0, + ); + assert.equal( + (yield* projection.searchThread({ threadId, query: "check", index: 99 })).activeIndex, + 3, + ); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("returns only nearby rows for a match beyond the recent history window", () => + Effect.gen(function* () { + const projection = yield* setup; + const items = Array.from({ length: 520 }, (_, i) => + item(`message:${i}`, i + 1, i === 8 || i === 510 ? "sentinel" : "other message"), + ); + yield* putItems(items); + const first = yield* projection.searchThread({ threadId, query: "sentinel" }); + const last = yield* projection.searchThread({ threadId, query: "sentinel", index: 1 }); + assert.equal(first.totalMatches, 2); + assert.equal(first.match?.entryId, "message:8"); + assert.equal(last.match?.entryId, "message:510"); + assert.lengthOf(first.items, 5); + assert.lengthOf(last.items, 5); + assert.deepEqual( + last.items.map((row) => row.item.id), + ["message:508", "message:509", "message:510", "message:511", "message:512"], + ); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("invalidates cached counts when streamed text changes or a run rolls back", () => + Effect.gen(function* () { + const projection = yield* setup; + const original = item("streaming", 1, "needle"); + yield* putItems([original]); + assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 1); + if (original.type !== "assistant_message") return; + yield* putItems([{ ...original, text: "needle needle", streaming: true }], "streaming"); + assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 2); + const updated = run(threadId, runId, 1, "rolled_back"); + yield* commit([{ ...updated, type: "run.updated" }]); + assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 0); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect( + "does not search undispatched inputs, cancelled queue rows, or folded question answers", + () => + Effect.gen(function* () { + const projection = yield* setup; + const queuedRunId = RunId.make("queued:cancelled"); + yield* commit([run(threadId, queuedRunId, 2, "cancelled")]); + const queued = item("cancelled", 1, "needle", "user_message"); + if (queued.type !== "user_message") return; + const answer = item("async-answer:question", 2, "needle", "user_message"); + const question: OrchestrationV2TurnItem = { + ...item("question", 3, ""), + type: "user_input_request", + requestId: RuntimeRequestId.make("question"), + questions: [], + questionAnswer: { requestId: "question", answers: {}, attachmentsByQuestionId: {} }, + }; + yield* putItems([ + { ...queued, runId: queuedRunId, inputIntent: "queued_turn" }, + answer, + question, + ]); + const event: OrchestrationV2DomainEvent = { + id: EventId.make("undispatched"), + threadId, + occurredAt: at(2), + type: "message.updated", + payload: { + id: MessageId.make("undispatched"), + threadId, + runId: null, + nodeId: null, + role: "user", + text: "needle", + attachments: [], + streaming: false, + createdBy: "user", + creationSource: "web", + createdAt: at(2), + updatedAt: at(2), + }, + }; + yield* commit([event]); + const result = yield* projection.searchThread({ threadId, query: "needle" }); + assert.equal(result.totalMatches, 0); + assert.deepEqual(result.items, []); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect( + "searches inherited fork history through the fork boundary, using source item identities", + () => + Effect.gen(function* () { + const projection = yield* setup; + const parent = ThreadId.make("thread:parent"); + const laterRun = RunId.make("run:later"); + const parentRun = RunId.make("run:parent"); + yield* commit([ + thread(parent, projectId), + run(parent, parentRun), + run(parent, laterRun, 2), + ]); + yield* putItems([ + { ...item("inherited", 1, "needle", "assistant_message", parent), runId: parentRun }, + { ...item("after-fork", 2, "needle", "assistant_message", parent), runId: laterRun }, + ]); + const child = thread(threadId, projectId); + if (child.type !== "thread.created") return; + yield* commit([ + { + ...child, + id: EventId.make("child:fork"), + type: "thread.metadata-updated", + payload: { + ...child.payload, + forkedFrom: { type: "run", threadId: parent, runId: parentRun }, + }, + }, + ]); + yield* putItems([item("local", 1, "needle")]); + const inherited = yield* projection.searchThread({ threadId, query: "needle" }); + assert.equal(inherited.totalMatches, 2); + assert.equal(inherited.match?.entryId, "inherited"); + assert.equal(inherited.items[0]?.visibility, "inherited"); + assert.equal(inherited.items[0]?.sourceThreadId, parent); + assert.equal( + (yield* projection.searchThread({ threadId, query: "needle", index: 1 })).match?.entryId, + "local", + ); + // Parent updates invalidate a child's cached inherited result too. + yield* putItems( + [{ ...item("inherited", 1, "gone", "assistant_message", parent), runId: parentRun }], + "parent-update", + ); + assert.equal( + (yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, + 1, + ); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("rejects deleted threads and keeps results scoped to their thread", () => + Effect.gen(function* () { + const projection = yield* setup; + const other = ThreadId.make("thread:other"); + yield* commit([thread(other, projectId), run(other, RunId.make("run:other"))]); + yield* putItems([ + item("shared-id", 1, "needle"), + item("other-id", 1, "other text", "assistant_message", other), + ]); + assert.equal((yield* projection.searchThread({ threadId, query: "needle" })).totalMatches, 1); + assert.equal( + (yield* projection.searchThread({ threadId: other, query: "needle" })).totalMatches, + 0, + ); + const deleted = thread(threadId, projectId, { deletedAt: at(3) }); + if (deleted.type !== "thread.created") return; + yield* commit([{ ...deleted, id: EventId.make("deleted"), type: "thread.deleted" }]); + yield* Effect.flip(projection.searchThread({ threadId, query: "needle" })); + }).pipe(Effect.provide(layerTest)), + ); +}); diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts new file mode 100644 index 000000000000..14245210123d --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -0,0 +1,253 @@ +import { + OrchestrationV2TurnItemJson, + type OrchestrationV2ProjectedTurnItem, + type OrchestrationV2SearchThreadInput, + type OrchestrationV2SearchThreadResult, + type OrchestrationV2TurnItem, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText"; +import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; +import * as Cache from "effect/Cache"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; +import { ProjectionStoreReadError, type ProjectionStoreV2Error } from "./ProjectionStore.ts"; + +interface FindRow { + readonly position: number; + readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"]; + readonly sourceThreadId: ThreadId; + readonly sourceItemId: TurnItemId; +} +interface FindDocument extends FindRow { + readonly entryId: string; + readonly runId: OrchestrationV2TurnItem["runId"]; + readonly count: number; +} +class SearchKey extends Data.Class<{ + threadId: ThreadId; + query: string; + sequence: number; + cwd: string | undefined; +}> {} + +function countItem(item: OrchestrationV2TurnItem, query: string, cwd?: string): number { + let segments: readonly string[] | null = null; + if (item.type === "proposed_plan") segments = searchablePlanSegments(item.markdown, cwd); + else if (item.type === "user_message" || item.type === "assistant_message") + segments = searchableMessageSegments( + { + role: item.type === "user_message" ? "user" : "assistant", + text: item.text, + streaming: item.type === "assistant_message" && item.streaming, + context: item.type === "user_message" ? item.context : undefined, + }, + cwd, + ); + return segments?.reduce((sum, text) => sum + countThreadSearchOccurrences(text, query), 0) ?? 0; +} + +function selectMatch(documents: readonly FindDocument[], requestedIndex: number) { + const totalMatches = documents.reduce((sum, doc) => sum + doc.count, 0); + const activeIndex = Math.min(requestedIndex, Math.max(0, totalMatches - 1)); + let occurrence = activeIndex; + for (const document of documents) { + if (occurrence < document.count) return { totalMatches, activeIndex, document, occurrence }; + occurrence -= document.count; + } + return { totalMatches, activeIndex, document: null, occurrence: 0 }; +} + +/** The memory projection uses the same match ordering and bounded context as SQLite. */ +export function findProjectedThreadItems( + items: readonly OrchestrationV2ProjectedTurnItem[], + input: OrchestrationV2SearchThreadInput, + snapshotSequence: number, + cwd?: string, +): OrchestrationV2SearchThreadResult { + const foldedAnswers = new Set( + items.flatMap(({ item }) => + item.type === "user_input_request" && item.questionAnswer + ? [`async-answer:${item.questionAnswer.requestId}`] + : [], + ), + ); + const rows = items.filter( + ({ item }) => + ["user_message", "assistant_message", "proposed_plan"].includes(item.type) && + !(item.type === "user_message" && foldedAnswers.has(item.messageId)), + ); + const docs: FindDocument[] = rows.map((row) => ({ + ...row, + entryId: + row.item.type === "user_message" || row.item.type === "assistant_message" + ? row.item.messageId + : row.item.id, + runId: row.item.runId, + count: countItem(row.item, input.query, cwd), + })); + const selected = selectMatch(docs, input.index ?? 0); + const index = selected.document === null ? -1 : docs.indexOf(selected.document); + return { + snapshotSequence, + totalMatches: selected.totalMatches, + activeIndex: selected.activeIndex, + match: + selected.document === null + ? null + : { + entryId: selected.document.entryId, + runId: selected.document.runId, + occurrence: selected.occurrence, + }, + items: index < 0 ? [] : rows.slice(Math.max(0, index - 2), index + 3), + }; +} + +/** Read the canonical index once per scan, including inherited fork rows, without tool bodies. */ +export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( + readIndex: (threadId: ThreadId) => Effect.Effect< + readonly (Omit & { + readonly item: Pick; + })[], + ProjectionStoreV2Error + >, +) { + const sql = yield* SqlClient.SqlClient; + const encodeSources = Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(Schema.Struct({ threadId: ThreadId, id: TurnItemId }))), + ); + const decodeItem = Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2TurnItemJson)); + const revision = (threadId: ThreadId) => + sql<{ sequence: number }>` + SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_events + `.pipe( + Effect.map((rows) => rows[0]?.sequence ?? 0), + Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })), + ); + const changed = (threadId: ThreadId) => new ProjectionStoreReadError({ threadId }); + const load = Effect.fn("ThreadFind.load")(function* ( + threadId: ThreadId, + rows: readonly FindRow[], + ) { + if (rows.length === 0) return []; + const sources = yield* encodeSources( + rows.map((row) => ({ threadId: row.sourceThreadId, id: row.sourceItemId })), + ).pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))); + const payloads = yield* sql<{ thread_id: string; turn_item_id: string; payload_json: string }>` + SELECT item.thread_id, item.turn_item_id, item.payload_json + FROM orchestration_v2_projection_turn_items AS item + JOIN json_each(${sources}) AS wanted + ON item.thread_id = json_extract(wanted.value, '$.threadId') + AND item.turn_item_id = json_extract(wanted.value, '$.id') + `.pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))); + const byThread = new Map>(); + for (const row of payloads) { + let items = byThread.get(row.thread_id); + if (!items) byThread.set(row.thread_id, (items = new Map())); + items.set(row.turn_item_id, row.payload_json); + } + return yield* Effect.forEach(rows, (row) => + Effect.gen(function* () { + const payload = byThread.get(row.sourceThreadId)?.get(row.sourceItemId); + if (payload === undefined) return yield* changed(threadId); + const item = yield* decodeItem(payload).pipe( + Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })), + ); + return { ...row, item }; + }), + ); + }); + const scan = Effect.fn("ThreadFind.scan")(function* (key: SearchKey) { + const index = yield* readIndex(key.threadId); + const rows = index.map((row, position) => ({ ...row, position })); + // Question answers render inside their request card, not as searchable message bubbles. + const questions = rows.filter((row) => row.item.type === "user_input_request"); + const foldedAnswers = new Set(); + for (let start = 0; start < questions.length; start += 128) { + for (const { item } of yield* load(key.threadId, questions.slice(start, start + 128))) { + if (item.type === "user_input_request" && item.questionAnswer) + foldedAnswers.add(`async-answer:${item.questionAnswer.requestId}`); + } + yield* Effect.yieldNow; + } + const candidates = rows.filter((row) => + ["user_message", "assistant_message", "proposed_plan"].includes(row.item.type), + ); + const context: FindRow[] = []; + const documents: FindDocument[] = []; + for (let start = 0; start < candidates.length; start += 128) { + const batch = yield* load(key.threadId, candidates.slice(start, start + 128)); + for (const { item, ...row } of batch) { + if (item.type === "user_message" && foldedAnswers.has(item.messageId)) continue; + context.push(row); + const count = countItem(item, key.query, key.cwd); + if (count > 0) + documents.push({ + ...row, + count, + runId: item.runId, + entryId: + item.type === "user_message" || item.type === "assistant_message" + ? item.messageId + : item.id, + }); + } + yield* Effect.yieldNow; + } + if ((yield* revision(key.threadId)) !== key.sequence) return yield* changed(key.threadId); + return { documents, context }; + }); + // Keep IDs/counts only. Navigation never retains transcript bodies or one record per occurrence. + const cache = yield* Cache.makeWith(scan, { + capacity: 16, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.context.length <= 10_000 ? "1 minute" : 0, + }); + return Effect.fn("ProjectionStore.searchThread")(function* ( + input: OrchestrationV2SearchThreadInput, + ) { + const active = yield* sql<{ cwd: string | null }>` + SELECT COALESCE(json_extract(t.payload_json, '$.worktreePath'), p.workspace_root) AS cwd + FROM orchestration_v2_projection_threads t JOIN projection_projects p ON p.project_id = t.project_id + WHERE t.thread_id = ${input.threadId} AND t.deleted_at IS NULL AND p.deleted_at IS NULL + `.pipe( + Effect.mapError((cause) => new ProjectionStoreReadError({ threadId: input.threadId, cause })), + ); + if (!active[0]) return yield* changed(input.threadId); + const snapshotSequence = yield* revision(input.threadId); + const { documents, context } = yield* Cache.get( + cache, + new SearchKey({ + threadId: input.threadId, + query: input.query, + sequence: snapshotSequence, + cwd: active[0].cwd ?? undefined, + }), + ); + const selected = selectMatch(documents, input.index ?? 0); + const document = selected.document; + const index = + document === null ? -1 : context.findIndex((row) => row.position === document.position); + const items = + index < 0 + ? [] + : yield* load(input.threadId, context.slice(Math.max(0, index - 2), index + 3)); + if ((yield* revision(input.threadId)) !== snapshotSequence) + return yield* changed(input.threadId); + return { + snapshotSequence, + totalMatches: selected.totalMatches, + activeIndex: selected.activeIndex, + match: + document === null + ? null + : { entryId: document.entryId, runId: document.runId, occurrence: selected.occurrence }, + items, + }; + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a450f34df5f0..00099b8af745 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1,3 +1,7 @@ +import type { + OrchestrationV2SearchThreadInput, + OrchestrationV2SearchThreadResult, +} from "@t3tools/contracts"; import type { ProjectionRecordField, ProjectionRecordFilter, @@ -271,6 +275,9 @@ export type ThreadManagementError = typeof ThreadManagementError.Type; type ThreadManagementFailure = ThreadManagementError | Orchestrator.OrchestratorV2Error; export interface ThreadManagementServiceShape { + readonly searchThread: ( + input: OrchestrationV2SearchThreadInput, + ) => Effect.Effect; readonly ensureLegacyTranscript: ( threadId: ThreadId, ) => Effect.Effect; @@ -793,6 +800,10 @@ const make = Effect.gen(function* () { return ThreadManagementService.of({ ensureLegacyTranscript, + searchThread: (input) => + ensureProjectionTranscript(input.threadId).pipe( + Effect.andThen(orchestrator.searchThread(input)), + ), dispatch, getTimelinePage: (threadId, options) => ensureProjectionTranscript(threadId).pipe( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 79f100fb46e9..214b43f31f0f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -192,6 +192,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( }), ensureLegacyTranscript: unused, dispatch: unused, + searchThread: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getTurnItem: () => Effect.die("unused turn item read"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fab4edb44406..6448bfc6caea 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -52,6 +52,7 @@ import { type AcpRegistrySetProviderInput, OrchestrationGetFullThreadDiffError, OrchestrationSearchThreadsError, + OrchestrationV2SearchThreadError, OrchestrationGetTurnDiffError, ORCHESTRATION_V2_WS_METHODS, ORCHESTRATION_PROTOCOL_QUERY_PARAM, @@ -1722,6 +1723,7 @@ const layerWsRpc = ( shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, threadSnapshotPagination: true, + threadFind: true, ...Option.match(scratchWorkspaceRoot, { onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), @@ -1888,6 +1890,10 @@ const layerWsRpc = ( }), ), ), + [ORCHESTRATION_V2_WS_METHODS.searchThread]: (input) => + threadManagement + .searchThread(input) + .pipe(Effect.mapError((cause) => new OrchestrationV2SearchThreadError({ cause }))), [ORCHESTRATION_V2_WS_METHODS.searchThreads]: (input) => threadSearch.search(input).pipe( Effect.mapError( diff --git a/apps/web/package.json b/apps/web/package.json index d17795f15c5d..18c096548ed8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,9 +55,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", - "rehype-raw": "^7.0.0", - "rehype-sanitize": "^6.0.0", - "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "unbash": "^5.0.0", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index b1ceb33062c8..274503a54e79 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { EnvironmentId, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { MarkdownFindContext } from "./chat/markdownFindContext"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -1034,3 +1035,63 @@ describe("ChatMarkdown Windows file links", () => { expect(html).not.toContain("chat-markdown-file-link"); }); }); + +it("opens nested disclosures for find and restores their prior state when find closes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const render = (searching: boolean) => ( + + + + ); + try { + await act(async () => { + renderer = create(render(false)); + }); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("needle"); + await act(async () => { + renderer!.update(render(true)); + }); + expect(JSON.stringify(renderer!.toJSON())).toContain("needle"); + await act(async () => { + renderer!.update(render(false)); + }); + expect( + renderer!.root + .findAll( + (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", + ) + .every((node) => node.props["aria-expanded"] === false), + ).toBe(true); + const trigger = renderer!.root.findAll( + (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", + )[0]!; + await act(async () => { + trigger.props.onClick({ nativeEvent: new Event("click") }); + }); + await act(async () => { + renderer!.update(render(true)); + }); + await act(async () => { + renderer!.update(render(false)); + }); + expect( + renderer!.root.findAllByProps({ "data-markdown-details-open": "true" }).length, + ).toBeGreaterThan(0); + expect( + renderer!.root + .findAll( + (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", + ) + .map((node) => node.props["aria-expanded"]), + ).toEqual([true, false]); + } finally { + await act(async () => { + renderer?.unmount(); + }); + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25..aa1a43016e33 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,5 +1,24 @@ +import { MarkdownFindContext } from "./chat/markdownFindContext"; +import { + buildFileLinkParentSuffixByPath, + fileLinkLabel, + resolvePathLinkTarget, +} from "@t3tools/shared/fileLinks"; +import { + isWindowsDrivePathHref, + normalizeMarkdownLinkDestination, + extractInlineCodeSpans, + extractMarkdownLinkHrefs, + inlineCodeFilePathCandidate, +} from "@t3tools/shared/markdownLinks"; +import { isAbsolutePath } from "@t3tools/shared/path"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { AuthFilesystemReadScope, AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + CHAT_MARKDOWN_REMARK_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + CHAT_MARKDOWN_REHYPE_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, @@ -46,12 +65,11 @@ import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, type CodexArtifactTemplateKind, -} from "@t3tools/client-runtime/codex-artifact-templates"; +} from "@t3tools/shared/codexArtifactTemplates"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; -import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; @@ -83,24 +101,16 @@ import ReactMarkdown from "react-markdown"; import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import remarkBreaks from "remark-breaks"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; -import { parseThreadLinkHref, THREAD_LINK_PROTOCOL } from "@t3tools/shared/threadLinks"; +import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; -import remarkGfm from "remark-gfm"; -import type { Processor } from "unified"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { remarkGithubAlerts } from "../markdown-github-alerts"; import { artifactTemplateFromHastProperties, - CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, - remarkCodexDirectives, renderCodexFileCitationsAsMarkdown, -} from "@t3tools/client-runtime/codex-markdown-directives"; +} from "@t3tools/shared/codexMarkdownDirectives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { resolveMarkdownMediaPreview, @@ -153,11 +163,7 @@ import { serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; -import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { - extractMarkdownLinkHrefs, - isWindowsDrivePathHref, - normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, @@ -165,7 +171,7 @@ import { shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; -import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; +import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; import { readLocalApi } from "../localApi"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; @@ -195,7 +201,6 @@ import { import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewAvailableFor } from "../browser/previewRuntime"; -import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -396,190 +401,7 @@ function orderedListGutterStyle( return { "--list-gutter": `${markerWidth + 2}ch` }; } -type MarkdownImageHastNode = { - type?: string; - value?: string; - tagName?: string; - properties?: Record; - children?: MarkdownImageHastNode[]; -}; -function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { - return (node.children ?? []).filter( - (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), - ); -} - -/** - * An image that is the only content of its block (optionally wrapped in a - * link) is almost always a screenshot or figure, so it gets a reserved slot - * while it loads. Images mixed with text or other images — badge rows, icons - * in a sentence — stay inline at their natural size, since a placeholder taller - * than the image would move the page more than the image itself does. - */ -/** Containers whose sole child image reads as a figure rather than part of a sentence. */ -const STANDALONE_IMAGE_BLOCKS = new Set([ - "p", - "div", - "li", - "td", - "th", - "figure", - "center", - "blockquote", -]); - -function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { - const children = meaningfulHastChildren(node); - if (children.length !== 1) return undefined; - const only = children[0]; - if (only?.type !== "element") return undefined; - if (only.tagName === "img") return only; - // A link, emphasis, or similar inline wrapper around the image still counts - // as long as nothing else shares the block. - return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" - ? soleImageDescendant(only) - : undefined; -} - -function markStandaloneImages(node: MarkdownImageHastNode) { - // A raw `` on its own line reaches the root without a paragraph. - if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { - const image = soleImageDescendant(node); - if (image) image.properties = { ...image.properties, dataStandalone: true }; - } - node.children?.forEach((child) => { - if (child.type === "element") markStandaloneImages(child); - }); -} - -/** Keep unmatched inline `` placeholders from opening an HTML link over later blocks. */ -function rehypePreserveBareAnchorPlaceholders() { - return (tree: MarkdownImageHastNode) => { - const anchors: Array = []; - let rawTextTag: string | undefined; - const visit = (node: MarkdownImageHastNode) => { - if (node.type === "raw" && typeof node.value === "string") { - // Raw blocks can contain several tags. Consume whole tags, quoted attributes, - // and comments so text resembling a closing anchor cannot pair a placeholder. - const tags = /|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g; - let offset = 0; - while (rawTextTag !== "plaintext") { - // Raw text ends at its closing tag even inside comment-looking text. - const matcher = rawTextTag ? new RegExp(``, "gi") : tags; - matcher.lastIndex = offset; - const match = matcher.exec(node.value); - if (!match) break; - const [tag] = match; - offset = matcher.lastIndex; - if (rawTextTag) { - rawTextTag = undefined; - continue; - } - if (tag.startsWith("B\n```", + query: "SearchSourceAlpha", + count: 1, + }, + { + text: "★ Insight ─────\nfirst line\nsecond line", + query: "first line second", + count: 0, + lineBreaks: true, + }, + { text: "Use $test-t3-app now", query: "T3 App Testing", count: 1 }, + { text: "`/tmp/file.ts:42`", query: "file.ts · L42", count: 1, user: true, lineBreaks: true }, + { text: "> [!NOTE]\n> Searchable alert", query: "Searchable alert", count: 1 }, + { + text: "
Folded

Hidden needle

", + query: "Hidden needle", + count: 1, + }, +])( + "highlights the indexed occurrences in $text", + async ({ text, query, count, lineBreaks, user }) => { + const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; + const highlights = new Map>(); + vi.stubGlobal( + "Highlight", + class extends Set { + constructor(...ranges: Range[]) { + super(ranges); + } + }, + ); + vi.stubGlobal("CSS", { highlights, escape: (value: string) => value }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + function Probe() { + useThreadFindHighlights({ + container, + query, + activeRowId: "row", + activeOccurrence: 0, + onActiveRange: () => {}, + }); + return ( +
+
+ + + +
+
+ ); + } + try { + await act(() => root.render()); + await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + const ranges = [...highlights.values()].flatMap((value) => [...value]); + expect(ranges.map((range) => range.toString())).toEqual( + Array.from({ length: count }, () => query), + ); + const segments = + searchableMessageSegments( + { role: user ? "user" : "assistant", text, streaming: false }, + undefined, + skills, + ) ?? []; + expect( + segments.reduce((sum, segment) => sum + countThreadSearchOccurrences(segment, query), 0), + ).toBe(count); + } finally { + await act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }, +); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index aa1a43016e33..c08de21f1cdd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -964,7 +964,8 @@ function MarkdownMermaidCodeBlock({ children: ReactNode; }) { const [showCode, setShowCode] = useState(false); - const showDiagram = !showCode && !isStreaming && code.trim().length > 0; + const searching = use(MarkdownFindContext); + const showDiagram = !searching && !showCode && !isStreaming && code.trim().length > 0; const toggleLabel = showCode ? "Show diagram" : "Show code"; return ( { - threadHistoryControls.onLoadEarlier(); - return true; - }, + onLoadEarlier: threadHistoryControls.onLoadEarlier, } : null, }); @@ -7929,13 +7930,8 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); return; } - if (isThreadFindActive && event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - closeThreadFind(); - focusComposer(); - return; - } + // Let contextual controls claim Escape before the bubbling find handler. + if (isThreadFindActive && event.key === "Escape") return; if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) { event.stopPropagation(); return; @@ -8216,8 +8212,28 @@ export default function ChatView(props: ChatViewProps) { event.stopPropagation(); void runProjectScript(script); }; + const dismissFind = (event: KeyboardEvent) => { + if ( + event.key !== "Escape" || + !isThreadFindActive || + event.defaultPrevented || + event.isComposing || + event.keyCode === 229 || + isCommandPaletteOpen() + ) + return; + const context = getShortcutContext(event.target); + if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return; + event.preventDefault(); + closeThreadFind(); + focusComposer(); + }; window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); + window.addEventListener("keydown", dismissFind); + return () => { + window.removeEventListener("keydown", handler, true); + window.removeEventListener("keydown", dismissFind); + }; }, [ activeProject, activeRightPanelSurface, @@ -11246,12 +11262,18 @@ export default function ChatView(props: ChatViewProps) { activeThreadTitle={activeThread.title} activeProject={activeProject ?? null} rightPanelOpen={inlineRightPanelOwnsTitleBar} - findBar={} onNewThreadInProject={handleNewThreadInActiveProject} {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettings } : {})} /> + { + closeThreadFind(); + focusComposer(); + }} + /> {/* Main content area with optional plan sidebar */} @@ -11381,11 +11403,7 @@ export default function ChatView(props: ChatViewProps) { ? (heldPaintContext?.workspaceRoot ?? undefined) : activeWorkspaceRoot } - skills={ - activeProviderStatus - ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) - : EMPTY_PROVIDER_SKILLS - } + skills={timelineSkills} anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} @@ -11395,6 +11413,7 @@ export default function ChatView(props: ChatViewProps) { onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + onResumeLiveFollow={scrollToEnd} cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} @@ -11404,7 +11423,7 @@ export default function ChatView(props: ChatViewProps) { /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} - {showScrollToBottom && threadFind.timelineProps.searchEntries === null && ( + {showScrollToBottom && !isThreadFindActive && (
- {findBar}
); }); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4c13641dd1b1..edd28efe1d7f 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,5 +1,5 @@ +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { - formatProviderSkillDisplayName, resolveProviderSkillSourceKind, type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 173a964d5efc..74b50b9b4a7a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,3 +1,4 @@ +import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts"; import { CheckpointRef, @@ -28,7 +29,6 @@ import { resolveAssistantMessageCopyState, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, - shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, resolveTimelineToolPresentation, workEntryDisplayLabel, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 18245f40fe24..6482149de81c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -308,10 +308,6 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo return contentLength - scroll - scrollLength <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } -export function shouldPreserveAssistantLineBreaks(text: string): boolean { - return /^★ Insight(?:\s|─)/mu.test(text); -} - export function resolveTimelineMinimapHeightStyle(itemCount: number): string { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index dddb15c49272..aa41b57e52db 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,4 @@ -const NOOP_SEARCH_LAYOUT = () => {}; +import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; import { MarkdownFindContext } from "./markdownFindContext"; import { ComputerUseAppIcon } from "~/components/Icons"; import { useChatCanvas } from "./ChatCanvasContext"; @@ -208,7 +208,6 @@ import { resolveTimelineMinimapTopPercent, resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, - shouldPreserveAssistantLineBreaks, toolGroupAction, workEntryDisplayLabel, workEntryReadOutput, @@ -391,6 +390,9 @@ function TimelineListFooter({ ); } +const NOOP_SEARCH_LAYOUT = () => {}; +const EMPTY_FIND_DIFFS: MessagesTimelineProps["turnDiffSummaries"] = []; + const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, @@ -506,6 +508,8 @@ interface MessagesTimelineProps { onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; + onResumeLiveFollow?: () => void; + findOpen?: boolean; cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; @@ -527,6 +531,27 @@ export function MessagesTimeline(props: MessagesTimelineProps) { const searchListRef = useRef(null); const searchEntries = props.searchEntries; const searching = searchEntries != null; + const origin = useRef<{ threadId: string; following: boolean } | null>(null); + const { + findOpen, + routeThreadKey: threadId, + liveFollowEnabled, + onManualNavigation, + onResumeLiveFollow, + } = props; + useLayoutEffect(() => { + if (origin.current?.threadId !== threadId) origin.current = null; + if (findOpen) { + if (!origin.current) { + origin.current = { threadId, following: liveFollowEnabled }; + onManualNavigation(); + } + } else if (origin.current) { + const following = origin.current.following; + origin.current = null; + if (following) onResumeLiveFollow?.(); + } + }, [findOpen, threadId, liveFollowEnabled, onManualNavigation, onResumeLiveFollow]); return (
{searchEntries && ( @@ -564,7 +589,7 @@ export function MessagesTimeline(props: MessagesTimelineProps) { anchorMessageId={null} latestRun={null} runningRunId={null} - turnDiffSummaries={[]} + turnDiffSummaries={EMPTY_FIND_DIFFS} supportsConversationRollback={false} isWorking={false} runlessWorkActive={false} @@ -1409,9 +1434,17 @@ const ConversationTimeline = memo(function ConversationTimeline({ const activeFindMatchKey = activeFindMatch ? `${findNavigationId}:${normalizedFindQuery}:${activeFindMatch.entryId}:${activeFindMatch.occurrence}` : null; + const revealedFindMatchKeyRef = useRef(null); + const [positionedFindMatchKey, setPositionedFindMatchKey] = useState(null); const revealActiveFindRange = useCallback( (range: Range | null) => { - if (!range) return; + if ( + !range || + !activeFindMatchKey || + positionedFindMatchKey !== activeFindMatchKey || + revealedFindMatchKeyRef.current === activeFindMatchKey + ) + return; const codeScroller = range.startContainer.parentElement?.closest("pre"); if (codeScroller) { @@ -1425,6 +1458,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ const viewportRect = timelineViewportElement?.getBoundingClientRect(); if (!viewportRect || matchRect.height === 0) return; + revealedFindMatchKeyRef.current = activeFindMatchKey; const topBoundary = viewportRect.top + FIND_MATCH_VIEW_MARGIN; const bottomBoundary = viewportRect.bottom - FIND_MATCH_VIEW_MARGIN - contentInsetEndAdjustment; @@ -1438,30 +1472,43 @@ const ConversationTimeline = memo(function ConversationTimeline({ listRef.current?.scrollToOffset({ offset: currentScroll + delta, animated: false }); } }, - [contentInsetEndAdjustment, listRef, timelineViewportElement], + [ + activeFindMatchKey, + positionedFindMatchKey, + contentInsetEndAdjustment, + listRef, + timelineViewportElement, + ], ); const navigatedFindMatchKeyRef = useRef(null); + const activeFindRowIndex = activeFindMatch + ? rows.findIndex((row) => row.id === activeFindMatch.entryId) + : -1; useEffect(() => { - if (!activeFindMatch || !activeFindMatchKey) { + if (!activeFindMatchKey) { navigatedFindMatchKeyRef.current = null; + revealedFindMatchKeyRef.current = null; return; } - - const rowIndex = rows.findIndex((row) => row.id === activeFindMatch.entryId); - if (rowIndex === -1) return; - - if (navigatedFindMatchKeyRef.current === activeFindMatchKey) return; - navigatedFindMatchKeyRef.current = activeFindMatchKey; - - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: rowIndex, - animated: false, - viewOffset: FIND_MATCH_VIEW_MARGIN, - }); - }, [activeFindMatch, activeFindMatchKey, listRef, onManualNavigation, rows]); + if (activeFindRowIndex < 0 || navigatedFindMatchKeyRef.current === activeFindMatchKey) return; + let cancelled = false; + void listRef.current + ?.scrollToIndex({ + index: activeFindRowIndex, + animated: false, + viewOffset: FIND_MATCH_VIEW_MARGIN, + }) + .then(() => { + if (cancelled) return; + navigatedFindMatchKeyRef.current = activeFindMatchKey; + setPositionedFindMatchKey(activeFindMatchKey); + }); + return () => { + cancelled = true; + }; + }, [activeFindMatchKey, activeFindRowIndex, listRef]); useThreadFindHighlights({ container: timelineViewportElement, diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 2327577b3a80..5832f8904a5c 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -1,34 +1,20 @@ +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { Children, cloneElement, isValidElement, type ReactNode } from "react"; -import type { ServerProviderSkill } from "@t3tools/contracts"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { matchInlineSkills, type InlineSkill } from "@t3tools/shared/inlineSkills"; import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip"; import { ContextChip, ContextChipLabel } from "../ContextChip"; -const SKILL_TOKEN_REGEX = - /(^|\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; - -type InlineSkill = Pick; - export function SkillInlineText(props: { text: string; skills: ReadonlyArray }) { const nodes: ReactNode[] = []; let cursor = 0; - for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) { - const prefix = match[1] ?? ""; - const name = match[2] ?? ""; - const start = (match.index ?? 0) + prefix.length; - const rawText = `$${name}`; - const skill = props.skills.find((candidate) => candidate.name === name); - if (!skill) { - continue; - } - + for (const { start, end, skill, rawText } of matchInlineSkills(props.text, props.skills)) { if (start > cursor) { nodes.push(props.text.slice(cursor, start)); } - nodes.push(); - cursor = (match.index ?? 0) + match[0].length; + nodes.push(); + cursor = end; } if (cursor === 0) { diff --git a/apps/web/src/components/chat/threadFind.ts b/apps/web/src/components/chat/threadFind.ts index dfa925cce882..411692a4cd56 100644 --- a/apps/web/src/components/chat/threadFind.ts +++ b/apps/web/src/components/chat/threadFind.ts @@ -1,3 +1,4 @@ +import type { InlineSkill } from "@t3tools/shared/inlineSkills"; import type { RunId } from "@t3tools/contracts"; import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; import type { TimelineEntry } from "../../session-logic"; @@ -16,22 +17,23 @@ export interface ThreadFindMatch { // Weak keys reuse parsed text across keystrokes without retaining old messages. const entryTextCache = new WeakMap< object, - { cwd: string | undefined; segments: readonly string[] | null } + { cwd: string | undefined; skills: readonly InlineSkill[]; segments: readonly string[] | null } >(); function searchableThreadEntrySegments( entry: TimelineEntry, cwd?: string, + skills: readonly InlineSkill[] = [], ): readonly string[] | null { if (entry.kind !== "message" && entry.kind !== "proposed-plan") return null; const key = entry.kind === "message" ? entry.message : entry.proposedPlan; const cached = entryTextCache.get(key); - if (cached && cached.cwd === cwd) return cached.segments; + if (cached && cached.cwd === cwd && cached.skills === skills) return cached.segments; const segments = entry.kind === "message" - ? searchableMessageSegments(entry.message, cwd) - : searchablePlanSegments(entry.proposedPlan.planMarkdown, cwd); - entryTextCache.set(key, { cwd, segments }); + ? searchableMessageSegments(entry.message, cwd, skills) + : searchablePlanSegments(entry.proposedPlan.planMarkdown, cwd, skills); + entryTextCache.set(key, { cwd, skills, segments }); return segments; } @@ -45,13 +47,14 @@ export function buildThreadFindMatches( entries: ReadonlyArray, query: string, cwd?: string, + skills: readonly InlineSkill[] = [], ): ThreadFindMatch[] { const normalizedQuery = query.trim(); if (normalizedQuery.length === 0) return []; const matches: ThreadFindMatch[] = []; for (const entry of entries) { - const segments = searchableThreadEntrySegments(entry, cwd); + const segments = searchableThreadEntrySegments(entry, cwd, skills); if (segments === null) continue; const total = segments.reduce( diff --git a/apps/web/src/components/chat/useThreadFind.ts b/apps/web/src/components/chat/useThreadFind.ts index cd9abeadcc7e..339028d63855 100644 --- a/apps/web/src/components/chat/useThreadFind.ts +++ b/apps/web/src/components/chat/useThreadFind.ts @@ -1,3 +1,4 @@ +import type { InlineSkill } from "@t3tools/shared/inlineSkills"; import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -9,6 +10,8 @@ import { buildThreadFindMatches, clampThreadFindIndex, stepThreadFindIndex } fro import { subscribeThreadFindOpen } from "./threadFindActionBus"; import { useThreadFindHistory } from "./useThreadFindHistory"; +const EMPTY_SKILLS: readonly InlineSkill[] = []; + const CLOSED_FIND = { threadKey: null as string | null, query: "", @@ -22,6 +25,7 @@ export function useThreadFind({ thread, serverSearch, cwd, + skills = EMPTY_SKILLS, content, entries, history, @@ -29,6 +33,7 @@ export function useThreadFind({ thread: ScopedThreadRef | null; serverSearch: boolean; cwd: string | undefined; + skills?: readonly InlineSkill[]; content: Pick | undefined; entries: ReadonlyArray; history: Parameters[1]; @@ -53,6 +58,7 @@ export function useThreadFind({ state.query, state.activeIndex, content, + skills, ); const localStatus = useThreadFindHistory( !serverSearch && isOpen && state.query.trim() ? `${threadKey}:${state.focusRequestId}` : null, @@ -70,8 +76,9 @@ export function useThreadFind({ entries, !serverSearch && isOpen && status !== "loading" ? state.query : "", cwd, + skills, ), - [cwd, entries, isOpen, serverSearch, state.query, status], + [cwd, entries, isOpen, serverSearch, skills, state.query, status], ); const count = serverSearch ? (remote.data?.totalMatches ?? 0) : localMatches.length; const activeIndex = serverSearch @@ -88,13 +95,7 @@ export function useThreadFind({ [remote.data], ); const selected = remote.data?.match; - const activeMatch = serverSearch - ? selected && { - entryId: selected.entryId, - runId: selected.runId, - occurrence: selected.occurrence, - } - : localMatches[activeIndex]; + const activeMatch = serverSearch ? selected : localMatches[activeIndex]; const step = (delta: number) => setState((previous) => ({ ...previous, @@ -121,7 +122,8 @@ export function useThreadFind({ onClose: close, }, timelineProps: { - searchEntries, + findOpen: isOpen, + searchEntries: !serverSearch && isOpen && state.query.trim() ? entries : searchEntries, onCloseSearch: close, findQuery: isOpen && (!serverSearch || searchEntries !== null) ? state.query : "", activeFindMatch: activeMatch ?? null, @@ -136,14 +138,19 @@ function useServerResults( query: string, index: number, content: Pick | undefined, + skills: readonly InlineSkill[], ) { + const skillLabels = useMemo( + () => skills.map(({ name, displayName }) => ({ name, displayName })), + [skills], + ); const normalizedQuery = query.trim(); const debouncedQuery = useDebouncedValue(normalizedQuery, 200); const atom = thread && debouncedQuery && normalizedQuery === debouncedQuery ? orchestrationEnvironment.threadFind({ environmentId: thread.environmentId, - input: { threadId: thread.threadId, query: debouncedQuery, index }, + input: { threadId: thread.threadId, query: debouncedQuery, index, skills: skillLabels }, }) : null; const result = useEnvironmentQuery(atom); diff --git a/apps/web/src/components/chat/useThreadFindHistory.test.tsx b/apps/web/src/components/chat/useThreadFindHistory.test.tsx index 20ea1b30a48a..16c3f4d2d002 100644 --- a/apps/web/src/components/chat/useThreadFindHistory.test.tsx +++ b/apps/web/src/components/chat/useThreadFindHistory.test.tsx @@ -50,7 +50,7 @@ afterEach(async () => { describe("thread find history loading", () => { it("loads sequential pages without duplicate requests and finishes only at the oldest page", async () => { - const load = vi.fn(() => true); + const load = vi.fn(); const page = { cursor: "recent", loading: false, onLoadEarlier: load }; await render("thread:1", page); await render("thread:1", { ...page }); @@ -69,7 +69,7 @@ describe("thread find history loading", () => { }); it("does not loop on a failed page and allows an explicit retry", async () => { - const load = vi.fn(() => true); + const load = vi.fn(); const page = { cursor: "recent", loading: false, onLoadEarlier: load }; await render("thread:1", page); await render("thread:1", { ...page, loading: true }); @@ -81,15 +81,8 @@ describe("thread find history loading", () => { expect(load).toHaveBeenCalledTimes(2); }); - it("offers retry if the thread cannot start a history request", async () => { - const load = vi.fn(() => false); - await render("thread:1", { cursor: "recent", loading: false, onLoadEarlier: load }); - expect(status).toBe("incomplete"); - expect(load).toHaveBeenCalledTimes(1); - }); - it("stops when find closes or the query clears, and isolates requests between threads", async () => { - const load = vi.fn(() => true); + const load = vi.fn(); const page = { cursor: "recent", loading: false, onLoadEarlier: load }; await render(null, page); expect(load).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/chat/useThreadFindHistory.ts b/apps/web/src/components/chat/useThreadFindHistory.ts index 2b4bd3b0b41a..1f1d49d59b67 100644 --- a/apps/web/src/components/chat/useThreadFindHistory.ts +++ b/apps/web/src/components/chat/useThreadFindHistory.ts @@ -1,14 +1,10 @@ import { useEffect, useRef, useState } from "react"; import type { CitationHistoryPage } from "./useAssistantCitationTarget"; -type FindHistoryPage = Omit & { - readonly onLoadEarlier: () => boolean; -}; - /** Search needs every history page, but leaves row mounting to the virtual list. */ export function useThreadFindHistory( requestKey: string | null, - page: FindHistoryPage | null, + page: CitationHistoryPage | null, ): "loading" | "incomplete" | null { const requested = useRef<{ key: string; cursors: Set; loading: boolean } | null>(null); const [failedKey, setFailedKey] = useState(null); @@ -34,7 +30,7 @@ export function useThreadFindHistory( return; } requested.current.cursors.add(cursor); - if (!page.onLoadEarlier()) setFailedKey(requestKey); + page.onLoadEarlier(); }, [failedKey, page, requestKey]); if (requestKey === null || page === null) return null; diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index a570c92d91f1..21e182360f9a 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -1,7 +1,7 @@ +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import type { ServerProviderSkill } from "@t3tools/contracts"; import { dedupeProviderSkillsByName, - formatProviderSkillDisplayName, isProviderSkillUserInvocable, } from "@t3tools/client-runtime/providerSkills"; import { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index d5c8933f0bf4..1b9ca671d10c 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -4,11 +4,11 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { describe, expect, it } from "vite-plus/test"; import { dedupeProviderSkillsByName, - formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 4d649cd19f11..fe69c371fc6c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -7,29 +7,10 @@ import { export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; -function titleCaseWords(value: string): string { - const words: string[] = []; - for (const segment of value.split(/[\s:_-]+/)) { - if (segment.length === 0) continue; - words.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - return words.join(" "); -} - function normalizePathSeparators(pathValue: string): string { return pathValue.replaceAll("\\", "/"); } -export function formatProviderSkillDisplayName( - skill: Pick, -): string { - const displayName = skill.displayName?.trim(); - if (displayName) { - return displayName; - } - return titleCaseWords(skill.name); -} - export function dedupeProviderSkillsByName( skills: ReadonlyArray, ): ServerProviderSkill[] { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 9c31d5a8633f..c9884de0d6f9 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3487,6 +3487,15 @@ export class OrchestrationGetWorkflowScriptError extends Schema.TaggedError; + +function titleCaseWords(value: string): string { + const words: string[] = []; + for (const segment of value.split(/[\s:_-]+/)) { + if (segment.length === 0) continue; + words.push(segment.charAt(0).toUpperCase() + segment.slice(1)); + } + return words.join(" "); +} + +export function formatProviderSkillDisplayName( + skill: Pick, +): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return titleCaseWords(skill.name); +} + +const SKILL_TOKEN_REGEX = + /(^|\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; + +export function* matchInlineSkills(text: string, skills: readonly InlineSkill[]) { + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const name = match[2] ?? ""; + const skill = skills.find((candidate) => candidate.name === name); + if (!skill) continue; + const start = match.index + (match[1]?.length ?? 0); + yield { start, end: match.index + match[0].length, skill, rawText: `$${name}` }; + } +} diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 918c0db6d2da..55df21815fba 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -230,3 +230,7 @@ function remarkKeepWindowsPathDestinations(this: Processor) { }, }); } + +export function shouldPreserveAssistantLineBreaks(text: string): boolean { + return /^★ Insight(?:\s|─)/mu.test(text); +} diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index abf0b2f14157..4260207ec69c 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -98,11 +98,11 @@ it("includes the same parent suffixes for duplicate filenames as the renderer", ).toEqual(["first main.ts and main.ts · L2"]); }); -it("keeps literal file paths in fences and user messages", () => { +it("keeps fence paths literal and indexes file-chip labels in user messages", () => { expect(assistantSegments("```text\n/tmp/file.ts:42\n```")).toEqual(["/tmp/file.ts:42\n"]); expect( searchableMessageSegments({ role: "user", text: "`/tmp/file.ts:42`", streaming: false }), - ).toEqual(["/tmp/file.ts:42"]); + ).toEqual(["file.ts · L42"]); }); it("indexes nested disclosure summaries and bodies in rendered order", () => { @@ -112,3 +112,26 @@ it("indexes nested disclosure summaries and bodies in rendered order", () => { ), ).toEqual(["Outer", "first", "Inner", "second"]); }); + +it("uses Insight line breaks without splitting ordinary assistant prose", () => { + expect(assistantSegments("★ Insight ─────\nfirst line\nsecond line")).toEqual([ + "★ Insight ─────", + " first line", + " second line", + ]); + expect(assistantSegments("first line\nsecond line")).toEqual(["first line second line"]); +}); + +it("indexes skill labels in prose but leaves links and code literal", () => { + expect( + searchableMessageSegments( + { + role: "assistant", + streaming: false, + text: "Use $test-t3-app now.\n\n`$test-t3-app`\n\n[$test-t3-app](https://example.com)", + }, + undefined, + [{ name: "test-t3-app", displayName: "T3 App Testing" }], + ), + ).toEqual(["Use T3 App Testing now.", "$test-t3-app", "$test-t3-app"]); +}); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index f11bbd808875..8a7e9c1c71de 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -1,3 +1,8 @@ +import { + matchInlineSkills, + formatProviderSkillDisplayName, + type InlineSkill, +} from "./inlineSkills.ts"; import { renderCodexFileCitationsAsMarkdown } from "./codexMarkdownDirectives.ts"; import { buildFileLinkParentSuffixByPath, @@ -19,6 +24,7 @@ import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { + shouldPreserveAssistantLineBreaks, CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, @@ -69,6 +75,11 @@ const assistantProcessor = unified() .use(CHAT_MARKDOWN_REMARK_PLUGINS) .use(remarkRehype, { allowDangerousHtml: true }) .use(CHAT_MARKDOWN_REHYPE_PLUGINS); +const assistantBreaksProcessor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); const userProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) @@ -87,20 +98,26 @@ interface TextTree { } /** Uses the renderer's Markdown transforms, without mounting folded/virtualized rows. */ -function markdownThreadFindText(markdown: string, userMessage = false, cwd?: string): string[] { - const processor = userMessage ? userProcessor : assistantProcessor; +function markdownThreadFindText( + markdown: string, + userMessage = false, + cwd?: string, + skills: readonly InlineSkill[] = [], + lineBreaks = false, +): string[] { + const processor = userMessage + ? userProcessor + : lineBreaks + ? assistantBreaksProcessor + : assistantProcessor; const tree = processor.runSync(processor.parse(markdown)); - const paths = userMessage - ? [] - : [ - ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)), - ...extractInlineCodeSpans(markdown).flatMap( - (span) => inlineCodeFilePathCandidate(span) ?? [], - ), - ].flatMap((href) => { - const target = resolveMarkdownFileLinkTarget(href, cwd); - return target ? [splitFilePathPosition(target).path] : []; - }); + const paths = [ + ...extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(markdown)), + ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []), + ].flatMap((href) => { + const target = resolveMarkdownFileLinkTarget(href, cwd); + return target ? [splitFilePathPosition(target).path] : []; + }); const parentSuffixes = buildFileLinkParentSuffixByPath(paths); const segments: string[] = []; let text = ""; @@ -108,13 +125,13 @@ function markdownThreadFindText(markdown: string, userMessage = false, cwd?: str if (text.trim()) segments.push(text); text = ""; }; - const visit = (node: TextTree, inPre = false) => { + const visit = (node: TextTree, inPre = false, inlineSkills = false) => { const href = node.properties?.href ?? node.properties?.src; if (userMessage && typeof href === "string" && parseComposerContextHref(href)) { flush(); return; } - if (!userMessage) { + { let candidate: string | null = null; if (node.tagName === "a" && typeof href === "string") { candidate = href; @@ -141,9 +158,24 @@ function markdownThreadFindText(markdown: string, userMessage = false, cwd?: str const block = THREAD_FIND_BLOCK_TAGS.has(node.tagName ?? ""); if (block) flush(); if (node.type === "text" || (userMessage && node.type === "raw")) { - text += inPre ? (node.value ?? "") : (node.value ?? "").replace(/\r?\n/g, " "); + let value = node.value ?? ""; + if (inlineSkills) { + let cursor = 0; + let rendered = ""; + for (const { start, end, skill } of matchInlineSkills(value, skills)) { + rendered += value.slice(cursor, start) + formatProviderSkillDisplayName(skill); + cursor = end; + } + value = rendered + value.slice(cursor); + } + text += inPre ? value : value.replace(/\r?\n/g, " "); } - for (const child of node.children ?? []) visit(child, inPre || node.tagName === "pre"); + const renderSkills = + node.tagName === "code" || node.tagName === "a" + ? false + : inlineSkills || node.tagName === "p" || node.tagName === "li"; + for (const child of node.children ?? []) + visit(child, inPre || node.tagName === "pre", renderSkills); if (block) flush(); }; visit(tree); @@ -151,25 +183,32 @@ function markdownThreadFindText(markdown: string, userMessage = false, cwd?: str return segments; } -export function searchablePlanSegments(markdown: string, cwd?: string): readonly string[] { +export function searchablePlanSegments( + markdown: string, + cwd?: string, + skills: readonly InlineSkill[] = [], +): readonly string[] { return [ proposedPlanTitle(markdown) ?? "Proposed plan", - ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd), + ...markdownThreadFindText(stripDisplayedPlanMarkdown(markdown), false, cwd, skills), ]; } export function searchableMessageSegments( message: Pick, cwd?: string, + skills: readonly InlineSkill[] = [], ): readonly string[] | null { if (message.role === "user") { const text = message.context ? message.text : upgradeLegacyContextMessage(message.text).text; - return markdownThreadFindText(text, true); + return markdownThreadFindText(text, true, cwd, skills); } if (message.role !== "assistant") return null; return markdownThreadFindText( message.text || (message.streaming ? "" : "(empty response)"), false, cwd, + skills, + shouldPreserveAssistantLineBreaks(message.text), ); } From 6c190f21d0627ef08daacbd97e082f8fc2eae1c3 Mon Sep 17 00:00:00 2001 From: Jono Kemball Date: Wed, 7 Oct 2026 16:58:43 +1300 Subject: [PATCH 03/21] fix: address thread search review findings --- .../src/orchestration-v2/ProjectionStore.ts | 24 ++++++----- .../src/orchestration-v2/ThreadFind.test.ts | 40 ++++++++++++++++++- .../server/src/orchestration-v2/ThreadFind.ts | 15 ++++--- apps/web/src/components/ChatMarkdown.test.tsx | 9 ++++- apps/web/src/components/ChatMarkdown.tsx | 12 +++--- apps/web/src/components/CommandPalette.tsx | 9 +++++ packages/shared/src/fileLinks.test.ts | 7 ++++ packages/shared/src/fileLinks.ts | 2 +- packages/shared/src/threadFindText.ts | 16 +++++++- 9 files changed, 109 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 18ae04d536cf..2f4b0054ae81 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -6271,17 +6271,21 @@ export const layerMemory: Layer.Layer = Layer.effect( ), ), searchThread: (input) => - service - .getThreadSnapshot(input.threadId) - .pipe( - Effect.map((snapshot) => - findProjectedThreadItems( - snapshot.projection.visibleTurnItems, - input, - snapshot.snapshotSequence, - ), - ), + service.getThreadSnapshot(input.threadId).pipe( + Effect.flatMap(({ projection, snapshotSequence }) => + projection.thread.deletedAt !== null + ? Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: input.threadId })) + : Effect.succeed( + // This store has no project records, so only worktree paths resolve labels. + findProjectedThreadItems( + projection.visibleTurnItems, + input, + snapshotSequence, + projection.thread.worktreePath ?? undefined, + ), + ), ), + ), getTimelinePage: (threadId, options) => service.getThreadProjection(threadId).pipe( Effect.map((projection) => { diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index d8f2fc160241..916bfcc877b5 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -446,7 +446,45 @@ describe("V2 thread find", () => { const deleted = thread(threadId, projectId, { deletedAt: at(3) }); if (deleted.type !== "thread.created") return; yield* commit([{ ...deleted, id: EventId.make("deleted"), type: "thread.deleted" }]); - yield* Effect.flip(projection.searchThread({ threadId, query: "needle" })); + const error = yield* Effect.flip(projection.searchThread({ threadId, query: "needle" })); + assert.equal(error._tag, "ProjectionStoreThreadNotFoundError"); }).pipe(Effect.provide(layerTest)), ); + + it.effect("memory store resolves worktree file labels and rejects deleted threads", () => + Effect.gen(function* () { + const projection = yield* ProjectionStore.ProjectionStoreV2; + const created = thread(threadId, projectId); + if (created.type !== "thread.created") return; + const withWorktree = { + ...created, + payload: { ...created.payload, worktreePath: "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/work/tree" }, + }; + const message = item("file", 1, "See `docs/notes.md:12`."); + yield* Effect.forEach( + [ + withWorktree, + run(), + { + id: EventId.make("file"), + threadId, + occurredAt: at(1), + type: "turn-item.updated", + payload: message, + }, + ] satisfies OrchestrationV2DomainEvent[], + projection.apply, + { discard: true }, + ); + assert.equal( + (yield* projection.searchThread({ threadId, query: "notes.md · L12" })).totalMatches, + 1, + ); + const deleted = thread(threadId, projectId, { deletedAt: at(3) }); + if (deleted.type !== "thread.created") return; + yield* projection.apply({ ...deleted, id: EventId.make("deleted"), type: "thread.deleted" }); + const error = yield* Effect.flip(projection.searchThread({ threadId, query: "notes" })); + assert.equal(error._tag, "ProjectionStoreThreadNotFoundError"); + }).pipe(Effect.provide(ProjectionStore.layerMemory)), + ); }); diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index 2c727dde5a23..8367521a4c0f 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -14,7 +14,11 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; -import { ProjectionStoreReadError, type ProjectionStoreV2Error } from "./ProjectionStore.ts"; +import { + ProjectionStoreReadError, + ProjectionStoreThreadNotFoundError, + type ProjectionStoreV2Error, +} from "./ProjectionStore.ts"; interface FindRow { readonly position: number; @@ -173,6 +177,9 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( Schema.fromJsonString(Schema.Array(Schema.Struct({ threadId: ThreadId, id: TurnItemId }))), ); const decodeItem = Schema.decodeUnknownEffect(Schema.fromJsonString(OrchestrationV2TurnItemJson)); + const isSnapshotError = Schema.is( + Schema.Union([ProjectionStoreReadError, ProjectionStoreThreadNotFoundError]), + ); const load = Effect.fn("ThreadFind.load")(function* ( threadId: ThreadId, rows: readonly FindRow[], @@ -218,7 +225,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( FROM orchestration_v2_projection_threads t JOIN projection_projects p ON p.project_id = t.project_id WHERE t.thread_id = ${threadId} AND t.deleted_at IS NULL AND p.deleted_at IS NULL `; - if (!active[0]) return yield* new ProjectionStoreReadError({ threadId }); + if (!active[0]) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); const index = yield* readIndex(threadId); const sources = yield* encodeThreadIds([ ...new Set([threadId, ...index.map((row) => row.sourceThreadId)]), @@ -245,9 +252,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( (effect, threadId) => effect.pipe( Effect.mapError((cause) => - Schema.is(ProjectionStoreReadError)(cause) - ? cause - : new ProjectionStoreReadError({ threadId, cause }), + isSnapshotError(cause) ? cause : new ProjectionStoreReadError({ threadId, cause }), ), ), ); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 1ef0b1bd79f5..9f7e0c536f90 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1121,9 +1121,13 @@ it.each([ query: "Hidden needle", count: 1, }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Hello World", count: 1, useTemplate: true }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Document template", count: 1, useTemplate: true }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "World Document", count: 0, useTemplate: true }, + { text: ARTIFACT_TEMPLATE_DIRECTIVE, query: "Use template", count: 0, useTemplate: true }, ])( - "highlights the indexed occurrences in $text", - async ({ text, query, count, lineBreaks, user }) => { + "highlights the indexed occurrences of $query in $text", + async ({ text, query, count, lineBreaks, user, useTemplate }) => { const skills = [{ name: "test-t3-app", displayName: "T3 App Testing" }]; const highlights = new Map>(); vi.stubGlobal( @@ -1157,6 +1161,7 @@ it.each([ skills={skills} lineBreaks={lineBreaks ?? false} parseRawHtml={!user} + onUseArtifactTemplate={useTemplate ? () => undefined : undefined} />
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c08de21f1cdd..361a17f9260e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -327,15 +327,17 @@ function CodexArtifactTemplateCard(props: { - - + {/* Block elements keep the name and label separate thread-find segments. */} +
+
{props.template.displayName} - - {presentationLabel} - +
+
{presentationLabel}
+
{props.onUse ? ( ) : null} diff --git a/apps/web/src/components/chat/threadFind.test.ts b/apps/web/src/components/chat/threadFind.test.ts index 92d63a767237..10511f4a2609 100644 --- a/apps/web/src/components/chat/threadFind.test.ts +++ b/apps/web/src/components/chat/threadFind.test.ts @@ -1,248 +1,8 @@ -import { MessageId, RunId, PlanId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import type { TimelineEntry } from "../../session-logic"; -import { deriveMessagesTimelineRows } from "./MessagesTimeline.logic"; -import { - buildThreadFindMatches, - clampThreadFindIndex, - formatThreadFindCount, - stepThreadFindIndex, -} from "./threadFind"; - -const CREATED_AT = "2026-01-01T00:00:00.000Z"; - -function messageEntry( - id: string, - role: "user" | "assistant" | "system", - text: string, - runId: RunId | null = null, -): TimelineEntry { - return { - id, - kind: "message", - createdAt: CREATED_AT, - message: { - id: MessageId.make(id), - role, - text, - runId, - streaming: false, - createdAt: CREATED_AT, - updatedAt: CREATED_AT, - }, - }; -} - -function workEntry(id: string): TimelineEntry { - return { - id, - kind: "work", - createdAt: CREATED_AT, - entry: { - id, - label: "deploy sentinel", - tone: "tool", - createdAt: CREATED_AT, - }, - }; -} - -function proposedPlanEntry(id: string, planMarkdown: string, runId: RunId | null): TimelineEntry { - return { - id, - kind: "proposed-plan", - createdAt: CREATED_AT, - proposedPlan: { - id: PlanId.make(id), - runId, - planMarkdown, - status: "active", - createdAt: CREATED_AT, - updatedAt: CREATED_AT, - }, - }; -} - -describe("searchable thread entries", () => { - it("searches displayed user text without appended context payloads", () => { - const prompt = [ - "check the build", - "", - "", - "- Terminal 1 line 12:", - " secret sentinel output", - "", - ].join("\n"); - - const entries = [messageEntry("m1", "user", prompt)]; - expect(buildThreadFindMatches(entries, "check the build")).toHaveLength(1); - expect(buildThreadFindMatches(entries, "sentinel")).toHaveLength(0); - }); - - it("excludes terminal labels that render as non-searchable chips", () => { - const prompt = [ - "check @terminal-1:12", - "", - "", - "- Terminal 1 line 12:", - " 12 | output", - "", - ].join("\n"); - - const entries = [messageEntry("m1", "user", prompt)]; - expect(buildThreadFindMatches(entries, "check")).toHaveLength(1); - expect(buildThreadFindMatches(entries, "@terminal-1:12")).toHaveLength(0); - }); - - it("keeps repeated terminal labels that are still visible after the chip", () => { - const prompt = - "check @terminal-1:12 and @terminal-1:12\n\n\n- Terminal 1 line 12:\n 12 | output\n"; - expect( - buildThreadFindMatches([messageEntry("m1", "user", prompt)], "@terminal-1:12"), - ).toHaveLength(1); - }); - - it("excludes terminal chips even when their labels are out of context order", () => { - const prompt = - "@terminal-2:12 then @terminal-1:12\n\n\n- Terminal 1 line 12:\n 12 | first\n- Terminal 2 line 12:\n 12 | second\n"; - expect(buildThreadFindMatches([messageEntry("m1", "user", prompt)], "@terminal-")).toHaveLength( - 0, - ); - }); - - it.each(["user", "assistant"] as const)( - "searches rendered %s Markdown, not link destinations or formatting", - (role) => { - const entries = [ - messageEntry( - "m1", - role, - "[documentation](https://hidden.example/path) foo**bar** and `inline code`", - ), - ]; - expect(buildThreadFindMatches(entries, "hidden.example")).toHaveLength(0); - expect(buildThreadFindMatches(entries, "documentation")).toHaveLength(1); - expect(buildThreadFindMatches(entries, "foobar")).toHaveLength(1); - expect(buildThreadFindMatches(entries, "inline code")).toHaveLength(1); - }, - ); - - it("searches code, escaped punctuation, entities and sanitized HTML as displayed", () => { - const entries = [ - messageEntry( - "m1", - "assistant", - "```ts\nconst value = 1;\n```\n\n\\*literal\\* & bold", - ), - ]; - for (const query of ["const value", "*literal* & bold"]) { - expect(buildThreadFindMatches(entries, query)).toHaveLength(1); - } - for (const query of ["hidden", "strong", "```ts"]) { - expect(buildThreadFindMatches(entries, query)).toHaveLength(0); - } - }); - - it("preserves literal HTML in user messages", () => { - expect( - buildThreadFindMatches([messageEntry("m1", "user", "bold")], ""), - ).toHaveLength(1); - }); - - it("searches plan titles before body matches, including the default title", () => { - const entries = [proposedPlanEntry("p1", "# Release\n\n## Summary\n\nRelease **ready**", null)]; - expect(buildThreadFindMatches(entries, "Release").map((match) => match.occurrence)).toEqual([ - 0, 1, - ]); - expect(buildThreadFindMatches(entries, "Summary")).toHaveLength(0); - expect( - buildThreadFindMatches([proposedPlanEntry("p2", "Body", null)], "Proposed plan"), - ).toHaveLength(1); - }); - - it("does not join separate blocks or the plan title and body into a phrase", () => { - expect( - buildThreadFindMatches([messageEntry("m1", "assistant", "first\n\nsecond")], "firstsecond"), - ).toHaveLength(0); - expect( - buildThreadFindMatches([proposedPlanEntry("p1", "# first\n\nsecond", null)], "firstsecond"), - ).toHaveLength(0); - }); - - it("indexes the rendered placeholder for empty assistant responses", () => { - expect( - buildThreadFindMatches([messageEntry("m1", "assistant", "")], "(empty response)"), - ).toHaveLength(1); - }); - - it("skips work rows and system messages", () => { - expect( - buildThreadFindMatches( - [workEntry("w1"), messageEntry("s1", "system", "sentinel")], - "sentinel", - ), - ).toHaveLength(0); - }); - - it("uses the displayed proposed-plan title and body", () => { - const entries = [proposedPlanEntry("p1", "# Visible title\n\n## Summary\n\nship it", null)]; - expect(buildThreadFindMatches(entries, "Visible title")).toHaveLength(1); - expect(buildThreadFindMatches(entries, "ship it")).toHaveLength(1); - expect(buildThreadFindMatches(entries, "Summary")).toHaveLength(0); - }); -}); - -describe("buildThreadFindMatches", () => { - it("carries turn ownership for folded messages and plans", () => { - const runId = RunId.make("turn-1"); - const entries = [ - messageEntry("m1", "assistant", "deploy twice: deploy", runId), - proposedPlanEntry("p1", "deploy the plan", runId), - ]; - - expect(buildThreadFindMatches(entries, "deploy")).toEqual([ - { entryId: "m1", runId, occurrence: 0 }, - { entryId: "m1", runId, occurrence: 1 }, - { entryId: "p1", runId, occurrence: 0 }, - ]); - }); - - it("reveals a search result inside a settled turn and restores its fold afterward", () => { - const runId = RunId.make("settled-turn"); - const entries = [ - messageEntry("prompt", "user", "Check the release"), - messageEntry("progress", "assistant", "Found the sentinel", runId), - messageEntry("final", "assistant", "Done", runId), - ]; - const input = { - timelineEntries: entries, - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaries: [], - supportsConversationRollback: false, - }; - const collapsed = deriveMessagesTimelineRows(input); - expect(collapsed.some((row) => row.id === "progress")).toBe(false); - - const match = buildThreadFindMatches(entries, "sentinel")[0]!; - const expanded = deriveMessagesTimelineRows({ - ...input, - expandedRunIds: new Set(match.runId ? [match.runId] : []), - }); - expect(expanded.some((row) => row.id === match.entryId)).toBe(true); - expect(deriveMessagesTimelineRows(input).map((row) => row.id)).toEqual( - collapsed.map((row) => row.id), - ); - }); - - it("ignores blank queries", () => { - expect(buildThreadFindMatches([messageEntry("m1", "user", "deploy")], " ")).toEqual([]); - }); -}); +import { formatThreadFindCount, stepThreadFindIndex } from "./threadFind"; describe("thread find navigation", () => { it("clamps, wraps, and formats positions", () => { - expect(clampThreadFindIndex(4, 2)).toBe(1); expect(stepThreadFindIndex(2, 3, 1)).toBe(0); expect(stepThreadFindIndex(0, 3, -1)).toBe(2); expect(formatThreadFindCount(4, 2)).toBe("2/2"); diff --git a/apps/web/src/components/chat/threadFind.ts b/apps/web/src/components/chat/threadFind.ts index 411692a4cd56..3717bdb98fa8 100644 --- a/apps/web/src/components/chat/threadFind.ts +++ b/apps/web/src/components/chat/threadFind.ts @@ -1,8 +1,4 @@ -import type { InlineSkill } from "@t3tools/shared/inlineSkills"; import type { RunId } from "@t3tools/contracts"; -import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch"; -import type { TimelineEntry } from "../../session-logic"; -import { searchableMessageSegments, searchablePlanSegments } from "@t3tools/shared/threadFindText"; /** One occurrence of the query inside a searchable timeline entry. */ export interface ThreadFindMatch { @@ -13,66 +9,7 @@ export interface ThreadFindMatch { readonly occurrence: number; } -// Message/plan records are immutable and survive timeline rebuilds during streaming. -// Weak keys reuse parsed text across keystrokes without retaining old messages. -const entryTextCache = new WeakMap< - object, - { cwd: string | undefined; skills: readonly InlineSkill[]; segments: readonly string[] | null } ->(); - -function searchableThreadEntrySegments( - entry: TimelineEntry, - cwd?: string, - skills: readonly InlineSkill[] = [], -): readonly string[] | null { - if (entry.kind !== "message" && entry.kind !== "proposed-plan") return null; - const key = entry.kind === "message" ? entry.message : entry.proposedPlan; - const cached = entryTextCache.get(key); - if (cached && cached.cwd === cwd && cached.skills === skills) return cached.segments; - const segments = - entry.kind === "message" - ? searchableMessageSegments(entry.message, cwd, skills) - : searchablePlanSegments(entry.proposedPlan.planMarkdown, cwd, skills); - entryTextCache.set(key, { cwd, skills, segments }); - return segments; -} - -function threadEntryRunId(entry: TimelineEntry): RunId | null { - if (entry.kind === "message") return entry.message.runId ?? null; - if (entry.kind === "proposed-plan") return entry.proposedPlan.runId; - return null; -} - -export function buildThreadFindMatches( - entries: ReadonlyArray, - query: string, - cwd?: string, - skills: readonly InlineSkill[] = [], -): ThreadFindMatch[] { - const normalizedQuery = query.trim(); - if (normalizedQuery.length === 0) return []; - - const matches: ThreadFindMatch[] = []; - for (const entry of entries) { - const segments = searchableThreadEntrySegments(entry, cwd, skills); - if (segments === null) continue; - - const total = segments.reduce( - (count, text) => count + countThreadSearchOccurrences(text, normalizedQuery), - 0, - ); - for (let occurrence = 0; occurrence < total; occurrence += 1) { - matches.push({ - entryId: entry.id, - runId: threadEntryRunId(entry), - occurrence, - }); - } - } - return matches; -} - -export function clampThreadFindIndex(index: number, total: number): number { +function clampThreadFindIndex(index: number, total: number): number { if (total <= 0 || !Number.isFinite(index) || index < 0) return 0; return Math.min(Math.trunc(index), total - 1); } diff --git a/apps/web/src/components/chat/useThreadFind.test.tsx b/apps/web/src/components/chat/useThreadFind.test.tsx index f6c82a294200..3fc4394d73c2 100644 --- a/apps/web/src/components/chat/useThreadFind.test.tsx +++ b/apps/web/src/components/chat/useThreadFind.test.tsx @@ -12,16 +12,17 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { useThreadFind } from "./useThreadFind"; +import { orchestrationEnvironment } from "~/state/orchestration"; +import { requestThreadFindOpen } from "./threadFindActionBus"; const queries = vi.hoisted(() => ({ results: new Map(), })); vi.mock("~/state/orchestration", () => ({ orchestrationEnvironment: { - threadFind: (input: { - environmentId: EnvironmentId; - input: OrchestrationV2SearchThreadInput; - }) => input, + threadFind: vi.fn( + (input: { environmentId: EnvironmentId; input: OrchestrationV2SearchThreadInput }) => input, + ), }, })); vi.mock("~/state/queries", () => ({ useDebouncedValue: (value: T) => value })); @@ -34,6 +35,8 @@ vi.mock("~/state/query", () => ({ }), })); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + let renderer: ReactTestRenderer | undefined; let find: ReturnType; const a = EnvironmentId.make("environment:a"); @@ -41,15 +44,17 @@ const b = EnvironmentId.make("environment:b"); const threadId = ThreadId.make("shared-thread"); const runId = RunId.make("run:plan"); const now = DateTime.makeUnsafe("2026-10-01T00:00:00Z"); -const entries: Parameters[0]["entries"] = []; -function Probe({ environmentId }: { environmentId: EnvironmentId }) { +function Probe({ + environmentId, + enabled = true, +}: { + environmentId: EnvironmentId; + enabled?: boolean; +}) { const state = useThreadFind({ thread: { environmentId, threadId }, - serverSearch: true, - cwd: "/repo", + enabled, content: undefined, - entries, - history: null, }); useLayoutEffect(() => { find = state; @@ -64,10 +69,47 @@ afterEach(async () => { if (renderer) await act(async () => renderer?.unmount()); renderer = undefined; queries.results.clear(); + vi.clearAllMocks(); vi.unstubAllGlobals(); }); describe("V2 find state", () => { + it("keeps search unavailable when the server does not support it", async () => { + await act(async () => { + renderer = create(); + }); + await act(async () => { + find.open(); + requestThreadFindOpen(); + }); + expect(find.isOpen).toBe(false); + expect(find.timelineProps.searchEntries).toBeNull(); + expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled(); + }); + + it("closes search when support disappears without restoring stale queries", async () => { + await act(async () => { + renderer = create(); + }); + await act(async () => find.open()); + await act(async () => find.barProps.onQueryChange("needle")); + expect(find.isOpen).toBe(true); + vi.mocked(orchestrationEnvironment.threadFind).mockClear(); + await act(async () => { + renderer?.update(); + }); + expect(find.isOpen).toBe(false); + expect(find.timelineProps.searchEntries).toBeNull(); + expect(find.timelineProps.findQuery).toBe(""); + expect(orchestrationEnvironment.threadFind).not.toHaveBeenCalled(); + await act(async () => { + renderer?.update(); + }); + expect(find.isOpen).toBe(false); + await act(async () => find.open()); + expect(find.barProps.query).toBe(""); + }); + it("derives a plan result with the timeline item ID and run ownership", async () => { queries.results.set(a, { snapshotSequence: 9, diff --git a/apps/web/src/components/chat/useThreadFind.ts b/apps/web/src/components/chat/useThreadFind.ts index 339028d63855..19dbdc845fb6 100644 --- a/apps/web/src/components/chat/useThreadFind.ts +++ b/apps/web/src/components/chat/useThreadFind.ts @@ -2,13 +2,13 @@ import type { InlineSkill } from "@t3tools/shared/inlineSkills"; import type { OrchestrationV2ThreadProjection, ScopedThreadRef } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { deriveTimelineEntriesFromVisibleTurnItems, type TimelineEntry } from "~/session-logic"; +import { deriveTimelineEntriesFromVisibleTurnItems } from "~/session-logic"; import { orchestrationEnvironment } from "~/state/orchestration"; import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; -import { buildThreadFindMatches, clampThreadFindIndex, stepThreadFindIndex } from "./threadFind"; +import { stepThreadFindIndex } from "./threadFind"; import { subscribeThreadFindOpen } from "./threadFindActionBus"; -import { useThreadFindHistory } from "./useThreadFindHistory"; +import { toastManager } from "../ui/toast"; const EMPTY_SKILLS: readonly InlineSkill[] = []; @@ -20,70 +20,56 @@ const CLOSED_FIND = { navigationId: 0, }; -/** Owns find state and presents the same controls for server search and older-server history. */ +/** Owns find state for the active thread and its environment. */ export function useThreadFind({ thread, - serverSearch, - cwd, + enabled, skills = EMPTY_SKILLS, content, - entries, - history, }: { thread: ScopedThreadRef | null; - serverSearch: boolean; - cwd: string | undefined; + enabled: boolean; skills?: readonly InlineSkill[]; content: Pick | undefined; - entries: ReadonlyArray; - history: Parameters[1]; }) { const threadKey = thread ? scopedThreadKey(thread) : null; const [state, setState] = useState(CLOSED_FIND); - if (state.threadKey !== null && state.threadKey !== threadKey) setState(CLOSED_FIND); - const isOpen = threadKey !== null && state.threadKey === threadKey; + if (state.threadKey !== null && (!enabled || state.threadKey !== threadKey)) + setState(CLOSED_FIND); + const isOpen = enabled && threadKey !== null && state.threadKey === threadKey; const open = useCallback(() => { if (threadKey === null) return; + if (!enabled) { + toastManager.add({ + id: "thread-find-unavailable", + title: "Thread search is unavailable on this server.", + description: "Update the server to enable it.", + }); + return; + } setState((previous) => ({ ...(previous.threadKey === threadKey ? previous : CLOSED_FIND), threadKey, focusRequestId: previous.focusRequestId + 1, })); - }, [threadKey]); + }, [enabled, threadKey]); const close = useCallback(() => setState(CLOSED_FIND), []); useEffect(() => subscribeThreadFindOpen(open), [open]); const remote = useServerResults( - serverSearch && isOpen ? thread : null, + isOpen ? thread : null, state.query, state.activeIndex, content, skills, ); - const localStatus = useThreadFindHistory( - !serverSearch && isOpen && state.query.trim() ? `${threadKey}:${state.focusRequestId}` : null, - history, - ); - let status: "loading" | "incomplete" | "error" | null = localStatus; - if (serverSearch) { - status = null; - if (remote.isPending) status = "loading"; - if (remote.error) status = "error"; - } - const localMatches = useMemo( - () => - buildThreadFindMatches( - entries, - !serverSearch && isOpen && status !== "loading" ? state.query : "", - cwd, - skills, - ), - [cwd, entries, isOpen, serverSearch, skills, state.query, status], - ); - const count = serverSearch ? (remote.data?.totalMatches ?? 0) : localMatches.length; - const activeIndex = serverSearch - ? (remote.data?.activeIndex ?? 0) - : clampThreadFindIndex(state.activeIndex, count); + const status: "loading" | "error" | null = remote.error + ? "error" + : remote.isPending + ? "loading" + : null; + const count = remote.data?.totalMatches ?? 0; + const activeIndex = remote.data?.activeIndex ?? 0; const searchEntries = useMemo( () => remote.data?.match @@ -94,8 +80,6 @@ export function useThreadFind({ : null, [remote.data], ); - const selected = remote.data?.match; - const activeMatch = serverSearch ? selected : localMatches[activeIndex]; const step = (delta: number) => setState((previous) => ({ ...previous, @@ -112,9 +96,9 @@ export function useThreadFind({ query: state.query, matchCount: count, activeIndex, - historyState: status, + status, focusRequestId: state.focusRequestId, - onRetryHistory: serverSearch ? remote.refresh : open, + onRetry: remote.refresh, onQueryChange: (query: string) => setState((previous) => ({ ...previous, query, activeIndex: 0 })), onNext: () => step(1), @@ -123,10 +107,10 @@ export function useThreadFind({ }, timelineProps: { findOpen: isOpen, - searchEntries: !serverSearch && isOpen && state.query.trim() ? entries : searchEntries, + searchEntries, onCloseSearch: close, - findQuery: isOpen && (!serverSearch || searchEntries !== null) ? state.query : "", - activeFindMatch: activeMatch ?? null, + findQuery: isOpen && searchEntries !== null ? state.query : "", + activeFindMatch: remote.data?.match ?? null, findNavigationId: state.navigationId, }, }; diff --git a/apps/web/src/components/chat/useThreadFindHistory.test.tsx b/apps/web/src/components/chat/useThreadFindHistory.test.tsx deleted file mode 100644 index 16c3f4d2d002..000000000000 --- a/apps/web/src/components/chat/useThreadFindHistory.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { act, StrictMode, useLayoutEffect } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { useThreadFindHistory } from "./useThreadFindHistory"; - -type CitationHistoryPage = NonNullable[1]>; - -let renderer: ReactTestRenderer; -let status: ReturnType; - -function Probe({ - requestKey, - page, -}: { - requestKey: string | null; - page: CitationHistoryPage | null; -}) { - const value = useThreadFindHistory(requestKey, page); - useLayoutEffect(() => { - status = value; - }, [value]); - return null; -} - -async function render(requestKey: string | null, page: CitationHistoryPage | null) { - await act(() => { - renderer.update( - - - , - ); - }); -} - -beforeEach(async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - await act(() => { - renderer = create( - - - , - ); - }); -}); - -afterEach(async () => { - await act(() => renderer.unmount()); - vi.unstubAllGlobals(); -}); - -describe("thread find history loading", () => { - it("loads sequential pages without duplicate requests and finishes only at the oldest page", async () => { - const load = vi.fn(); - const page = { cursor: "recent", loading: false, onLoadEarlier: load }; - await render("thread:1", page); - await render("thread:1", { ...page }); - expect(load).toHaveBeenCalledTimes(1); - expect(status).toBe("loading"); - - await render("thread:1", { ...page, loading: true }); - expect(status).toBe("loading"); - expect(load).toHaveBeenCalledTimes(1); - - await render("thread:1", { ...page, cursor: "older" }); - expect(load).toHaveBeenCalledTimes(2); - expect(status).not.toBeNull(); - await render("thread:1", null); - expect(status).toBeNull(); - }); - - it("does not loop on a failed page and allows an explicit retry", async () => { - const load = vi.fn(); - const page = { cursor: "recent", loading: false, onLoadEarlier: load }; - await render("thread:1", page); - await render("thread:1", { ...page, loading: true }); - await render("thread:1", { ...page }); - expect(status).toBe("incomplete"); - expect(load).toHaveBeenCalledTimes(1); - - await render("thread:2", page); - expect(load).toHaveBeenCalledTimes(2); - }); - - it("stops when find closes or the query clears, and isolates requests between threads", async () => { - const load = vi.fn(); - const page = { cursor: "recent", loading: false, onLoadEarlier: load }; - await render(null, page); - expect(load).not.toHaveBeenCalled(); - await render("thread-a:1", page); - await render(null, { ...page, cursor: "older" }); - expect(load).toHaveBeenCalledTimes(1); - expect(status).toBeNull(); - - await render("thread-b:1", page); - expect(load).toHaveBeenCalledTimes(2); - }); -}); diff --git a/apps/web/src/components/chat/useThreadFindHistory.ts b/apps/web/src/components/chat/useThreadFindHistory.ts deleted file mode 100644 index 1f1d49d59b67..000000000000 --- a/apps/web/src/components/chat/useThreadFindHistory.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { CitationHistoryPage } from "./useAssistantCitationTarget"; - -/** Search needs every history page, but leaves row mounting to the virtual list. */ -export function useThreadFindHistory( - requestKey: string | null, - page: CitationHistoryPage | null, -): "loading" | "incomplete" | null { - const requested = useRef<{ key: string; cursors: Set; loading: boolean } | null>(null); - const [failedKey, setFailedKey] = useState(null); - if (requestKey === null && failedKey !== null) setFailedKey(null); - - useEffect(() => { - if (requestKey === null) { - requested.current = null; - return; - } - if (requested.current?.key !== requestKey) { - requested.current = { key: requestKey, cursors: new Set(), loading: false }; - } - const wasLoading = requested.current.loading; - requested.current.loading = page?.loading ?? false; - if (!page || page.loading || failedKey === requestKey) return; - - const cursor = page.cursor ?? "first"; - // StrictMode replay is not a failed request. Only a completed fetch that - // leaves the cursor unchanged needs a manual retry. - if (requested.current.cursors.has(cursor)) { - if (wasLoading) setFailedKey(requestKey); - return; - } - requested.current.cursors.add(cursor); - page.onLoadEarlier(); - }, [failedKey, page, requestKey]); - - if (requestKey === null || page === null) return null; - return failedKey === requestKey ? "incomplete" : "loading"; -} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f61b745f285b..b911190476a3 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -115,8 +115,8 @@ Combine keys with `!` for not, `&&` for and, `||` for or, and parentheses: ## Find in the current thread `chat.find` searches conversation messages and proposed plans in the active thread. Entering a -query searches the entire thread, including older messages. When connected to an older server, -search loads earlier history first. Select **Retry** if a search or history load fails. It defaults to +query searches the entire thread, including older messages. Thread search requires server support; +update an older server to enable it. Select **Retry** if a search fails. It defaults to `mod+f` outside terminals and previews. Press **Enter** or **Shift+Enter** to move between matches, and **Escape** to close find. From 00f3a5f6717a7b107014699d376b789c77285c9f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 7 Oct 2026 06:07:24 -0500 Subject: [PATCH 05/21] fix(web): preserve markdown transforms after rebase --- packages/shared/src/markdownPipeline.ts | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 55df21815fba..f7d133f1dc6b 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -15,6 +15,7 @@ import { isWindowsDrivePathHref } from "./markdownLinks.ts"; type MarkdownImageHastNode = { type?: string; + value?: string; tagName?: string; properties?: Record; children?: MarkdownImageHastNode[]; @@ -69,6 +70,54 @@ function markStandaloneImages(node: MarkdownImageHastNode) { }); } +/** Keep unmatched inline `
` placeholders from opening an HTML link over later blocks. */ +function rehypePreserveBareAnchorPlaceholders() { + return (tree: MarkdownImageHastNode) => { + const anchors: Array = []; + let rawTextTag: string | undefined; + const visit = (node: MarkdownImageHastNode) => { + if (node.type === "raw" && typeof node.value === "string") { + // Raw blocks can contain several tags. Consume whole tags, quoted attributes, + // and comments so text resembling a closing anchor cannot pair a placeholder. + const tags = /|$)|<\/?[A-Za-z](?:[^"'<>]|"[^"]*"|'[^']*')*>/g; + let offset = 0; + while (rawTextTag !== "plaintext") { + // Raw text ends at its closing tag even inside comment-looking text. + const matcher = rawTextTag ? new RegExp(``, "gi") : tags; + matcher.lastIndex = offset; + const match = matcher.exec(node.value); + if (!match) break; + const [tag] = match; + offset = matcher.lastIndex; + if (rawTextTag) { + rawTextTag = undefined; + continue; + } + if (tag.startsWith("Beta\n```"; + function Probe({ query }: { query: string }) { + useThreadFindHighlights({ + container, + query, + activeRowId: "row", + activeOccurrence: 0, + onActiveRange: () => {}, }); + return ( +
+
+ + + +
+
+ ); + } + const frame = () => + act(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + const diagrams = () => container.querySelectorAll('svg[aria-label="Diagram"]').length; + try { + await act(() => root.render()); + await frame(); + expect(diagrams()).toBe(2); + await act(() => root.render()); + await frame(); + await frame(); + // Only the diagram holding the selected match switches to source. + expect(diagrams()).toBe(1); expect( - renderer!.root.findAllByProps({ "data-markdown-details-open": "true" }).length, - ).toBeGreaterThan(0); - expect( - renderer!.root - .findAll( - (node) => node.type === "button" && node.props["data-markdown-details-summary"] === "", - ) - .map((node) => node.props["aria-expanded"]), - ).toEqual([true, false]); + [...(highlights.get("t3-thread-find-active") ?? [])].map((range) => range.toString()), + ).toEqual(["Alpha"]); } finally { - await act(async () => { - renderer?.unmount(); - }); + await act(() => root.unmount()); + container.remove(); vi.unstubAllGlobals(); } }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 05b4b08c13a5..2e154c0d952a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,4 @@ -import { MarkdownFindContext } from "./chat/markdownFindContext"; +import { MarkdownFindContext, useFindRevealRef } from "./chat/markdownFindContext"; import { buildFileLinkParentSuffixByPath, fileLinkLabel, @@ -690,7 +690,7 @@ function MarkdownDetails({ }: Pick, "children" | "open">) { const [isOpen, setIsOpen] = useState(open); const searching = use(MarkdownFindContext); - const expanded = searching || isOpen; + const expanded = isOpen; const childNodes = Children.toArray(children); const summaryIndex = childNodes.findIndex( (child) => isValidElement(child) && child.type === "summary", @@ -720,7 +720,8 @@ function MarkdownDetails({ /> {summary} - + {/* Base UI opens an until-found panel on `beforematch`. */} +
0; + const revealSource = useCallback(() => setShowCode(true), []); + const sourceRevealRef = useFindRevealRef(revealSource); + const showDiagram = !showCode && !isStreaming && code.trim().length > 0; const toggleLabel = showCode ? "Show diagram" : "Show code"; return ( + {/* Find counts the source; a selected match here switches to it. */} + {searching ? ( + + ) : null} ) : ( children diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx index 30c987918845..dd03bdc4ea10 100644 --- a/apps/web/src/components/chat/MermaidDiagram.tsx +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -192,7 +192,11 @@ export function MermaidDiagram({ ) : null}
-
+        {/* Find matches the source through the hidden copy beside the diagram. */}
+        
           {source}
         
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 17f1e26cc7d1..edb147ed1467 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,5 +1,5 @@ import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline"; -import { MarkdownFindContext } from "./markdownFindContext"; +import { MarkdownFindContext, useFindRevealRef } from "./markdownFindContext"; import { ComputerUseAppIcon } from "~/components/Icons"; import { useChatCanvas } from "./ChatCanvasContext"; import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; @@ -2454,7 +2454,6 @@ function UserTimelineRow({ row }: { row: Extract ); @@ -4594,28 +4593,26 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; skills: ReadonlyArray>; markdownCwd: string | undefined; - expandForFind?: boolean; footer?: ReactNode; }) { const [expanded, setExpanded] = useState(false); - // Closing find keeps bodies it opened, so the reading position and revealed match stay put. - const [wasExpandedForFind, setWasExpandedForFind] = useState(props.expandForFind === true); - if (wasExpandedForFind !== (props.expandForFind === true)) { - setWasExpandedForFind(props.expandForFind === true); - if (wasExpandedForFind) setExpanded(true); - } + // Find opens the body only when it selects a match in the clipped part. + const revealForFind = useCallback(() => setExpanded(true), []); + const findRevealRef = useFindRevealRef(revealForFind); const hasVisibleBody = props.text.trim().length > 0; const canCollapse = hasVisibleBody && shouldCollapseUserMessage(props.text); - const isCollapsed = canCollapse && !expanded && !props.expandForFind; - const showCollapseControl = canCollapse && !props.expandForFind; + const isCollapsed = canCollapse && !expanded; + const showCollapseControl = canCollapse; return (
{hasVisibleBody ? (
setExpanded(true), []); + const findRevealRef = useFindRevealRef(revealForFind); const downloadFilename = buildProposedPlanMarkdownFilename(planMarkdown); const saveContents = normalizePlanMarkdownForExport(planMarkdown); @@ -187,10 +193,12 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
- {isCollapsed ? ( + {showPreview ? ( ) : null}
- {canCollapse && !expandForFind ? ( + {canCollapse ? (