From aa93775ba7a4720a04fe09f7353a23a6d0492a32 Mon Sep 17 00:00:00 2001 From: Abdullah1738 Date: Mon, 12 Oct 2026 22:41:29 +0400 Subject: [PATCH] feat(mobile): cite assistant text in the composer --- .../t3composereditor/T3ComposerEditorView.kt | 5 +- .../modules/t3composereditor/T3ContextChip.kt | 19 ++ .../src/components/AssistantCitationSheet.tsx | 81 +++++ apps/mobile/src/components/ComposerEditor.tsx | 123 +++++-- .../features/threads/ThreadDetailScreen.tsx | 75 ++++- .../src/features/threads/ThreadFeed.tsx | 32 ++ .../lib/assistantCitationSelection.test.ts | 127 ++++++++ .../src/lib/assistantCitationSelection.ts | 91 ++++++ .../src/lib/composerChipPresentation.ts | 1 + apps/mobile/src/lib/composerContext.test.ts | 26 ++ apps/mobile/src/lib/composerContext.ts | 15 +- .../src/native/EnrichedMarkdownText.tsx | 15 + .../native/SelectableMarkdownText.types.ts | 2 + .../src/native/T3ComposerEditor.ios.tsx | 4 +- .../src/native/T3ComposerEditor.native.tsx | 4 +- .../src/lib/assistantTextSelection.test.ts | 257 +-------------- apps/web/src/lib/assistantTextSelection.ts | 122 +------ docs/user/composer.md | 10 +- packages/shared/package.json | 4 + .../shared/src/assistantTextSelection.test.ts | 303 ++++++++++++++++++ packages/shared/src/assistantTextSelection.ts | 142 ++++++++ 21 files changed, 1058 insertions(+), 400 deletions(-) create mode 100644 apps/mobile/src/components/AssistantCitationSheet.tsx create mode 100644 apps/mobile/src/lib/assistantCitationSelection.test.ts create mode 100644 apps/mobile/src/lib/assistantCitationSelection.ts create mode 100644 packages/shared/src/assistantTextSelection.test.ts create mode 100644 packages/shared/src/assistantTextSelection.ts diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index ca2426f8c023f..85c18eeec6f34 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -110,7 +110,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( val offset = editor.getOffsetForPosition(event.x, event.y) val token = tokens.firstOrNull { - (it.type == "context" || it.type == "mention" || it.type == "skill") && + (it.type == "context" || + it.type == "mention" || + it.type == "skill" || + it.type == "citation") && offset >= it.start && offset < it.end } diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt index d4f8312b72b3b..85980784a7fd5 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ContextChip.kt @@ -160,6 +160,25 @@ class T3ContextChip( line(7f, 8f, 17f, 8f) line(7f, 12f, 14f, 12f) } + // lucide `quote`, the glyph web draws on a citation: two closing quote marks. + "quote.closing" -> { + for (x in floatArrayOf(0f, 11f)) { + moveTo(x + 5f, 3f) + quadTo(x + 3f, 3f, x + 3f, 5f) + lineTo(x + 3f, 11f) + quadTo(x + 3f, 13f, x + 5f, 13f) + quadTo(x + 6f, 13f, x + 6f, 14f) + lineTo(x + 6f, 15f) + quadTo(x + 6f, 17f, x + 4f, 17f) + quadTo(x + 3f, 17f, x + 3f, 18f) + lineTo(x + 3f, 20f) + quadTo(x + 3f, 21f, x + 4f, 21f) + quadTo(x + 10f, 21f, x + 10f, 15f) + lineTo(x + 10f, 5f) + quadTo(x + 10f, 3f, x + 8f, 3f) + close() + } + } "terminal", "play.rectangle", "photo" -> { addRoundRect(2f, 4f, 22f, 20f, 2f, 2f, Path.Direction.CW) when (symbol) { diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx new file mode 100644 index 0000000000000..b94455c438851 --- /dev/null +++ b/apps/mobile/src/components/AssistantCitationSheet.tsx @@ -0,0 +1,81 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { useRef, useState } from "react"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppSheet } from "./AppSheet"; +import { AppText as Text, AppTextInput as TextInput } from "./AppText"; + +/** A quoted assistant reply in the draft. Closing the sheet keeps the comment as typed. */ +export function AssistantCitationSheet(props: { + readonly citation: AssistantCitation; + /** Restores a comment the draft could not take, so it can be shortened. */ + readonly initialComment?: string | undefined; + /** Focuses the comment field, for a quote that was just cited. */ + readonly autoFocus?: boolean; + readonly onClose: (comment: string) => void; + /** Omitted when the draft is read-only. */ + readonly onRemove?: () => void; +}) { + const insets = useSafeAreaInsets(); + const [comment, setComment] = useState(props.initialComment ?? props.citation.comment ?? ""); + const removedRef = useRef(false); + return ( + { + if (!removedRef.current) props.onClose(comment); + }} + > + + {props.onRemove ? ( + + ) : comment ? ( + + Comment + + {comment} + + + ) : null} + + + {props.citation.text} + + + {props.onRemove ? ( + { + removedRef.current = true; + props.onRemove?.(); + }} + className="rounded-xl bg-subtle p-4" + > + Remove from draft + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index a4e2610dffc05..d976c6fbf2f72 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -1,14 +1,22 @@ -import { ComposerContextId } from "@t3tools/contracts"; +import { ComposerContextId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; import { useNavigation } from "@react-navigation/native"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import { Alert } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + collectAssistantCitations, + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor"; -import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor"; +import type { + ComposerEditorHandle as NativeComposerEditorHandle, + ComposerEditorProps as NativeComposerEditorProps, +} from "../native/T3ComposerEditor"; import { appendComposerDraftAttachments, createComposerDraftContextHistory, @@ -23,6 +31,7 @@ import { } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; import { mobilePreferencesAtom } from "../state/preferences"; +import { AssistantCitationSheet } from "./AssistantCitationSheet"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./AppText"; import { @@ -31,7 +40,18 @@ import { type ComposerDocumentAttachment, } from "../lib/composerContext"; -export type ComposerEditorProps = NativeComposerEditorProps & { +type ComposerChip = { source: string; start: number; end: number }; + +export interface ComposerEditorHandle extends NativeComposerEditorHandle { + /** + * Opens a just-inserted quote's sheet with its comment field focused. Returns false when the + * editor has moved on to another draft since the quote went into `draftKey`. + */ + openNewCitation: (draftKey: string, chip: ComposerChip) => boolean; +} + +export type ComposerEditorProps = Omit & { + readonly ref?: Ref; readonly draftKey?: string | null; readonly environmentId?: EnvironmentId; readonly onOpenMention?: (path: string) => void; @@ -48,6 +68,7 @@ export type ComposerEditorProps = NativeComposerEditorProps & { }; export function ComposerEditor({ + ref, draftKey, environmentId, onOpenMention, @@ -74,8 +95,26 @@ export function ComposerEditor({ appendComposerDraftAttachments(draftKey, restored.attachments, { allowOverflow: true }); } }; - const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( - null, + const currentDraftKey = draftKey ?? null; + const [selected, setSelected] = useState< + (ComposerChip & { draftKey: string | null; comment?: string; isNewCitation?: boolean }) | null + >(null); + // A chip's sheet belongs to the draft it was opened from. + if (selected && selected.draftKey !== currentDraftKey) setSelected(null); + const nativeRef = useRef(null); + useImperativeHandle( + ref, + () => ({ + focus: () => nativeRef.current?.focus(), + blur: () => nativeRef.current?.blur(), + setSelection: (selection) => nativeRef.current?.setSelection(selection), + openNewCitation: (chipDraftKey, chip) => { + if (chipDraftKey !== currentDraftKey) return false; + setSelected({ ...chip, draftKey: chipDraftKey, isNewCitation: true }); + return true; + }, + }), + [currentDraftKey], ); const importRef = useRef(null); const [importing, setImporting] = useState(false); @@ -164,10 +203,25 @@ export function ComposerEditor({ const record = draft.context?.records.find( (entry) => entry.contextId === selectedReference?.contextId, ); + const selectedCitation = selected + ? collectAssistantCitations(selected.source).find((entry) => entry.source === selected.source) + ?.citation + : undefined; + const editable = !props.readOnly && props.editable !== false; + // Edits apply only while the chip still sits where it was opened. + const replaceSelected = (replacement: string) => { + if (!selected || props.value.slice(selected.start, selected.end) !== selected.source) return; + changeText( + props.value.slice(0, selected.start) + replacement + props.value.slice(selected.end), + ); + const caret = selected.start + replacement.length; + props.onSelectionChange?.({ start: caret, end: caret }); + }; return ( <> { if (draftKey) @@ -205,6 +259,42 @@ export function ComposerEditor({ {importing ? ( Copying context… ) : null} + {selected && selectedCitation ? ( + { + setSelected(null); + // After citing, go back to writing around the quote. + if (selected.isNewCitation) requestAnimationFrame(() => nativeRef.current?.focus()); + if (!editable || comment.trim() === (selectedCitation.comment ?? "")) return; + const source = serializeAssistantCitation( + withAssistantCitationComment(selectedCitation, comment), + ); + if ( + props.value.length - selected.source.length + source.length > + PROVIDER_SEND_TURN_MAX_INPUT_CHARS + ) { + const chip = selected; + Alert.alert("The draft is too long", "Shorten the draft or the comment.", [ + { text: "OK", onPress: () => setSelected({ ...chip, comment }) }, + ]); + return; + } + replaceSelected(source); + }} + {...(editable + ? { + onRemove: () => { + replaceSelected(""); + setSelected(null); + }, + } + : {})} + /> + ) : null} {selected && (selectedReference || selectedSkill) ? ( setSelected(null)} onRemove={ - props.readOnly || props.editable === false - ? undefined - : () => { - if (props.value.slice(selected.start, selected.end) === selected.source) { - changeText( - props.value.slice(0, selected.start) + props.value.slice(selected.end), - ); - props.onSelectionChange?.({ start: selected.start, end: selected.start }); - } + editable + ? () => { + replaceSelected(""); setSelected(null); } + : undefined } /> ) : null} ); } -export type { - ComposerEditorHandle, - ComposerEditorSelection, - ComposerTextPaste, -} from "../native/T3ComposerEditor"; +export type { ComposerEditorSelection, ComposerTextPaste } from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e4f489115f3ad..6ee64381a2b86 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -47,6 +47,7 @@ import { isProviderNativeSubagentThread } from "@t3tools/contracts"; import type { QueuedRunEdit } from "../../state/queued-run-edit"; import type { FollowUpBehavior } from "../../lib/followUpBehavior"; import type { ActiveTurnComposerAction } from "@t3tools/client-runtime/state/composer-dispatch"; +import { voiceInputBlocksSubmission } from "@t3tools/client-runtime/voice-input"; import * as Haptics from "expo-haptics"; import { memo, @@ -142,7 +143,20 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ComposerPopoverHost } from "./ComposerPopoverHost"; -import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed"; +import { + type AssistantTextCiteRequest, + ThreadFeed, + type ThreadFeedHistoryControls, +} from "./ThreadFeed"; +import { + createMobileAssistantCitation, + insertAssistantCitation, +} from "../../lib/assistantCitationSelection"; +import { + captureComposerDraftInsertion, + rememberComposerDraftSelection, + setComposerDraftText, +} from "../../state/use-composer-drafts"; import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; @@ -1083,6 +1097,64 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread [props.onChangeDraftMessage], ); + const composerBusy = + props.activePendingApproval !== null || props.activePendingUserInput !== null; + const handleCiteAssistantText = useCallback( + (request: AssistantTextCiteRequest) => { + const draftKey = props.composerDraftKey; + if (draftKey === null || composerBusy || composerSlotHidden) { + Alert.alert( + "The composer is not ready", + "Try citing the selection after the connection or pending input is resolved.", + ); + return; + } + const citation = createMobileAssistantCitation({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + ...request, + }); + if (citation === "too-long") { + Alert.alert("Selection is too long to cite", "Select less text and try again."); + return; + } + if (citation === null) return; + // Dictation holds this draft read-only and drops its transcript if the draft changes. + if ( + voiceInputSession.ownerKey === draftKey && + voiceInputBlocksSubmission(voiceInputSession.controller.currentState) + ) { + Alert.alert("The composer is not ready", "Try citing the selection after dictation ends."); + return; + } + const target = captureComposerDraftInsertion(draftKey); + const inserted = insertAssistantCitation(target.text, target, citation); + if (inserted === null) { + Alert.alert("The draft is too long", "Shorten the draft before adding this quote."); + return; + } + draftMessageRef.current = inserted.text; + setComposerDraftText(draftKey, inserted.text); + const caret = { start: inserted.caret, end: inserted.caret }; + rememberComposerDraftSelection(draftKey, inserted.text, caret); + // Like web, citing goes straight to the quote's comment. + requestAnimationFrame(() => { + const editor = composerEditorRef.current; + // The composer can switch drafts first, e.g. when a queued run being edited starts. + if (!editor?.openNewCitation(draftKey, inserted.citation)) return; + editor.setSelection(caret); + }); + }, + [ + composerBusy, + composerSlotHidden, + props.composerDraftKey, + props.environmentId, + props.selectedThread.id, + voiceInputSession, + ], + ); + const handleScrollToEnd = useCallback(() => { void Haptics.selectionAsync(); void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { @@ -1191,6 +1263,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} onUseArtifactTemplate={handleUseArtifactTemplate} + {...(isProviderSubagent ? {} : { onCiteAssistantText: handleCiteAssistantText })} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 6198174ce8f0f..60e7df03ef22b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -310,6 +310,15 @@ export interface ThreadFeedProps { readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; + /** Adds Cite to the selection menu of assistant replies. */ + readonly onCiteAssistantText?: (request: AssistantTextCiteRequest) => void; +} + +export interface AssistantTextCiteRequest { + readonly messageId: MessageId; + /** The reply's rendered Markdown, which the selected text is located in. */ + readonly markdown: string; + readonly selectedText: string; } async function waitForThreadShell( @@ -814,9 +823,26 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; + readonly messageId?: MessageId | undefined; + readonly onCiteAssistantText?: ((selection: AssistantTextCiteRequest) => void) | undefined; }) { const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const segments = useMemo(() => splitCodexArtifactTemplateMarkdown(liveMarkdown), [liveMarkdown]); + const { messageId, onCiteAssistantText } = props; + const cite = useCallback( + (selectedText: string) => { + if (messageId === undefined || onCiteAssistantText === undefined) return; + // Positions and context come from the whole reply, not the block the selection sits in. + const markdown = segments + .flatMap((segment) => + segment.kind === "markdown" ? [renderCodexFileCitationsAsMarkdown(segment.markdown)] : [], + ) + .join("\n\n"); + onCiteAssistantText({ messageId, markdown, selectedText }); + }, + [messageId, onCiteAssistantText, segments], + ); + const onCite = messageId !== undefined && onCiteAssistantText !== undefined ? cite : undefined; return segments.map((segment) => { if (segment.kind === "artifact-template") { @@ -839,6 +865,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { textStyle={props.markdownStyles.nativeTextStyle} {...props.linkHandlers} renderImage={props.renderImage} + onCite={onCite} /> ); }); @@ -982,6 +1009,7 @@ function renderFeedEntry( ThreadFeedProps, | "environmentId" | "onUseArtifactTemplate" + | "onCiteAssistantText" | "skills" | "dispatchingMessageId" | "onEditPendingMessage" @@ -1403,6 +1431,8 @@ function renderFeedEntry( onUseArtifactTemplate={props.onUseArtifactTemplate} renderImage={props.renderMarkdownImage} skills={props.skills} + messageId={message.id} + onCiteAssistantText={props.onCiteAssistantText} /> ) : null} @@ -2513,6 +2543,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { dispatchingMessageId: props.dispatchingMessageId, onEditPendingMessage: props.onEditPendingMessage, onUseArtifactTemplate: props.onUseArtifactTemplate, + onCiteAssistantText: props.onCiteAssistantText, threadId: props.threadId, copiedRowId, expandedWorkRows, @@ -2590,6 +2621,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, props.environmentId, props.onUseArtifactTemplate, + props.onCiteAssistantText, props.threadId, props.threadTitle, props.skills, diff --git a/apps/mobile/src/lib/assistantCitationSelection.test.ts b/apps/mobile/src/lib/assistantCitationSelection.test.ts new file mode 100644 index 0000000000000..f29d95713a7e3 --- /dev/null +++ b/apps/mobile/src/lib/assistantCitationSelection.test.ts @@ -0,0 +1,127 @@ +import { + ASSISTANT_CITATION_MAX_TEXT_LENGTH, + EnvironmentId, + MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ThreadId, +} from "@t3tools/contracts"; +import { collectAssistantCitations } from "@t3tools/shared/assistantCitations"; +import { findAssistantCitationText } from "@t3tools/shared/assistantTextSelection"; +import { describe, expect, it } from "vite-plus/test"; + +import { + assistantMarkdownText, + createMobileAssistantCitation, + insertAssistantCitation, +} from "./assistantCitationSelection"; + +const ids = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), +}; + +const markdown = [ + "## Plan", + "", + "Retry the **upload** once, then [check the logs](https://example.com/logs).", + "", + "- First `step`", + "- Second step", + "", + "```ts", + "retry();", + "```", + "", + "![diagram](https://example.com/a.png)", +].join("\n"); + +describe("assistantMarkdownText", () => { + it("keeps the words a reader sees, block by block, and drops Markdown syntax", () => { + const text = assistantMarkdownText(markdown); + expect(text.replace(/\s+/g, " ")).toBe( + "Plan Retry the upload once, then check the logs. First step Second step retry();", + ); + expect(text).toMatch(/^Plan\n+Retry/); + }); +}); + +describe("createMobileAssistantCitation", () => { + it("records where a unique selection sits so web can find it again", () => { + const citation = createMobileAssistantCitation({ + ...ids, + markdown, + selectedText: "upload once, then check\nthe logs", + }); + expect(citation).toMatchObject({ + ...ids, + version: 1, + text: "upload once, then check\nthe logs", + prefix: "Plan Retry the ", + suffix: ". First step Second step retry()", + }); + if (citation === null || citation === "too-long") throw new Error("expected a citation"); + expect(findAssistantCitationText(assistantMarkdownText(markdown), citation)).toEqual({ + start: citation.start, + end: citation.end, + }); + }); + + it("keeps a repeated selection without a position web could resolve to the wrong place", () => { + expect(createMobileAssistantCitation({ ...ids, markdown, selectedText: "step" })).toEqual({ + ...ids, + version: 1, + text: "step", + start: 0, + end: 4, + prefix: "", + suffix: "", + }); + }); + + it("refuses empty and over-long selections", () => { + expect(createMobileAssistantCitation({ ...ids, markdown, selectedText: " \n " })).toBeNull(); + const long = "word ".repeat(ASSISTANT_CITATION_MAX_TEXT_LENGTH / 4); + expect(createMobileAssistantCitation({ ...ids, markdown: long, selectedText: long })).toBe( + "too-long", + ); + }); +}); + +describe("insertAssistantCitation", () => { + const citation = { + ...ids, + version: 1 as const, + text: "quote", + start: 0, + end: 5, + prefix: "", + suffix: "", + }; + + it("separates the chip from the text around it and leaves the caret after it", () => { + const inserted = insertAssistantCitation( + "Explain this please", + { start: 12, end: 12 }, + citation, + ); + const [chip] = collectAssistantCitations(inserted!.text); + expect(chip).toMatchObject({ start: inserted!.citation.start, end: inserted!.citation.end }); + expect(inserted!.text).toBe(`Explain this ${inserted!.citation.source} please`); + expect(inserted!.caret).toBe(inserted!.citation.end + 1); + }); + + it("does not add a leading space at the start or after whitespace", () => { + expect(insertAssistantCitation("", { start: 0, end: 0 }, citation)?.citation.start).toBe(0); + expect(insertAssistantCitation("Why ", { start: 4, end: 4 }, citation)?.text).toMatch( + /^Why \[Assistant quote\]\([^)]+\) $/, + ); + }); + + it("refuses a quote that would push the draft past the input limit", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - 10); + expect( + insertAssistantCitation(draft, { start: draft.length, end: draft.length }, citation), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/assistantCitationSelection.ts b/apps/mobile/src/lib/assistantCitationSelection.ts new file mode 100644 index 0000000000000..b7c9713c0d736 --- /dev/null +++ b/apps/mobile/src/lib/assistantCitationSelection.ts @@ -0,0 +1,91 @@ +import { + ASSISTANT_CITATION_MAX_TEXT_LENGTH, + type AssistantCitation, + type EnvironmentId, + type MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ThreadId, +} from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { locateAssistantTextSelector } from "@t3tools/shared/assistantTextSelection"; + +import { type MarkdownNode, parseMarkdownDocument } from "./markdownDocument"; + +const INLINE_PARENTS = new Set(["emphasis", "strong", "delete", "link", "linkReference"]); + +function appendRenderedText(node: MarkdownNode, parts: string[]): void { + switch (node.type) { + case "text": + case "inlineCode": + parts.push(node.value); + return; + case "code": + parts.push("\n", node.value, "\n"); + return; + case "break": + parts.push("\n"); + return; + } + if (!("children" in node)) return; + const inline = INLINE_PARENTS.has(node.type); + if (!inline) parts.push("\n"); + for (const child of node.children as ReadonlyArray) + appendRenderedText(child, parts); + if (!inline) parts.push("\n"); +} + +/** + * The text a reader sees in rendered assistant Markdown, one block per line. Native selection + * only reports the selected text, so this is the stream a citation's position and context come + * from. + */ +export function assistantMarkdownText(markdown: string): string { + const parts: string[] = []; + appendRenderedText(parseMarkdownDocument(markdown), parts); + return parts.join("").trim(); +} + +export function createMobileAssistantCitation(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly messageId: MessageId; + /** The Markdown rendered by the view the text was selected in. */ + readonly markdown: string; + readonly selectedText: string; +}): AssistantCitation | "too-long" | null { + const selector = locateAssistantTextSelector( + assistantMarkdownText(input.markdown), + input.selectedText, + ); + if (selector === null) return null; + if (selector.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) return "too-long"; + return { + version: 1, + environmentId: input.environmentId, + threadId: input.threadId, + messageId: input.messageId, + ...selector, + }; +} + +/** + * Inserts a citation the way web does: apart from the text before it and followed by a space. + * Returns null when the draft would pass the input limit the editor enforces on typing and paste. + */ +export function insertAssistantCitation( + text: string, + selection: { readonly start: number; readonly end: number }, + citation: AssistantCitation, +) { + const source = serializeAssistantCitation(citation); + const leading = selection.start > 0 && !/\s/.test(text[selection.start - 1]!) ? " " : ""; + const end = text[selection.end] === " " ? selection.end + 1 : selection.end; + const start = selection.start + leading.length; + const next = `${text.slice(0, selection.start)}${leading}${source} ${text.slice(end)}`; + if (next.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) return null; + return { + text: next, + citation: { source, start, end: start + source.length }, + caret: start + source.length + 1, + }; +} diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 853ae43f87915..975b098a1ac50 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -17,6 +17,7 @@ const CONTEXT_CHIP_PRESENTATIONS = { "review-comment": { accent: "#8a70dd", symbol: "text.bubble" }, "pull-request": { accent: "#7079e4", symbol: "git-pull-request" }, skill: { accent: "#b261be", symbol: "cube" }, + citation: { accent: "#4684e5", symbol: "quote.closing" }, } as const; /** diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index 4547efaa332cf..f4043114e2394 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -4,8 +4,12 @@ import { ProjectId, ProviderInstanceId, ComposerContextId, + EnvironmentId, + MessageId, + ThreadId, type OrchestrationMessageContext, } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, @@ -165,6 +169,28 @@ describe("mobile composer context", () => { for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source); }); + it("collapses an assistant quote into one chip labelled like web", () => { + const quote = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + text: "Use the retry\nqueue", + comment: "Why here?", + start: 0, + end: 19, + prefix: "", + suffix: "", + }); + const text = `Check ${quote} in [app.ts](src/app.ts) now`; + const tokens = composerContextEditorTokens(text, collectComposerInlineTokens(text)); + expect(tokens.map((token) => [token.type, token.value])).toEqual([ + ["citation", "Why here?"], + ["mention", "src/app.ts"], + ]); + for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source); + }); + it("removes deleted payloads but keeps the screenshot linked to a remaining annotation", () => { const context: OrchestrationMessageContext = { version: 1, diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index 45b033dc9488f..fa40ef181df2a 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -1,3 +1,7 @@ +import { + assistantCitationLabel, + collectAssistantCitations, +} from "@t3tools/shared/assistantCitations"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { videoMimeType } from "@t3tools/shared/video"; @@ -156,15 +160,24 @@ export function pullRequestComposerContext( /** Native editors collapse the canonical source range to a single atomic attachment. */ export function composerContextEditorTokens(text: string, tokens: readonly ComposerInlineToken[]) { const references = collectComposerContextReferences(text); + const citations = collectAssistantCitations(text); + const atomic = [...references, ...citations]; return [ ...tokens.filter( - (token) => !references.some((ref) => token.start < ref.end && token.end > ref.start), + (token) => !atomic.some((range) => token.start < range.end && token.end > range.start), ), ...references.map((ref) => ({ type: "context" as const, value: ref.label, ...ref, })), + ...citations.map(({ citation, source, start, end }) => ({ + type: "citation" as const, + value: assistantCitationLabel(citation), + source, + start, + end, + })), ].sort((a, b) => a.start - b.start); } diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index d77c9491972f0..bc03d85b7f641 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -239,6 +239,20 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { mimeTypes: { "app.t3.context-fragment": props.contextClipboardFragment! }, }; }, [props.contextClipboardFragment, props.markdown]); + const { onCite } = props; + const contextMenuItems = useMemo( + () => + onCite + ? [ + { + text: "Cite", + icon: "quote.closing", + onPress: ({ text }: { text: string }) => onCite(text), + }, + ] + : undefined, + [onCite], + ); const markdownStyle = useMemo( () => ({ ...enrichedStyle(props.textStyle, themeAppearance === "dark"), linkVariants }), [props.textStyle, themeAppearance, linkVariants], @@ -332,6 +346,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { onLinkLongPress={onLinkLongPress} linkContextMenuItems={linkContextMenuItems} linkPillContent={linkPillContent} + contextMenuItems={contextMenuItems} /> ); diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index ce8c1330ac8bc..738aa82673031 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -79,6 +79,8 @@ export interface SelectableMarkdownTextProps { readonly renderImage?: MarkdownImageRenderer; readonly resolveImageSource?: MarkdownImageSourceResolver; readonly onImagePress?: (href: string) => void; + /** Adds Cite to the text selection menu and receives the selected text. */ + readonly onCite?: (selectedText: string) => void; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index fc19e032c3c69..45f9bb614bccf 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -178,7 +178,9 @@ export function ComposerEditor({ ? (skillLabels.get(token.value) ?? token.value) : token.type === "context" ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " Β· unavailable"}` - : basename(token.value), + : token.type === "citation" + ? token.value + : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", // Only a mention wears per-filetype artwork. An attachment chip keeps the tinted // monochrome glyph web draws for it: coloured artwork ignores the chip's accent and diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 9cc4f4c36d99b..56f5ca5bf267f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -176,7 +176,9 @@ export function ComposerEditor({ ? (skillLabels.get(token.value) ?? token.value) : token.type === "context" ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " Β· unavailable"}` - : basename(token.value), + : token.type === "citation" + ? token.value + : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", // Only a mention wears per-filetype artwork. An attachment chip keeps the tinted // monochrome glyph web draws for it: coloured artwork ignores the chip's accent and diff --git a/apps/web/src/lib/assistantTextSelection.test.ts b/apps/web/src/lib/assistantTextSelection.test.ts index f4725af73ad19..8e8f09f74485f 100644 --- a/apps/web/src/lib/assistantTextSelection.test.ts +++ b/apps/web/src/lib/assistantTextSelection.test.ts @@ -1,21 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; -import { - ASSISTANT_CITATION_CONTEXT_LENGTH, - EnvironmentId, - MessageId, - ThreadId, -} from "@t3tools/contracts"; -import { - formatAssistantCitationHref, - parseAssistantCitationHref, -} from "@t3tools/shared/assistantCitations"; +import type { AssistantTextSelector } from "@t3tools/shared/assistantTextSelection"; -import { - type AssistantTextSelector, - captureAssistantTextSelection, - createAssistantTextSelector, - findAssistantCitationText, -} from "./assistantTextSelection"; +import { captureAssistantTextSelection } from "./assistantTextSelection"; function selector( text: string, @@ -31,18 +17,6 @@ function selector( }; } -function roundTripSelector(selector: AssistantTextSelector) { - return parseAssistantCitationHref( - formatAssistantCitationHref({ - version: 1, - environmentId: EnvironmentId.make("environment"), - threadId: ThreadId.make("thread"), - messageId: MessageId.make("assistant"), - ...selector, - }), - ); -} - class SelectionNode { parentElement: SelectionNode | null = null; childNodes: SelectionNode[] = []; @@ -357,230 +331,3 @@ describe("captureAssistantTextSelection", () => { ).toBeNull(); }); }); - -describe("createAssistantTextSelector", () => { - it("keeps exact selected whitespace while normalizing its UTF-16 positions", () => { - const text = "Before \n quote\t \n after"; - expect( - createAssistantTextSelector(text, "Before \n".length, "Before \n quote\t".length), - ).toEqual({ - text: " quote\t", - start: 6, - end: 13, - prefix: "Before", - suffix: "after", - }); - }); - - it.each([ - 0, - ASSISTANT_CITATION_CONTEXT_LENGTH - 2, - ASSISTANT_CITATION_CONTEXT_LENGTH - 1, - ASSISTANT_CITATION_CONTEXT_LENGTH, - ])("keeps complete context code points with %i neighboring UTF-16 units", (paddingLength) => { - const padding = "x".repeat(paddingLength); - const text = `πŸ˜€${padding}quote${padding}πŸš€`; - const start = text.indexOf("quote"); - const captured = createAssistantTextSelector(text, start, start + 5); - const keepEmoji = paddingLength + 2 <= ASSISTANT_CITATION_CONTEXT_LENGTH; - expect(captured).toEqual({ - text: "quote", - start, - end: start + 5, - prefix: `${keepEmoji ? "πŸ˜€" : ""}${padding}`, - suffix: `${padding}${keepEmoji ? "πŸš€" : ""}`, - }); - expect(roundTripSelector(captured!)).toMatchObject(captured!); - }); - - it("preserves multiline quotes and their matching location through a citation URL", () => { - const quote = "selected\r\n code πŸš€"; - const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1); - const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1); - const text = `${quote} elsewhere\nπŸ˜€${prefix}${quote}${suffix}πŸš€`; - const rawStart = text.lastIndexOf(quote); - const captured = createAssistantTextSelector(text, rawStart, rawStart + quote.length); - const start = "selected code πŸš€ elsewhere πŸ˜€".length + prefix.length; - const end = start + "selected code πŸš€".length; - expect(captured).toEqual({ text: quote, start, end, prefix, suffix }); - - const parsed = roundTripSelector(captured!); - expect(parsed).toMatchObject(captured!); - expect(findAssistantCitationText(text, parsed!)).toEqual({ start, end }); - expect(findAssistantCitationText(`Inserted paragraph.\n${text}`, parsed!)).toEqual({ - start: "Inserted paragraph. ".length + start, - end: "Inserted paragraph. ".length + end, - }); - }); - - it.each(["prefix", "suffix"])( - "does not relocate to a replacement-character decoy after clipping the %s", - (side) => { - const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "prefix" ? 1 : 0)); - const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "suffix" ? 1 : 0)); - const occurrence = (character: string) => - `${side === "prefix" ? character : ""}${prefix}quote${suffix}${side === "suffix" ? character : ""}`; - const text = `${occurrence("πŸ˜€")} / ${occurrence("\uFFFD")}`; - const start = text.indexOf("quote"); - const captured = createAssistantTextSelector(text, start, start + 5); - const parsed = roundTripSelector(captured!); - expect(parsed).toMatchObject(captured!); - expect(findAssistantCitationText(text, captured!)).toBeNull(); - expect(findAssistantCitationText(text, parsed!)).toBeNull(); - }, - ); - - it.each(["", " \n\t\r\n"])("rejects empty or whitespace-only selections: %j", (text) => { - expect(createAssistantTextSelector(text, 0, text.length)).toBeNull(); - }); -}); - -describe("findAssistantCitationText", () => { - it("resolves an exact selection with its saved position and context", () => { - expect( - findAssistantCitationText( - "Before the selected text after.", - selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }), - ), - ).toEqual({ start: 11, end: 24 }); - }); - - it("finds a unique quote when insertion before it shifts its offsets", () => { - expect( - findAssistantCitationText( - "An inserted paragraph. Before the selected text after.", - selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }), - ), - ).toEqual({ start: 34, end: 47 }); - }); - - it("still finds a unique quote after its surrounding text changes", () => { - expect( - findAssistantCitationText( - "New selected text nearby.", - selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }), - ), - ).toEqual({ start: 4, end: 17 }); - }); - - it("uses both context sides when each side alone matches several repeated quotes", () => { - const text = "left quote one; other quote two; left quote two"; - expect( - findAssistantCitationText( - text, - selector("quote", { start: 5, end: 10, prefix: "left ", suffix: " two" }), - ), - ).toEqual({ start: 38, end: 43 }); - }); - - it("does not trust stale offsets that now point at another occurrence", () => { - expect( - findAssistantCitationText( - "wrong quote here; right quote there", - selector("quote", { start: 6, end: 11, prefix: "right ", suffix: " there" }), - ), - ).toEqual({ start: 24, end: 29 }); - }); - - it.each([ - { prefix: "first ", suffix: "", expected: { start: 6, end: 11 } }, - { prefix: "", suffix: " last", expected: { start: 14, end: 19 } }, - ])( - "allows a single context side to disambiguate: $prefix / $suffix", - ({ expected, ...context }) => { - expect( - findAssistantCitationText("first quote / quote last", selector("quote", context)), - ).toEqual(expected); - }, - ); - - it("does not guess between quotes without context, even at the saved position", () => { - expect(findAssistantCitationText("quote / quote", selector("quote"))).toBeNull(); - }); - - it("does not use distance or saved offsets to break a context tie", () => { - expect( - findAssistantCitationText( - "same quote end / same quote end", - selector("quote", { start: 5, end: 10, prefix: "same ", suffix: " end" }), - ), - ).toBeNull(); - }); - - it("rejects repeated quotes when neither occurrence matches all supplied context", () => { - expect( - findAssistantCitationText( - "left quote wrong / wrong quote right", - selector("quote", { prefix: "left ", suffix: " right" }), - ), - ).toBeNull(); - }); - - it("counts overlapping occurrences when checking ambiguity", () => { - expect(findAssistantCitationText("banana", selector("ana", { start: 1, end: 4 }))).toBeNull(); - }); - - it("matches multiline code after indentation, tabs, and line endings change", () => { - const quote = "if (ready) {\r\n run();\r\n}"; - expect( - findAssistantCitationText( - "Example:\nif (ready) {\n\trun();\n}\nDone.", - selector(quote, { prefix: "Example:\n", suffix: "\nDone." }), - ), - ).toEqual({ start: 9, end: 30 }); - }); - - it("normalizes nonbreaking spaces and keeps selected boundary whitespace", () => { - expect( - findAssistantCitationText( - "before\u00a0\n quoted\t text \nafter", - selector("\nquoted text\t"), - ), - ).toEqual({ start: 6, end: 19 }); - }); - - it("does not trim the document's leading whitespace out of its offsets", () => { - expect(findAssistantCitationText("\n\t quote", selector("quote"))).toEqual({ - start: 1, - end: 6, - }); - }); - - it("uses UTF-16 offsets for emoji and combining characters", () => { - expect(findAssistantCitationText("πŸ˜€ cafe\u0301 πŸš€ done", selector("cafe\u0301 πŸš€"))).toEqual({ - start: 3, - end: 11, - }); - }); - - it("uses up to 32 UTF-16 context units without requiring the entire surrounding text", () => { - const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1) + " "; - const suffix = " " + "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1); - const text = `unrelated quote / ${prefix}quote${suffix} changed further away`; - const start = text.lastIndexOf("quote"); - expect(findAssistantCitationText(text, selector("quote", { prefix, suffix }))).toEqual({ - start, - end: start + 5, - }); - }); - - it.each(["", " \n\t\r\n", "absent", "QUOTE", "qu.te"])( - "rejects empty or missing literal text: %j", - (quote) => { - expect(findAssistantCitationText("quote", selector(quote))).toBeNull(); - }, - ); - - it.each([ - { start: -1, end: 4 }, - { start: 2.5, end: 7.5 }, - { start: Number.NaN, end: Number.NaN }, - { start: Number.POSITIVE_INFINITY, end: Number.POSITIVE_INFINITY }, - { start: 8, end: 3 }, - ])("treats invalid stored offsets as unusable hints: $start / $end", (offsets) => { - expect(findAssistantCitationText("a quote", selector("quote", offsets))).toEqual({ - start: 2, - end: 7, - }); - }); -}); diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index 1f388b2e14f94..a1314025e548a 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -1,12 +1,10 @@ -import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; - -export type AssistantTextSelector = { - readonly text: string; - readonly start: number; - readonly end: number; - readonly prefix: string; - readonly suffix: string; -}; +import type { AssistantCitation } from "@t3tools/contracts"; +import { + type AssistantTextSelector, + createAssistantTextSelector, + findAssistantCitationText, + rawTextOffset, +} from "@t3tools/shared/assistantTextSelection"; /** Live DOM state for an open comment, never part of a saved citation. */ export type AssistantCitationSourceAnchor = { @@ -38,96 +36,6 @@ const EXCLUDED_SELECTOR = `${CONTROL_SELECTOR}, [hidden], [aria-hidden=true], sc const BLOCK_SELECTOR = "address, article, aside, blockquote, dd, div, dl, dt, figcaption, figure, footer, h1, h2, h3, h4, h5, h6, header, hr, li, main, nav, ol, p, pre, section, table, td, th, tr, ul"; -function normalizeWhitespace(text: string): string { - return text.replace(/\s+/g, " "); -} - -function splitsSurrogatePair(text: string, offset: number): boolean { - const before = text.charCodeAt(offset - 1); - const after = text.charCodeAt(offset); - return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff; -} - -/** Keeps the exact captured text while storing normalized UTF-16 positions and context. */ -export function createAssistantTextSelector( - text: string, - rawStart: number, - rawEnd: number, -): AssistantTextSelector | null { - const quote = text.slice(rawStart, rawEnd); - if (quote.trim().length === 0) return null; - - const normalized = normalizeWhitespace(text); - let start = normalizeWhitespace(text.slice(0, rawStart)).length; - // A selection starting inside a whitespace run includes its normalized space. - if (rawStart > 0 && /\s/.test(text[rawStart - 1]!) && /\s/.test(text[rawStart]!)) { - start -= 1; - } - const end = normalizeWhitespace(text.slice(0, rawEnd)).length; - let prefixStart = Math.max(0, start - ASSISTANT_CITATION_CONTEXT_LENGTH); - let suffixEnd = Math.min(normalized.length, end + ASSISTANT_CITATION_CONTEXT_LENGTH); - // A split pair becomes a replacement character when the context enters a URL. - if (splitsSurrogatePair(normalized, prefixStart)) prefixStart += 1; - if (splitsSurrogatePair(normalized, suffixEnd)) suffixEnd -= 1; - return { - text: quote, - start, - end, - prefix: normalized.slice(prefixStart, start), - suffix: normalized.slice(end, suffixEnd), - }; -} - -/** - * Matches case-sensitive text after collapsing each JS whitespace run to one - * space, without trimming. All positions and context lengths use UTF-16 units - * in that normalized stream, not markdown offsets or Unicode code points. - * `selector.text` can retain the original line breaks and code indentation. - * Repeated quotes require one match for all supplied context. Even matching - * saved offsets cannot break a context tie, since those offsets may have drifted. - */ -export function findAssistantCitationText( - text: string, - selector: AssistantTextSelector, -): { start: number; end: number } | null { - const normalized = normalizeWhitespace(text); - const quote = normalizeWhitespace(selector.text); - if (quote.trim().length === 0) return null; - - const prefix = normalizeWhitespace(selector.prefix); - const suffix = normalizeWhitespace(selector.suffix); - const matchesContext = (start: number, end: number) => - normalized.slice(Math.max(0, start - prefix.length), start) === prefix && - normalized.slice(end, end + suffix.length) === suffix; - - let match = - Number.isSafeInteger(selector.start) && - Number.isSafeInteger(selector.end) && - selector.start >= 0 && - selector.end - selector.start === quote.length && - normalized.slice(selector.start, selector.end) === quote && - matchesContext(selector.start, selector.end) - ? { start: selector.start, end: selector.end } - : null; - let onlyQuote: { start: number; end: number } | null = null; - let quoteCount = 0; - - for ( - let start = normalized.indexOf(quote); - start !== -1; - start = normalized.indexOf(quote, start + 1) - ) { - const end = start + quote.length; - quoteCount += 1; - onlyQuote = { start, end }; - if (!matchesContext(start, end)) continue; - if (match !== null && match.start !== start) return null; - match = { start, end }; - } - - return match ?? (quoteCount === 1 ? onlyQuote : null); -} - type TextChunk = { node: Text; start: number; end: number }; /** @@ -251,22 +159,6 @@ export function captureAssistantTextSelection( return selector === null ? null : { source, selector, range }; } -function rawTextOffset(text: string, normalizedOffset: number): number { - let offset = 0; - for (const match of text.matchAll(/\s+|\S+/g)) { - const whitespace = /\s/.test(match[0][0]!); - const length = whitespace ? 1 : match[0].length; - if (normalizedOffset <= offset + length) { - return ( - match.index + - (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset) - ); - } - offset += length; - } - return text.length; -} - /** Resolves against the current DOM without changing the user's selection. */ export function resolveAssistantCitationRange( root: HTMLElement, diff --git a/docs/user/composer.md b/docs/user/composer.md index 831a8ac9792d1..9416fab92f192 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -96,8 +96,9 @@ Leaving reasoning level or service tier unset uses the provider's own configurat ## Quote an assistant response On web and desktop, select text within one assistant response and choose -**Cite in composer**. You can add a comment about the quote and write instructions -around it. +**Cite in composer**. On iOS and Android, select text in an assistant response and +choose **Cite** from the selection menu. You can add a comment about the quote and +write instructions around it. Select the quote in a draft or sent message to return to its source. If the source is unavailable or has changed, the saved quote remains readable. @@ -109,8 +110,9 @@ delete it like other inline context. Copying, reloading, and restoring a with its quote, and sending tells the agent which words were quoted and which comment you wrote. The quoted text and comment count toward the message limit. -Mobile displays saved quotes and comments, but does not create citations or -navigate to their sources. +On mobile, citing opens the quote so you can add a comment right away. Tap a quote chip in +the draft later to read the quote, change its comment, or remove it. Mobile does not navigate +to a quote's source yet. ## Recall a sent prompt diff --git a/packages/shared/package.json b/packages/shared/package.json index 98c9379527675..3c7518c30d541 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -251,6 +251,10 @@ "types": "./src/assistantCitations.ts", "import": "./src/assistantCitations.ts" }, + "./assistantTextSelection": { + "types": "./src/assistantTextSelection.ts", + "import": "./src/assistantTextSelection.ts" + }, "./promptLinks": { "types": "./src/promptLinks.ts", "import": "./src/promptLinks.ts" diff --git a/packages/shared/src/assistantTextSelection.test.ts b/packages/shared/src/assistantTextSelection.test.ts new file mode 100644 index 0000000000000..763731b1a0657 --- /dev/null +++ b/packages/shared/src/assistantTextSelection.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ASSISTANT_CITATION_CONTEXT_LENGTH, + EnvironmentId, + MessageId, + ThreadId, +} from "@t3tools/contracts"; + +import { formatAssistantCitationHref, parseAssistantCitationHref } from "./assistantCitations.ts"; +import { + type AssistantTextSelector, + createAssistantTextSelector, + findAssistantCitationText, + locateAssistantTextSelector, +} from "./assistantTextSelection.ts"; + +function selector( + text: string, + overrides: Partial> = {}, +): AssistantTextSelector { + return { + text, + start: 0, + end: text.replace(/\s+/g, " ").length, + prefix: "", + suffix: "", + ...overrides, + }; +} + +function roundTripSelector(selector: AssistantTextSelector) { + return parseAssistantCitationHref( + formatAssistantCitationHref({ + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("assistant"), + ...selector, + }), + ); +} + +describe("createAssistantTextSelector", () => { + it("keeps exact selected whitespace while normalizing its UTF-16 positions", () => { + const text = "Before \n quote\t \n after"; + expect( + createAssistantTextSelector(text, "Before \n".length, "Before \n quote\t".length), + ).toEqual({ + text: " quote\t", + start: 6, + end: 13, + prefix: "Before", + suffix: "after", + }); + }); + + it.each([ + 0, + ASSISTANT_CITATION_CONTEXT_LENGTH - 2, + ASSISTANT_CITATION_CONTEXT_LENGTH - 1, + ASSISTANT_CITATION_CONTEXT_LENGTH, + ])("keeps complete context code points with %i neighboring UTF-16 units", (paddingLength) => { + const padding = "x".repeat(paddingLength); + const text = `πŸ˜€${padding}quote${padding}πŸš€`; + const start = text.indexOf("quote"); + const captured = createAssistantTextSelector(text, start, start + 5); + const keepEmoji = paddingLength + 2 <= ASSISTANT_CITATION_CONTEXT_LENGTH; + expect(captured).toEqual({ + text: "quote", + start, + end: start + 5, + prefix: `${keepEmoji ? "πŸ˜€" : ""}${padding}`, + suffix: `${padding}${keepEmoji ? "πŸš€" : ""}`, + }); + expect(roundTripSelector(captured!)).toMatchObject(captured!); + }); + + it("preserves multiline quotes and their matching location through a citation URL", () => { + const quote = "selected\r\n code πŸš€"; + const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1); + const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1); + const text = `${quote} elsewhere\nπŸ˜€${prefix}${quote}${suffix}πŸš€`; + const rawStart = text.lastIndexOf(quote); + const captured = createAssistantTextSelector(text, rawStart, rawStart + quote.length); + const start = "selected code πŸš€ elsewhere πŸ˜€".length + prefix.length; + const end = start + "selected code πŸš€".length; + expect(captured).toEqual({ text: quote, start, end, prefix, suffix }); + + const parsed = roundTripSelector(captured!); + expect(parsed).toMatchObject(captured!); + expect(findAssistantCitationText(text, parsed!)).toEqual({ start, end }); + expect(findAssistantCitationText(`Inserted paragraph.\n${text}`, parsed!)).toEqual({ + start: "Inserted paragraph. ".length + start, + end: "Inserted paragraph. ".length + end, + }); + }); + + it.each(["prefix", "suffix"])( + "does not relocate to a replacement-character decoy after clipping the %s", + (side) => { + const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "prefix" ? 1 : 0)); + const suffix = "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - (side === "suffix" ? 1 : 0)); + const occurrence = (character: string) => + `${side === "prefix" ? character : ""}${prefix}quote${suffix}${side === "suffix" ? character : ""}`; + const text = `${occurrence("πŸ˜€")} / ${occurrence("\uFFFD")}`; + const start = text.indexOf("quote"); + const captured = createAssistantTextSelector(text, start, start + 5); + const parsed = roundTripSelector(captured!); + expect(parsed).toMatchObject(captured!); + expect(findAssistantCitationText(text, captured!)).toBeNull(); + expect(findAssistantCitationText(text, parsed!)).toBeNull(); + }, + ); + + it.each(["", " \n\t\r\n"])("rejects empty or whitespace-only selections: %j", (text) => { + expect(createAssistantTextSelector(text, 0, text.length)).toBeNull(); + }); +}); + +describe("findAssistantCitationText", () => { + it("resolves an exact selection with its saved position and context", () => { + expect( + findAssistantCitationText( + "Before the selected text after.", + selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }), + ), + ).toEqual({ start: 11, end: 24 }); + }); + + it("finds a unique quote when insertion before it shifts its offsets", () => { + expect( + findAssistantCitationText( + "An inserted paragraph. Before the selected text after.", + selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }), + ), + ).toEqual({ start: 34, end: 47 }); + }); + + it("still finds a unique quote after its surrounding text changes", () => { + expect( + findAssistantCitationText( + "New selected text nearby.", + selector("selected text", { start: 11, end: 24, prefix: "Before the ", suffix: " after." }), + ), + ).toEqual({ start: 4, end: 17 }); + }); + + it("uses both context sides when each side alone matches several repeated quotes", () => { + const text = "left quote one; other quote two; left quote two"; + expect( + findAssistantCitationText( + text, + selector("quote", { start: 5, end: 10, prefix: "left ", suffix: " two" }), + ), + ).toEqual({ start: 38, end: 43 }); + }); + + it("does not trust stale offsets that now point at another occurrence", () => { + expect( + findAssistantCitationText( + "wrong quote here; right quote there", + selector("quote", { start: 6, end: 11, prefix: "right ", suffix: " there" }), + ), + ).toEqual({ start: 24, end: 29 }); + }); + + it.each([ + { prefix: "first ", suffix: "", expected: { start: 6, end: 11 } }, + { prefix: "", suffix: " last", expected: { start: 14, end: 19 } }, + ])( + "allows a single context side to disambiguate: $prefix / $suffix", + ({ expected, ...context }) => { + expect( + findAssistantCitationText("first quote / quote last", selector("quote", context)), + ).toEqual(expected); + }, + ); + + it("does not guess between quotes without context, even at the saved position", () => { + expect(findAssistantCitationText("quote / quote", selector("quote"))).toBeNull(); + }); + + it("does not use distance or saved offsets to break a context tie", () => { + expect( + findAssistantCitationText( + "same quote end / same quote end", + selector("quote", { start: 5, end: 10, prefix: "same ", suffix: " end" }), + ), + ).toBeNull(); + }); + + it("rejects repeated quotes when neither occurrence matches all supplied context", () => { + expect( + findAssistantCitationText( + "left quote wrong / wrong quote right", + selector("quote", { prefix: "left ", suffix: " right" }), + ), + ).toBeNull(); + }); + + it("counts overlapping occurrences when checking ambiguity", () => { + expect(findAssistantCitationText("banana", selector("ana", { start: 1, end: 4 }))).toBeNull(); + }); + + it("matches multiline code after indentation, tabs, and line endings change", () => { + const quote = "if (ready) {\r\n run();\r\n}"; + expect( + findAssistantCitationText( + "Example:\nif (ready) {\n\trun();\n}\nDone.", + selector(quote, { prefix: "Example:\n", suffix: "\nDone." }), + ), + ).toEqual({ start: 9, end: 30 }); + }); + + it("normalizes nonbreaking spaces and keeps selected boundary whitespace", () => { + expect( + findAssistantCitationText( + "before\u00a0\n quoted\t text \nafter", + selector("\nquoted text\t"), + ), + ).toEqual({ start: 6, end: 19 }); + }); + + it("does not trim the document's leading whitespace out of its offsets", () => { + expect(findAssistantCitationText("\n\t quote", selector("quote"))).toEqual({ + start: 1, + end: 6, + }); + }); + + it("uses UTF-16 offsets for emoji and combining characters", () => { + expect(findAssistantCitationText("πŸ˜€ cafe\u0301 πŸš€ done", selector("cafe\u0301 πŸš€"))).toEqual({ + start: 3, + end: 11, + }); + }); + + it("uses up to 32 UTF-16 context units without requiring the entire surrounding text", () => { + const prefix = "x".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1) + " "; + const suffix = " " + "y".repeat(ASSISTANT_CITATION_CONTEXT_LENGTH - 1); + const text = `unrelated quote / ${prefix}quote${suffix} changed further away`; + const start = text.lastIndexOf("quote"); + expect(findAssistantCitationText(text, selector("quote", { prefix, suffix }))).toEqual({ + start, + end: start + 5, + }); + }); + + it.each(["", " \n\t\r\n", "absent", "QUOTE", "qu.te"])( + "rejects empty or missing literal text: %j", + (quote) => { + expect(findAssistantCitationText("quote", selector(quote))).toBeNull(); + }, + ); + + it.each([ + { start: -1, end: 4 }, + { start: 2.5, end: 7.5 }, + { start: Number.NaN, end: Number.NaN }, + { start: Number.POSITIVE_INFINITY, end: Number.POSITIVE_INFINITY }, + { start: 8, end: 3 }, + ])("treats invalid stored offsets as unusable hints: $start / $end", (offsets) => { + expect(findAssistantCitationText("a quote", selector("quote", offsets))).toEqual({ + start: 2, + end: 7, + }); + }); +}); + +describe("locateAssistantTextSelector", () => { + it("adds position and context when the selected text occurs once", () => { + const text = "Intro line.\nThe selected text sits here.\nOutro."; + const located = locateAssistantTextSelector(text, " selected text\n"); + + expect(located).toEqual({ + text: "selected text", + start: 16, + end: 29, + prefix: "Intro line. The ", + suffix: " sits here. Outro.", + }); + expect(findAssistantCitationText(text, roundTripSelector(located!)!)).toEqual({ + start: 16, + end: 29, + }); + }); + + it("keeps only the quote when its position is ambiguous or unknown", () => { + for (const text of ["quote / quote", "nothing to see"]) { + const located = locateAssistantTextSelector(text, "quote"); + expect(located).toEqual(selector("quote")); + expect(findAssistantCitationText(text, located!)).toBeNull(); + } + expect(findAssistantCitationText("one quote", selector("quote"))).toEqual({ + start: 4, + end: 9, + }); + }); + + it("rejects whitespace-only selections", () => { + expect(locateAssistantTextSelector("text", " \n\t")).toBeNull(); + }); +}); diff --git a/packages/shared/src/assistantTextSelection.ts b/packages/shared/src/assistantTextSelection.ts new file mode 100644 index 0000000000000..6a2fecf306842 --- /dev/null +++ b/packages/shared/src/assistantTextSelection.ts @@ -0,0 +1,142 @@ +import { ASSISTANT_CITATION_CONTEXT_LENGTH } from "@t3tools/contracts"; + +export type AssistantTextSelector = { + readonly text: string; + readonly start: number; + readonly end: number; + readonly prefix: string; + readonly suffix: string; +}; + +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, " "); +} + +function splitsSurrogatePair(text: string, offset: number): boolean { + const before = text.charCodeAt(offset - 1); + const after = text.charCodeAt(offset); + return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff; +} + +/** Keeps the exact captured text while storing normalized UTF-16 positions and context. */ +export function createAssistantTextSelector( + text: string, + rawStart: number, + rawEnd: number, +): AssistantTextSelector | null { + const quote = text.slice(rawStart, rawEnd); + if (quote.trim().length === 0) return null; + + const normalized = normalizeWhitespace(text); + let start = normalizeWhitespace(text.slice(0, rawStart)).length; + // A selection starting inside a whitespace run includes its normalized space. + if (rawStart > 0 && /\s/.test(text[rawStart - 1]!) && /\s/.test(text[rawStart]!)) { + start -= 1; + } + const end = normalizeWhitespace(text.slice(0, rawEnd)).length; + let prefixStart = Math.max(0, start - ASSISTANT_CITATION_CONTEXT_LENGTH); + let suffixEnd = Math.min(normalized.length, end + ASSISTANT_CITATION_CONTEXT_LENGTH); + // A split pair becomes a replacement character when the context enters a URL. + if (splitsSurrogatePair(normalized, prefixStart)) prefixStart += 1; + if (splitsSurrogatePair(normalized, suffixEnd)) suffixEnd -= 1; + return { + text: quote, + start, + end, + prefix: normalized.slice(prefixStart, start), + suffix: normalized.slice(end, suffixEnd), + }; +} + +/** + * Matches case-sensitive text after collapsing each JS whitespace run to one + * space, without trimming. All positions and context lengths use UTF-16 units + * in that normalized stream, not markdown offsets or Unicode code points. + * `selector.text` can retain the original line breaks and code indentation. + * Repeated quotes require one match for all supplied context. Even matching + * saved offsets cannot break a context tie, since those offsets may have drifted. + */ +export function findAssistantCitationText( + text: string, + selector: AssistantTextSelector, +): { start: number; end: number } | null { + const normalized = normalizeWhitespace(text); + const quote = normalizeWhitespace(selector.text); + if (quote.trim().length === 0) return null; + + const prefix = normalizeWhitespace(selector.prefix); + const suffix = normalizeWhitespace(selector.suffix); + const matchesContext = (start: number, end: number) => + normalized.slice(Math.max(0, start - prefix.length), start) === prefix && + normalized.slice(end, end + suffix.length) === suffix; + + let match = + Number.isSafeInteger(selector.start) && + Number.isSafeInteger(selector.end) && + selector.start >= 0 && + selector.end - selector.start === quote.length && + normalized.slice(selector.start, selector.end) === quote && + matchesContext(selector.start, selector.end) + ? { start: selector.start, end: selector.end } + : null; + let onlyQuote: { start: number; end: number } | null = null; + let quoteCount = 0; + + for ( + let start = normalized.indexOf(quote); + start !== -1; + start = normalized.indexOf(quote, start + 1) + ) { + const end = start + quote.length; + quoteCount += 1; + onlyQuote = { start, end }; + if (!matchesContext(start, end)) continue; + if (match !== null && match.start !== start) return null; + match = { start, end }; + } + + return match ?? (quoteCount === 1 ? onlyQuote : null); +} + +/** Maps a position in the whitespace-normalized stream back to `text`. */ +export function rawTextOffset(text: string, normalizedOffset: number): number { + let offset = 0; + for (const match of text.matchAll(/\s+|\S+/g)) { + const whitespace = /\s/.test(match[0][0]!); + const length = whitespace ? 1 : match[0].length; + if (normalizedOffset <= offset + length) { + return ( + match.index + + (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset) + ); + } + offset += length; + } + return text.length; +} + +/** + * Builds a selector when a client knows the selected text but not where it sits, + * such as native selection menus that report offsets per rendered block. + * A unique occurrence gets full position and context. Otherwise the selector keeps + * only the quote, so later lookups succeed only where the quote is unambiguous. + */ +export function locateAssistantTextSelector( + text: string, + selectedText: string, +): AssistantTextSelector | null { + const quote = selectedText.trim(); + if (quote.length === 0) return null; + const normalized = normalizeWhitespace(text); + const normalizedQuote = normalizeWhitespace(quote); + const start = normalized.indexOf(normalizedQuote); + if (start !== -1 && normalized.indexOf(normalizedQuote, start + 1) === -1) { + const selector = createAssistantTextSelector( + text, + rawTextOffset(text, start), + rawTextOffset(text, start + normalizedQuote.length), + ); + if (selector !== null) return { ...selector, text: quote }; + } + return { text: quote, start: 0, end: normalizedQuote.length, prefix: "", suffix: "" }; +}