diff --git a/.github/workflows/scient-upstream-provenance.yml b/.github/workflows/scient-upstream-provenance.yml index 5d0eb0a7f..1fde907f0 100644 --- a/.github/workflows/scient-upstream-provenance.yml +++ b/.github/workflows/scient-upstream-provenance.yml @@ -19,6 +19,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Fetch official T3 main run: git fetch --no-tags https://github.com/pingdotgg/t3code.git main:refs/remotes/upstream-verification/main diff --git a/UPSTREAM.md b/UPSTREAM.md index 57de9b473..af03b14da 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -560,6 +560,16 @@ behavior, and Markdown transport must remain outside inherited T3 components; `index.css` inherited rule bodies stay byte-identical. See [Scient rich Markdown editor](docs/internals/scient-rich-markdown-editor.md). +Markdown file quotes deliberately extend the inherited assistant Cite flow. +Preserve the concrete `FileCitation` variant and `composerCitations` helpers, +the shared composer node/comment and selection toolbar, and the small source +capture/reveal mounts in `ChatView`, `FilePreviewPanel`, and the right-panel +store. Source mapping and editor interaction remain in `scient/markdownEditor`; +assistant v1 links and timeline navigation keep their original semantics. +Provider expansion, prompt previews, and mobile fallback must handle both quote +types. Do not reinstate an assistant-only parser in these shared entry points +or turn file citations into file-read operations. The rich editor remains lazy. + No upstream update authorizes public release, live cloud, mobile publication, production credentials, or user-data conversion. Those remain separate Scient gates even when inherited T3 code contains the capability. diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 28d5d0406..867820185 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -10,7 +10,7 @@ import type { ThreadId, TurnId, } from "@t3tools/contracts"; -import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; +import { renderComposerCitationsAsText } from "@t3tools/shared/composerCitations"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, @@ -1453,7 +1453,7 @@ function renderFeedEntry( if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; - const renderedText = renderAssistantCitationsAsText(message.text); + const renderedText = renderComposerCitationsAsText(message.text); const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 37190780c..460d41b4f 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -7,12 +7,12 @@ import { type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { composerCitationsToPlainText } from "@t3tools/shared/composerCitations"; import type { UploadedMobileAttachment } from "./attachmentUpload"; export function deriveThreadTitleFromPrompt(value: string): string { - const trimmed = assistantCitationsToPlainText(value).trim(); + const trimmed = composerCitationsToPlainText(value).trim(); if (trimmed.length === 0) { return "New thread"; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1d6763183..063ef2215 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,7 +12,7 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { composerCitationsToPlainText } from "@t3tools/shared/composerCitations"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -125,7 +125,7 @@ function formatThreadTitleSection(message: ThreadTitleMessage): string | undefin if (message.role === "system") { return undefined; } - const text = assistantCitationsToPlainText(message.text).trim(); + const text = composerCitationsToPlainText(message.text).trim(); const attachmentSummary = (message.attachments ?? []) .map((attachment) => attachment.name) .join(", "); @@ -1312,7 +1312,7 @@ const make = Effect.gen(function* () { projects: project ? [project] : [], }) ?? process.cwd(); const generationInput = { - messageText: assistantCitationsToPlainText(message.text), + messageText: composerCitationsToPlainText(message.text), ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ac2464f54..ee895d2b9 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; import * as FileSystem from "effect/FileSystem"; import type { + FileCitation, ProviderApprovalDecision, ProviderRuntimeEvent, ProviderSendTurnInput, @@ -33,6 +34,10 @@ import { expandAssistantCitationsForProvider, serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; +import { + serializeComposerCitation, + expandComposerCitationsForProvider, +} from "@t3tools/shared/composerCitations"; import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, describe, vi } from "@effect/vitest"; import { afterAll } from "vite-plus/test"; @@ -3442,6 +3447,52 @@ citations.layer("ProviderServiceLive assistant citations", (it) => { [CLAUDE_AGENT_DRIVER, citations.claude], [CURSOR_DRIVER, citations.cursor], ] as const) { + it.effect( + `expands a file quote at the shared ${driver} boundary without losing its source`, + () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId(`thread-file-citation-${driver}`); + yield* provider.startSession(threadId, { + provider: driver, + providerInstanceId: ProviderInstanceId.make(driver), + threadId, + runtimeMode: "full-access", + }); + const quote: FileCitation = { + kind: "file", + version: 1, + environmentId: EnvironmentId.make("remote-source"), + threadId: asThreadId("original-thread"), + cwd: "/original/worktree", + path: "notes.md", + revision: `sha256:${"a".repeat(64)}`, + origin: "draft", + sourceStart: 0, + sourceEnd: 50, + startLine: 1, + endLine: 4, + from: 1, + to: 12, + text: "Exact quote\n with indentation", + prefix: "", + suffix: "", + comment: "Explain this.", + }; + const prompt = `Explain ${serializeComposerCitation(quote)}`; + const request = Object.freeze({ threadId, input: prompt }); + adapter.sendTurn.mockClear(); + yield* provider.sendTurn(request); + const sent = adapter.sendTurn.mock.calls[0]?.[0].input ?? ""; + assert.equal(sent, expandComposerCitationsForProvider(prompt)); + assert.include(sent, '"cwd": "/original/worktree"'); + assert.include(sent, '"origin": "draft"'); + assert.include(sent, '"text": "Exact quote\\n with indentation"'); + assert.notInclude(sent, "scient-file-citation:"); + assert.equal(request.input, prompt); + yield* provider.stopSession({ threadId }); + }), + ); it.effect(`expands quotes and bound comments as JSON data for ${driver}`, () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 78edf4431..46813249c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -33,7 +33,7 @@ import { type ProviderRuntimeEvent, type ProviderSession, } from "@t3tools/contracts"; -import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; +import { expandComposerCitationsForProvider } from "@t3tools/shared/composerCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; @@ -1574,7 +1574,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } const inputTextWithCitations = - parsed.input === undefined ? undefined : expandAssistantCitationsForProvider(parsed.input); + parsed.input === undefined ? undefined : expandComposerCitationsForProvider(parsed.input); if (inputTextWithCitations !== parsed.input) { yield* decodeInputOrValidationError({ operation: "ProviderService.sendTurn", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 600452823..cb794c4ad 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,4 +1,5 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type FileCitation } from "@t3tools/contracts"; +import { serializeComposerCitation } from "@t3tools/shared/composerCitations"; import { act, type ComponentProps, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; @@ -10,6 +11,15 @@ import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("@tanstack/react-router", async (original) => ({ + ...(await original()), + useNavigate: () => vi.fn(), + Link: ({ children, className, "aria-label": label }: ComponentProps<"a">) => ( + + {children} + + ), +})); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); vi.mock("../hooks/useSettings", async (importOriginal) => { const actual = await importOriginal(); @@ -72,6 +82,48 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("Markdown file quote in sent messages", () => { + it("renders validated file links as the shared citation chip, but not inline code examples", () => { + const citation: FileCitation = { + kind: "file", + version: 1, + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("source"), + cwd: "/workspace", + path: "notes.md", + revision: `sha256:${"a".repeat(64)}`, + origin: "draft", + from: 1, + to: 6, + sourceStart: 0, + sourceEnd: 10, + startLine: 1, + endLine: 1, + text: "Hello", + prefix: "", + suffix: "", + }; + const token = serializeComposerCitation(citation); + const html = renderToStaticMarkup(); + expect(html).toContain('data-file-citation-chip="true"'); + expect(html).toContain("notes.md"); + expect(html).toContain("Hello"); + expect(html).not.toContain('data-assistant-citation-chip="true"'); + const code = renderToStaticMarkup(); + expect(code).not.toContain('data-file-citation-chip="true"'); + expect(code).toContain("scient-file-citation"); + const invalid = renderToStaticMarkup( + , + ); + expect(invalid).not.toContain("data-file-citation-chip"); + expect(invalid).not.toContain('href="scient-file-citation'); + expect(invalid).not.toContain('href="javascript:'); + }); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0be232754..98c0bbec0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,8 +74,8 @@ 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 { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { parseComposerCitationHref } from "@t3tools/shared/composerCitations"; +import { CitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { @@ -503,7 +503,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { }, protocols: { ...defaultSchema.protocols, - href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation"], + href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation", "scient-file-citation"], src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -2179,7 +2179,7 @@ function useChatMarkdownState({ return buildFileLinkParentSuffixByPath(filePaths); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { - if (parseAssistantCitationHref(href)) return href; + if (parseComposerCitationHref(href)) return href; if (isWindowsDrivePathHref(href)) return href; return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -2763,8 +2763,8 @@ const CHAT_MARKDOWN_COMPONENTS = { updateThreadPullRequestLink, } = use(ChatMarkdownRendererContext); - const citation = href ? parseAssistantCitationHref(href) : null; - if (citation) return ; + const citation = href ? parseComposerCitationHref(href) : null; + if (citation) return ; const normalizedHref = href ? normalizeMarkdownLinkHref(href) : ""; const fileLinkMeta = normalizedHref ? (markdownFileLinkMetaByHref.get(markdownLinkLookupKey(normalizedHref)) ?? diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 03d6cdafb..42dddb468 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -107,7 +107,8 @@ import { } from "react"; import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; +import { useFileCitationTarget } from "~/scient/markdownEditor/fileCitationNavigation"; +import { composerCitationsToPlainText } from "@t3tools/shared/composerCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; @@ -1619,7 +1620,10 @@ function ChatViewContent(props: ChatViewProps) { const citationLocation = useLocation({ select: (location) => ({ href: location.href, - key: location.state.assistantCitationActivation ?? location.state.__TSR_key, + key: + location.state.fileCitationActivation ?? + location.state.assistantCitationActivation ?? + location.state.__TSR_key, }), }); const citationRequest = useMemo(() => { @@ -3693,6 +3697,12 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThreadWorktreePath, projectCwd: activeProjectCwd, }); + useFileCitationTarget( + activeThreadRef, + citationLocation, + activeWorkspaceRoot, + runAfterPendingFileSave, + ); useEffect(() => { if (!activeThreadRef) return; restoreForkPdfContinuity({ @@ -7600,7 +7610,7 @@ function ChatViewContent(props: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = assistantCitationsToPlainText(trimmed); + let titleSeed = composerCitationsToPlainText(trimmed); if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; @@ -8954,6 +8964,14 @@ function ChatViewContent(props: ChatViewProps) { projectName={activeProject?.title ?? "Project"} threadRef={activeThreadRef} composerDraftTarget={composerDraftTarget} + onCiteFile={(citation, anchor) => + composerRef.current?.citeText(citation, anchor) ?? false + } + fileCitation={ + renderedRightPanelSurface.kind === "file" + ? renderedRightPanelSurface.fileCitation + : undefined + } keybindings={keybindings} availableEditors={availableEditors} relativePath={ diff --git a/apps/web/src/components/ComposerCitationNode.tsx b/apps/web/src/components/ComposerCitationNode.tsx index 2c8f02586..411825e5a 100644 --- a/apps/web/src/components/ComposerCitationNode.tsx +++ b/apps/web/src/components/ComposerCitationNode.tsx @@ -1,9 +1,9 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import type { AssistantCitation } from "@t3tools/contracts"; +import type { ComposerCitation } from "@t3tools/contracts"; import { - serializeAssistantCitation, - withAssistantCitationComment, -} from "@t3tools/shared/assistantCitations"; + serializeComposerCitation, + withComposerCitationComment, +} from "@t3tools/shared/composerCitations"; import { $applyNodeReplacement, $getNodeByKey, @@ -19,12 +19,12 @@ import { import { createContext, use, type ReactElement } from "react"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { CitationChip } from "./chat/AssistantCitationChip"; import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip"; type SerializedComposerCitationNode = Spread< { - citation: AssistantCitation; + citation: ComposerCitation; source: string; type: "composer-citation"; version: 1; @@ -80,7 +80,7 @@ export function $consumeComposerCitationCommentRequest(requestRef: { return null; } -function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) { +function ComposerCitationDecorator(props: { citation: ComposerCitation; nodeKey: NodeKey }) { const [editor] = useLexicalComposerContext(); const commentContext = use(ComposerCitationCommentContext); const commentTarget = @@ -122,7 +122,7 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey spellCheck={false} data-composer-citation-chip="true" > - { - __citation: AssistantCitation; + __citation: ComposerCitation; __source: string; static override getType(): string { @@ -164,7 +164,7 @@ export class ComposerCitationNode extends DecoratorNode { ).updateFromJSON(serializedNode); } - constructor(citation: AssistantCitation, source: string, key?: NodeKey) { + constructor(citation: ComposerCitation, source: string, key?: NodeKey) { super(key); this.__citation = citation; this.__source = source; @@ -197,9 +197,9 @@ export class ComposerCitationNode extends DecoratorNode { setComment(comment: string): this { const latest = this.getLatest(); - const citation = withAssistantCitationComment(latest.__citation, comment); + const citation = withComposerCitationComment(latest.__citation, comment); if (citation.comment === latest.__citation.comment) return latest; - const source = serializeAssistantCitation(citation); + const source = serializeComposerCitation(citation); const writable = this.getWritable(); writable.__citation = citation; writable.__source = source; @@ -216,7 +216,7 @@ export class ComposerCitationNode extends DecoratorNode { } export function $createComposerCitationNode( - citation: AssistantCitation, + citation: ComposerCitation, source: string, ): ComposerCitationNode { return $applyNodeReplacement(new ComposerCitationNode(citation, source)); diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx index 980521084..66056db01 100644 --- a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx @@ -6,6 +6,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { collapseExpandedComposerCursor } from "../composer-logic"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { ComposerCitationNode } from "./ComposerCitationNode"; +import { EnvironmentId, ThreadId, type FileCitation } from "@t3tools/contracts"; +import { + serializeComposerCitation, + collectComposerCitations, +} from "@t3tools/shared/composerCitations"; vi.mock("./chat/FileTagChip", () => ({ FILE_TAG_CHIP_CLASS_NAME: "", @@ -14,7 +20,9 @@ vi.mock("./chat/FileTagChip", () => ({ vi.mock("./chat/ComposerPendingTerminalContexts", () => ({ ComposerPendingTerminalContextChip: () => null, })); -vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); +vi.mock("./chat/AssistantCitationChip", () => ({ + CitationChip: () => null, +})); let lexicalEditor: LexicalEditor; // Keep the real composer, registered nodes, updates, and snapshot API. Only the @@ -78,6 +86,86 @@ beforeEach(() => { vi.stubGlobal("document", { activeElement: null }); }); +describe("file quotes share the real composer inline node", () => { + const citation: FileCitation = { + kind: "file", + version: 1, + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("thread"), + cwd: "/project", + path: "docs/้›ช ๐Ÿ‘‹.md", + revision: `sha256:${"a".repeat(64)}`, + origin: "draft", + sourceStart: 0, + sourceEnd: 90, + startLine: 1, + endLine: 4, + from: 1, + to: 12, + text: "selected text\n indentation", + prefix: "", + suffix: "", + comment: "Explain", + }; + function $citation() { + const paragraph = $getRoot().getFirstChildOrThrow(); + if (!$isElementNode(paragraph)) throw new Error("Expected paragraph"); + const node = paragraph.getChildren().find((entry) => entry instanceof ComposerCitationNode); + if (!(node instanceof ComposerCitationNode)) throw new Error("Expected citation"); + return node; + } + it("survives adjacent file mentions, controlled replacements, clone, JSON reload and comment edits", async () => { + const token = serializeComposerCitation(citation); + const prompt = `[notes.md](notes.md) ${token} trailing text`; + await renderPrompt(prompt); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + await act(() => + lexicalEditor.update( + () => { + const node = $citation(); + expect(node.isInline()).toBe(true); + node.replace($copyNode(node)); + }, + { discrete: true }, + ), + ); + const exported = lexicalEditor.getEditorState().toJSON(); + await renderPrompt(""); + await act(() => lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exported))); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + await act(() => + lexicalEditor.update(() => $citation().setComment(" New comment "), { discrete: true }), + ); + const updated = editorRef.current!.readSnapshot().value; + expect(collectComposerCitations(updated)[0]?.citation).toEqual({ + ...citation, + comment: "New comment", + }); + expect(updated.startsWith("[notes.md](notes.md) ")).toBe(true); + expect(updated.endsWith(" trailing text")).toBe(true); + await act(() => lexicalEditor.update(() => $citation().remove(), { discrete: true })); + expect(editorRef.current!.readSnapshot().value).toBe("[notes.md](notes.md) trailing text"); + }); + it("pastes one file citation atomically with its quote and comment", async () => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + await renderPrompt("Before "); + const token = serializeComposerCitation(citation); + const event = new TestClipboardEvent(token); + await act(() => + lexicalEditor.update( + () => { + $getRoot().selectEnd(); + lexicalEditor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, + ), + ); + expect(event.defaultPrevented).toBe(true); + expect(editorRef.current!.readSnapshot().value).toBe(`Before ${token}`); + expect(collectComposerCitations(editorRef.current!.readSnapshot().value)).toHaveLength(1); + }); +}); + afterEach(async () => { await act(() => renderer?.unmount()); renderer = undefined; diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 069bd1783..6e823b192 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -23,7 +23,9 @@ import { import { splitPromptIntoComposerSegments } from "../composer-editor-mentions"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -vi.mock("./chat/AssistantCitationChip", () => ({ AssistantCitationChip: () => null })); +vi.mock("./chat/AssistantCitationChip", () => ({ + CitationChip: () => null, +})); const citation: AssistantCitation = { version: 1, diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index ccfd74666..f92acfcb8 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -1,5 +1,10 @@ -import type { AssistantCitation } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { isFileCitation, type ComposerCitation } from "@t3tools/contracts"; +import { serializeComposerCitation } from "@t3tools/shared/composerCitations"; +import { + fileCitationHash, + fileCitationNavigation, +} from "~/scient/markdownEditor/fileCitationNavigation"; +import { basenameOfPath } from "~/pierre-icons"; import { Link, useNavigate } from "@tanstack/react-router"; import { PencilIcon, QuoteIcon, XIcon } from "lucide-react"; import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react"; @@ -30,12 +35,12 @@ const CITATION_ACTION_BUTTON_CLASS_NAME = cn( "text-primary/80 hover:bg-primary/10 hover:text-primary", ); -export function AssistantCitationChip({ +export function CitationChip({ citation, onRemove, commentEditor, }: { - citation: AssistantCitation; + citation: ComposerCitation; onRemove?: () => void; commentEditor?: { open: boolean; @@ -55,11 +60,13 @@ export function AssistantCitationChip({ }); useEffect(() => { if (!commentOpen) return; - const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); + const anchor = + sourceAnchor ?? + (isFileCitation(citation) ? null : findAssistantCitationSourceAnchor(document, citation)); if (!anchor) return; return observeAssistantCitationCommentSource({ anchor, - citation, + citation: isFileCitation(citation) ? undefined : citation, onUnavailable: onSourceUnavailable, }); }, [citation, commentOpen, sourceAnchor]); @@ -75,26 +82,36 @@ export function AssistantCitationChip({ } : undefined; const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); - const label = preview.length > 64 ? `${preview.slice(0, 64)}โ€ฆ` : preview; + const excerpt = preview.length > 64 ? `${preview.slice(0, 64)}โ€ฆ` : preview; + const label = isFileCitation(citation) + ? `${basenameOfPath(citation.path)} ยท ${excerpt}` + : excerpt; const sourceLinkProps = { to: "/$environmentId/$threadId" as const, params: { environmentId: citation.environmentId, threadId: citation.threadId }, - hash: assistantCitationHash(citation), - "data-markdown-copy": serializeAssistantCitation(citation), + hash: isFileCitation(citation) ? fileCitationHash(citation) : assistantCitationHash(citation), + "data-markdown-copy": serializeComposerCitation(citation), resetScroll: false, onClick: (event: ReactMouseEvent) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { return; } event.preventDefault(); - void navigate(assistantCitationNavigation(citation)); + if (isFileCitation(citation)) { + void navigate(fileCitationNavigation(citation)); + } else void navigate(assistantCitationNavigation(citation)); }, }; const composerSourceLink = (