From 2bd0232a3783f63610cd29646567390d508e361c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 15:26:31 -0400 Subject: [PATCH 1/8] feat(mobile): select, cite, and comment on assistant text Long-press an assistant response, adjust the selection, and choose Cite to insert a quote into the composer. Tap a draft quote to add or edit its comment, copy it, jump to the source message, or remove it. Sent quotes open the same sheet read-only. Co-Authored-By: Claude Opus 5.5 --- .../src/components/AssistantCitationSheet.tsx | 113 ++++++++++++++++++ apps/mobile/src/components/ComposerEditor.tsx | 57 +++++++-- .../features/threads/ThreadDetailScreen.tsx | 28 ++++- .../src/features/threads/ThreadFeed.tsx | 42 ++++++- .../src/lib/composerChipPresentation.ts | 1 + apps/mobile/src/lib/composerContext.test.ts | 21 +++- apps/mobile/src/lib/composerContext.ts | 15 ++- .../src/native/EnrichedMarkdownText.tsx | 11 ++ .../src/native/SelectableMarkdownText.tsx | 53 +++++++- .../native/SelectableMarkdownText.types.ts | 1 + .../src/native/T3ComposerEditor.ios.tsx | 4 +- .../src/native/T3ComposerEditor.native.tsx | 5 +- .../src/native/useEnrichedLinkVariants.ts | 11 ++ docs/user/composer.md | 6 +- 14 files changed, 341 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/src/components/AssistantCitationSheet.tsx diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx new file mode 100644 index 0000000000000..8aa8d00edf1ac --- /dev/null +++ b/apps/mobile/src/components/AssistantCitationSheet.tsx @@ -0,0 +1,113 @@ +import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useState } from "react"; +import { Platform, ScrollView, TextInput, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useOpenLinkedThread } from "../features/threads/useOpenLinkedThread"; +import { requestThreadMessageJump } from "../features/threads/threadMessageJump"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; +import { AppText as Text } from "./ui/AppText"; +import { ListActionRow } from "./ui/ListActionRow"; +import { ListSection } from "./ui/ListSection"; +import { Sheet } from "./ui/Sheet"; + +const CONTEXT_CHARS = 80; + +function contextBefore(prefix: string) { + const flat = prefix.replace(/\s+/g, " "); + if (flat.length <= CONTEXT_CHARS) return flat.trimStart(); + const cut = flat.slice(-CONTEXT_CHARS); + return `…${cut.slice(cut.indexOf(" ") + 1)}`; +} + +function contextAfter(suffix: string) { + const flat = suffix.replace(/\s+/g, " "); + if (flat.length <= CONTEXT_CHARS) return flat.trimEnd(); + const cut = flat.slice(0, CONTEXT_CHARS); + return `${cut.slice(0, cut.lastIndexOf(" "))}…`; +} + +export function AssistantCitationSheet(props: { + readonly citation: AssistantCitation; + readonly onClose: () => void; + readonly onRemove?: () => void; + readonly onCommentChange?: (comment: string) => void; +}) { + const openLinkedThread = useOpenLinkedThread(); + const insets = useSafeAreaInsets(); + const citation = props.citation; + const [comment, setComment] = useState(citation.comment ?? ""); + const close = () => { + if (props.onCommentChange && comment.trim() !== (citation.comment ?? "")) { + props.onCommentChange(comment); + } + props.onClose(); + }; + return ( + + + + + + {contextBefore(citation.prefix)} + {citation.text} + {contextAfter(citation.suffix)} + + + {props.onCommentChange ? ( + + + + ) : citation.comment ? ( + + + {citation.comment} + + + ) : null} + + { + close(); + requestThreadMessageJump(scopedThreadKey(citation), citation.messageId); + openLinkedThread(citation); + }} + /> + copyTextWithHaptic(citation.text)} + /> + {props.onRemove ? ( + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index f7118b95b425f..a8f31bf1f9316 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -7,6 +7,12 @@ import { useEffect, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + collectAssistantCitations, + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; +import { AssistantCitationSheet } from "./AssistantCitationSheet"; import { ComposerEditor as NativeComposerEditor } from "../native/T3ComposerEditor"; import type { ComposerEditorProps as NativeComposerEditorProps } from "../native/T3ComposerEditor"; import { @@ -169,6 +175,9 @@ export function ComposerEditor({ const selectedReference = selected ? collectComposerContextReferences(selected.source)[0] : undefined; + const selectedCitation = selected + ? collectAssistantCitations(selected.source)[0]?.citation + : undefined; const selectedSkillName = selected?.source.match(/^\p{Sc}(.+)$/u)?.[1]; const selectedSkill = selectedSkillName ? props.skills?.find((skill) => skill.name === selectedSkillName) @@ -176,6 +185,32 @@ export function ComposerEditor({ const record = draft.context?.records.find( (entry) => entry.contextId === selectedReference?.contextId, ); + const removeSelected = + props.readOnly || props.editable === false + ? undefined + : () => { + if (!selected) return; + 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 }); + } + setSelected(null); + }; + const commentSelectedCitation = + props.readOnly || props.editable === false || !selected || !selectedCitation + ? undefined + : (comment: string) => { + if (props.value.slice(selected.start, selected.end) !== selected.source) return; + const next = serializeAssistantCitation( + withAssistantCitationComment(selectedCitation, comment), + ); + const text = + props.value.slice(0, selected.start) + next + props.value.slice(selected.end); + const cursor = selected.start + next.length; + if (draftKey) + rememberComposerDraftSelection(draftKey, text, { start: cursor, end: cursor }); + changeText(text); + }; return ( <> {/* Reset revision history with the recreated iOS view without aborting context imports. */} @@ -226,6 +261,14 @@ export function ComposerEditor({ {importing ? ( Copying context… ) : null} + {selectedCitation ? ( + setSelected(null)} + onRemove={removeSelected} + onCommentChange={commentSelectedCitation} + /> + ) : 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 }); - } - setSelected(null); - } - } + onRemove={removeSelected} /> ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3e7eec1449e26..61e7236e03e4f 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -130,7 +130,14 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; -import { getComposerDraftSnapshot, setFreshSessionPending } from "../../state/use-composer-drafts"; +import { + captureComposerDraftInsertion, + getComposerDraftSnapshot, + setFreshSessionPending, + insertComposerDraftText, +} from "../../state/use-composer-drafts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import type { AssistantCitation } from "@t3tools/contracts"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { ANDROID_READING_MAX_WIDTH, @@ -1381,6 +1388,24 @@ function ThreadDetailBody( composerEditorRef.current?.blur(); }; + const canCiteAssistantText = + props.composerDraftKey !== null && + !isProviderSubagent && + !composerSlotHidden && + !props.isSavingQueuedEdit && + !props.draftMode?.interactionLocked; + const handleCiteAssistantText = (citation: AssistantCitation) => { + if (!canCiteAssistantText || props.composerDraftKey === null) return; + const target = captureComposerDraftInsertion(props.composerDraftKey); + const prefix = target.start > 0 && !/\s/.test(target.text[target.start - 1]!) ? " " : ""; + insertComposerDraftText( + props.composerDraftKey, + `${prefix}${serializeAssistantCitation(citation)} `, + target, + ); + composerEditorRef.current?.focus(); + }; + const handleUseArtifactTemplate = (template: CodexArtifactTemplate) => { const draftMessage = props.composerDraftKey === null ? "" : getComposerDraftSnapshot(props.composerDraftKey).text; @@ -1518,6 +1543,7 @@ function ThreadDetailBody( onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} onIsAtEndChange={setIsAtEnd} skills={selectedProviderSkills} + onCiteAssistantText={handleCiteAssistantText} onUseArtifactTemplate={handleUseArtifactTemplate} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index dbbcaa1fd9cff..fe0e914718d8f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,5 +1,6 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; +import { ASSISTANT_CITATION_MAX_TEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; @@ -60,7 +61,6 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import type { ToolCallVisibility } from "@t3tools/contracts/settings"; -import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import { hostedThreadLink, parseThreadLinkHref } from "@t3tools/shared/threadLinks"; import { PROMPT_LINK_MAX_PROMPT_LENGTH } from "@t3tools/shared/promptLinks"; @@ -347,6 +347,7 @@ export interface ThreadFeedProps { readonly onIsAtEndChange?: (isAtEnd: boolean) => void; readonly skills?: ReadonlyArray; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; + readonly onCiteAssistantText?: (citation: AssistantCitation) => void; } const USER_MESSAGE_COPY_MENU_ACTIONS = [ @@ -1138,6 +1139,8 @@ function ArtifactTemplateCard(props: { const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly environmentId: EnvironmentId; + readonly citationSource?: Pick; + readonly onCiteAssistantText?: (citation: AssistantCitation) => void; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; @@ -1156,6 +1159,32 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { return ( { + if (!text.trim()) return; + if (text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) { + Dialog.alert( + "Quote is too long", + `Select up to ${ASSISTANT_CITATION_MAX_TEXT_LENGTH.toLocaleString()} characters to cite.`, + ); + return; + } + // Native offsets are local to Markdown blocks; navigation resolves the quote in its message. + if (!props.citationSource) return; + props.onCiteAssistantText?.({ + version: 1, + environmentId: props.environmentId, + ...props.citationSource, + text, + start: 0, + end: text.replace(/\s+/g, " ").length, + prefix: "", + suffix: "", + }); + } + : undefined + } markdownStyles={props.markdownStyles} linkHandlers={linkHandlers} onUseArtifactTemplate={props.onUseArtifactTemplate} @@ -1169,6 +1198,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { // Folds split first, so an artifact card inside a fold stays inside it. function AssistantMarkdownBlocks(props: { readonly markdown: string; + readonly onCiteText?: (text: string) => void; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; @@ -1216,6 +1246,7 @@ function AssistantMarkdownBlocks(props: { skills={props.skills} models={props.models} textStyle={props.markdownStyles.nativeTextStyle} + onCiteText={props.onCiteText} {...props.linkHandlers} renderImage={props.renderImage} /> @@ -1485,7 +1516,7 @@ function ThreadAutomationMessageRow(props: { { it("keeps exact source positions and repeated references alongside existing native tokens", () => { const reference = formatComposerContextReference(terminal); - const text = `Use $babysit with $disabled $agentOnly $HOME $PATH $anything $notaskill $20 $20k and [app.ts](src/app.ts) with ${reference} then ${reference}`; + const citation = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text: "日本語 🚀 with @file.ts and $HOME", + start: 0, + end: 33, + prefix: "", + suffix: "", + }); + const text = `Use $babysit with $disabled $agentOnly $HOME $PATH $anything $notaskill $20 $20k and [app.ts](src/app.ts) with ${reference} then ${reference} (${citation}),${citation}!`; const babysit: ServerProviderSkill = { name: "babysit", path: "/skills/babysit", @@ -222,6 +237,8 @@ describe("mobile composer context", () => { "[app.ts](src/app.ts)", reference, reference, + citation, + citation, ]); for (const token of tokens) expect(text.slice(token.start, token.end)).toBe(token.source); expect( @@ -230,7 +247,7 @@ describe("mobile composer context", () => { collectComposerInlineTokens(text), composerSkillLabels([]), ).map((token) => token.type), - ).toEqual(["mention", "context", "context"]); + ).toEqual(["mention", "context", "context", "citation", "citation"]); expect( composerContextEditorTokens( text, diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index 53494209362af..644e8ddeb7a2f 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -6,6 +6,10 @@ import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSk import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { videoMimeType } from "@t3tools/shared/video"; +import { + assistantCitationLabel, + collectAssistantCitations, +} from "@t3tools/shared/assistantCitations"; import { COMPOSER_CONTEXT_MAX_RECORDS, COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS, @@ -194,13 +198,15 @@ export function composerContextEditorTokens( modelTokens: ReadonlyArray = [], ) { const references = collectComposerContextReferences(text); + const citations = collectAssistantCitations(text); const inlineTokens = tokens.filter( (token) => // `$HOME` and other `$name` text that is not one of the provider's skills stays prose. (token.type !== "skill" || knownSkills.has(token.value)) && - !references.some((ref) => token.start < ref.end && token.end > ref.start), + !references.some((ref) => token.start < ref.end && token.end > ref.start) && + !citations.some((ref) => token.start < ref.end && token.end > ref.start), ); - const taken = [...inlineTokens, ...references, ...composerMathRanges(text)]; + const taken = [...inlineTokens, ...references, ...citations, ...composerMathRanges(text)]; const models = modelTokens.filter( (mention) => !taken.some((range) => mention.start < range.end && mention.end > range.start), ); @@ -216,6 +222,11 @@ export function composerContextEditorTokens( value: mention.model, ...mention, })), + ...citations.map((ref) => ({ + type: "citation" as const, + value: assistantCitationLabel(ref.citation), + ...ref, + })), ].sort((a, b) => a.start - b.start); } diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 86bf8021bdd0f..a9a4570c22b6d 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -420,6 +420,17 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { containerStyle={{ flexShrink: 1, minWidth: 0 }} flavor="github" selectable + contextMenuItems={ + props.onCiteText + ? [ + { + text: "Cite", + icon: "quote.bubble", + onPress: ({ text }) => props.onCiteText?.(text), + }, + ] + : undefined + } selectionClipboard={selectionClipboard} selectionColor={props.textStyle.selectionColor} selectionHandleColor={props.textStyle.selectionHandleColor} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index e49405af4dd4f..d05e91304bf94 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,4 +1,9 @@ import { Platform } from "react-native"; +import { useState } from "react"; +import type { AssistantCitation } from "@t3tools/contracts"; +import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; +import { AssistantCitationSheet } from "../components/AssistantCitationSheet"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; import { MobileEnrichedMarkdownText } from "./EnrichedMarkdownText"; @@ -17,6 +22,8 @@ export type { } from "./SelectableMarkdownText.types"; export function SelectableMarkdownText(props: SelectableMarkdownTextProps) { + const [citation, setCitation] = useState(null); + const hasCitations = props.markdown.includes("t3-citation:"); const theme = useUniwindTheme(); const selectionColor = themeColorWithAlpha(theme["--color-focus"], 0.32); const selectionHandleColor = theme["--color-focus"]; @@ -24,5 +31,49 @@ export function SelectableMarkdownText(props: SelectableMarkdownTextProps) { Platform.OS === "android" ? { selectionColor, selectionHandleColor, ...props.textStyle } : props.textStyle; - return ; + return ( + <> + { + const quote = parseAssistantCitationHref(href); + if (quote) setCitation(quote); + else props.onLinkPress?.(href); + } + : props.onLinkPress + } + fileContextMenu={ + hasCitations + ? (href) => + parseAssistantCitationHref(href) + ? { + title: "Assistant quote", + actions: [ + { id: "open-quote", title: "Open quote" }, + { id: "copy-quote", title: "Copy quote" }, + ], + } + : props.fileContextMenu?.(href) + : props.fileContextMenu + } + onFileContextMenuAction={ + hasCitations + ? (href, actionId) => { + const quote = parseAssistantCitationHref(href); + if (quote) { + if (actionId === "open-quote") setCitation(quote); + else if (actionId === "copy-quote") copyTextWithHaptic(quote.text); + } else props.onFileContextMenuAction?.(href, actionId); + } + : props.onFileContextMenuAction + } + /> + {citation ? ( + setCitation(null)} /> + ) : null} + + ); } diff --git a/apps/mobile/src/native/SelectableMarkdownText.types.ts b/apps/mobile/src/native/SelectableMarkdownText.types.ts index 63f8429c386fd..95b9545122b97 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -77,6 +77,7 @@ export interface SelectableMarkdownTextProps { readonly models?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly onCiteText?: (text: string) => void; readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction?: (href: string, actionId: string) => void; readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 437bcda4d012d..26cc26fd6b3c4 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -200,7 +200,7 @@ export function ComposerEditor({ token.type === "mention" ? token.value : contextRecordFileName(record), ); return { - type: token.type, + type: token.type === "citation" ? "context" : token.type, source: token.source, start: token.start, end: token.end, @@ -216,6 +216,8 @@ export function ComposerEditor({ ? model.label : token.type === "context" ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` + : token.type === "citation" + ? token.value : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon), diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 5aabd8e0d6071..15ab04f93a2b2 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -200,8 +200,7 @@ export function ComposerEditor({ token.type === "mention" ? token.value : contextRecordFileName(record), ); return { - // Android taps only known token types; a model chip opens like a skill chip. - type: token.type === "model" ? "skill" : token.type, + type: token.type === "model" ? "skill" : token.type === "citation" ? "context" : token.type, source: token.source, start: token.start, end: token.end, @@ -217,6 +216,8 @@ export function ComposerEditor({ ? model.label : token.type === "context" ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` + : token.type === "citation" + ? token.value : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon), diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index 2d4cf85cb7db1..f05f66aec6f28 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -1,5 +1,9 @@ import { modelChipIcon } from "../lib/contextChipIconSource"; import { useMemo } from "react"; +import { + assistantCitationLabel, + parseAssistantCitationHref, +} from "@t3tools/shared/assistantCitations"; import { Image } from "react-native"; import type { LinkPillContent, MarkdownStyle } from "react-native-enriched-markdown"; import type { ComposerContextRecord } from "@t3tools/contracts"; @@ -65,6 +69,13 @@ function presentation( ): MarkdownLinkCustomization | undefined { const custom = props.linkCustomization?.(url); if (custom) return custom; + const citation = parseAssistantCitationHref(url); + if (citation) + return { + label: assistantCitationLabel(citation), + icon: CONTEXT_CHIP_ICON_SOURCES["message-circle"], + chip: { accent: contextChipPresentation("citation").accent, tintsIcon: true }, + }; const context = enrichedContextLinkCustomization(url); if (context) return context; const skill = enrichedSkillReference(url, props.skills); diff --git a/docs/user/composer.md b/docs/user/composer.md index df880e49c6ca5..862d48792eba1 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -142,8 +142,10 @@ 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, long-press an assistant response, adjust the text selection, and choose +**Cite** to insert a quote at the composer cursor. Tap a quote in a draft to add +or edit its comment, or to remove it. Tap a quote in a draft or sent message to +open its source message. ## Recall a sent prompt From 0d134febc5619397f88cd2cc4fb5ef8883432eb8 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 28 Oct 2026 15:44:06 -0400 Subject: [PATCH 2/8] docs(mobile): name Go to message for opening a quote's source Co-Authored-By: Claude Opus 5.5 --- docs/user/composer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index 862d48792eba1..7bff24f0dab48 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -144,8 +144,8 @@ The quoted text and comment count toward the message limit. On mobile, long-press an assistant response, adjust the text selection, and choose **Cite** to insert a quote at the composer cursor. Tap a quote in a draft to add -or edit its comment, or to remove it. Tap a quote in a draft or sent message to -open its source message. +or edit its comment, or to remove it. To open the source message, tap a quote in +a draft or sent message, then choose **Go to message**. ## Recall a sent prompt From 02568f2cbcf012fe4849ff877ee4a97272c66a7e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 10:10:55 -0500 Subject: [PATCH 3/8] fix(mobile): keep voice drafts locked when citing text --- .../features/threads/ThreadDetailScreen.tsx | 9 +++- .../src/features/threads/ThreadFeed.tsx | 50 +++++++++---------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 61e7236e03e4f..8f4ffab6f7879 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -218,6 +218,7 @@ import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-scroll-position import { useOpenLinkedThread } from "./useOpenLinkedThread"; import { useGlobalVoiceInput } from "../voice-input/VoiceInputProvider"; import type { OrchestrationV2ProviderSession } from "@t3tools/contracts"; +import { voiceInputBlocksSubmission } from "@t3tools/client-runtime/voice-input"; export interface ThreadDetailScreenProps { readonly header?: React.ReactNode; @@ -1395,7 +1396,13 @@ function ThreadDetailBody( !props.isSavingQueuedEdit && !props.draftMode?.interactionLocked; const handleCiteAssistantText = (citation: AssistantCitation) => { - if (!canCiteAssistantText || props.composerDraftKey === null) return; + if ( + !canCiteAssistantText || + props.composerDraftKey === null || + (voiceInputSession.ownerKey === props.composerDraftKey && + voiceInputBlocksSubmission(voiceInputSession.controller.currentState)) + ) + return; const target = captureComposerDraftInsertion(props.composerDraftKey); const prefix = target.start > 0 && !/\s/.test(target.text[target.start - 1]!) ? " " : ""; insertComposerDraftText( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fe0e914718d8f..ad8f9cd3281a3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1159,32 +1159,32 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { return ( { - if (!text.trim()) return; - if (text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) { - Dialog.alert( - "Quote is too long", - `Select up to ${ASSISTANT_CITATION_MAX_TEXT_LENGTH.toLocaleString()} characters to cite.`, - ); - return; - } - // Native offsets are local to Markdown blocks; navigation resolves the quote in its message. - if (!props.citationSource) return; - props.onCiteAssistantText?.({ - version: 1, - environmentId: props.environmentId, - ...props.citationSource, - text, - start: 0, - end: text.replace(/\s+/g, " ").length, - prefix: "", - suffix: "", - }); + onCiteText={ + props.onCiteAssistantText && props.citationSource + ? (text) => { + if (!text.trim()) return; + if (text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) { + Dialog.alert( + "Quote is too long", + `Select up to ${ASSISTANT_CITATION_MAX_TEXT_LENGTH.toLocaleString()} characters to cite.`, + ); + return; } - : undefined - } + // Native offsets are local to Markdown blocks; navigation resolves the quote in its message. + if (!props.citationSource) return; + props.onCiteAssistantText?.({ + version: 1, + environmentId: props.environmentId, + ...props.citationSource, + text, + start: 0, + end: text.replace(/\s+/g, " ").length, + prefix: "", + suffix: "", + }); + } + : undefined + } markdownStyles={props.markdownStyles} linkHandlers={linkHandlers} onUseArtifactTemplate={props.onUseArtifactTemplate} From 2ee5433cf61f866865035285d4108e3fcdbe2b76 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 21:49:00 +0000 Subject: [PATCH 4/8] fix(mobile): hide cite while voice input blocks drafts --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8f4ffab6f7879..6cc78df8dec21 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1394,7 +1394,11 @@ function ThreadDetailBody( !isProviderSubagent && !composerSlotHidden && !props.isSavingQueuedEdit && - !props.draftMode?.interactionLocked; + !props.draftMode?.interactionLocked && + !( + voiceInputSession.ownerKey === props.composerDraftKey && + voiceInputBlocksSubmission(voiceInputSession.controller.currentState) + ); const handleCiteAssistantText = (citation: AssistantCitation) => { if ( !canCiteAssistantText || From 16f43680a82e16ebb01627840acb5cadca8a99ed Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 12 Nov 2026 09:29:31 -0500 Subject: [PATCH 5/8] fix(mobile): keep citation drafts within input limits --- .../src/components/AssistantCitationSheet.tsx | 14 ++++++++-- .../components/ComposerEditor.ios.test.tsx | 1 + apps/mobile/src/components/ComposerEditor.tsx | 20 ++++++++++++++ .../features/threads/ThreadDetailScreen.tsx | 18 ++++++++----- apps/mobile/src/lib/composerContext.test.ts | 27 +++++++++++++++++++ apps/mobile/src/lib/composerContext.ts | 9 +++++++ .../src/native/T3ComposerEditor.ios.tsx | 2 +- .../src/native/T3ComposerEditor.native.tsx | 5 ++-- 8 files changed, 85 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx index 8aa8d00edf1ac..62185cc3b9f89 100644 --- a/apps/mobile/src/components/AssistantCitationSheet.tsx +++ b/apps/mobile/src/components/AssistantCitationSheet.tsx @@ -32,19 +32,23 @@ export function AssistantCitationSheet(props: { readonly onClose: () => void; readonly onRemove?: () => void; readonly onCommentChange?: (comment: string) => void; + readonly canChangeComment?: (comment: string) => boolean; }) { const openLinkedThread = useOpenLinkedThread(); const insets = useSafeAreaInsets(); const citation = props.citation; const [comment, setComment] = useState(citation.comment ?? ""); + const commentChanged = comment.trim() !== (citation.comment ?? ""); + const commentTooLong = commentChanged && props.canChangeComment?.(comment) === false; const close = () => { - if (props.onCommentChange && comment.trim() !== (citation.comment ?? "")) { + if (commentTooLong) return; + if (props.onCommentChange && commentChanged) { props.onCommentChange(comment); } props.onClose(); }; return ( - + + {commentTooLong ? ( + + This comment makes the message too long to send. Shorten it to save and close. + + ) : null} ) : citation.comment ? ( @@ -88,6 +97,7 @@ export function AssistantCitationSheet(props: { icon="arrow.up.right" label="Go to message" onPress={() => { + if (commentTooLong) return; close(); requestThreadMessageJump(scopedThreadKey(citation), citation.messageId); openLinkedThread(citation); diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx index f7ce1f9918d85..60f37716b84bb 100644 --- a/apps/mobile/src/components/ComposerEditor.ios.test.tsx +++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx @@ -116,6 +116,7 @@ vi.mock("expo-file-system", () => ({ vi.mock("./ui/Dialog", () => ({ alert: vi.fn() })); vi.mock("./ui/AppText", () => ({ AppText: ({ children }: { children?: ReactNode }) => children })); vi.mock("./ComposerContextSheet", () => ({ ComposerContextSheet: () => null })); +vi.mock("./AssistantCitationSheet", () => ({ AssistantCitationSheet: () => null })); vi.mock("../features/threads/useOpenLinkedThread", () => ({ useOpenLinkedThread: () => vi.fn() })); vi.mock("../state/peerLinks", () => ({ useLinkedEnvironments: () => [] })); vi.mock("../state/model-mentions", () => ({ useModelMentions: () => [] })); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index a8f31bf1f9316..ea527efdb8e94 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -40,6 +40,7 @@ import { useLinkedEnvironments } from "../state/peerLinks"; import { ComposerContextSheet } from "./ComposerContextSheet"; import { AppText as Text } from "./ui/AppText"; import { + assistantCitationDraftWithinLimit, composerDocumentAttachment, composerMentionPath, type ComposerDocumentAttachment, @@ -206,6 +207,13 @@ export function ComposerEditor({ ); const text = props.value.slice(0, selected.start) + next + props.value.slice(selected.end); + if (!assistantCitationDraftWithinLimit(text)) { + Dialog.alert( + "Could not update quote", + "This comment would make the message too long to send. Shorten the comment or draft.", + ); + return; + } const cursor = selected.start + next.length; if (draftKey) rememberComposerDraftSelection(draftKey, text, { start: cursor, end: cursor }); @@ -267,6 +275,18 @@ export function ComposerEditor({ onClose={() => setSelected(null)} onRemove={removeSelected} onCommentChange={commentSelectedCitation} + canChangeComment={ + selected && commentSelectedCitation + ? (comment) => + assistantCitationDraftWithinLimit( + props.value.slice(0, selected.start) + + serializeAssistantCitation( + withAssistantCitationComment(selectedCitation, comment), + ) + + props.value.slice(selected.end), + ) + : undefined + } /> ) : null} {selected && (selectedReference || selectedSkill) ? ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 6cc78df8dec21..c6c987f8838da 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -136,6 +136,7 @@ import { setFreshSessionPending, insertComposerDraftText, } from "../../state/use-composer-drafts"; +import { assistantCitationDraftWithinLimit } from "../../lib/composerContext"; import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import type { AssistantCitation } from "@t3tools/contracts"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; @@ -1409,11 +1410,16 @@ function ThreadDetailBody( return; const target = captureComposerDraftInsertion(props.composerDraftKey); const prefix = target.start > 0 && !/\s/.test(target.text[target.start - 1]!) ? " " : ""; - insertComposerDraftText( - props.composerDraftKey, - `${prefix}${serializeAssistantCitation(citation)} `, - target, - ); + const value = `${prefix}${serializeAssistantCitation(citation)} `; + const text = target.text.slice(0, target.start) + value + target.text.slice(target.end); + if (!assistantCitationDraftWithinLimit(text)) { + Dialog.alert( + "Could not add quote", + "This quote would make the message too long to send. Shorten the draft or select less text.", + ); + return; + } + insertComposerDraftText(props.composerDraftKey, value, target); composerEditorRef.current?.focus(); }; @@ -1554,7 +1560,7 @@ function ThreadDetailBody( onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} onIsAtEndChange={setIsAtEnd} skills={selectedProviderSkills} - onCiteAssistantText={handleCiteAssistantText} + onCiteAssistantText={canCiteAssistantText ? handleCiteAssistantText : undefined} onUseArtifactTemplate={handleUseArtifactTemplate} /> diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index 3366846439c6d..657970c28a999 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -1,4 +1,5 @@ import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ComposerContextId, EnvironmentId, MessageId, @@ -16,6 +17,7 @@ import { } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { + assistantCitationDraftWithinLimit, collectComposerModelTokens, composerContextEditorTokens, composerSkillLabels, @@ -205,6 +207,31 @@ describe("mobile composer context", () => { expect(collectComposerModelTokens("model:codex/gpt-6.1", known, kept)).toEqual([]); }); + it("rejects citation drafts exceeding either encoded or expanded input limits", () => { + const quote = (text: string) => + serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text, + start: 0, + end: text.length, + prefix: "", + suffix: "", + }); + expect(assistantCitationDraftWithinLimit(`Explain ${quote("short quote")}`)).toBe(true); + expect( + assistantCitationDraftWithinLimit( + `${quote("日".repeat(8_000))} ${quote("語".repeat(8_000))}`, + ), + ).toBe(false); + const short = quote("x"); + const expandedOverflow = "a".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - short.length) + short; + expect(expandedOverflow.length).toBe(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(assistantCitationDraftWithinLimit(expandedOverflow)).toBe(false); + }); + it("keeps exact source positions and repeated references alongside existing native tokens", () => { const reference = formatComposerContextReference(terminal); const citation = serializeAssistantCitation({ diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index 644e8ddeb7a2f..403e01da6efd2 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -9,8 +9,10 @@ import { videoMimeType } from "@t3tools/shared/video"; import { assistantCitationLabel, collectAssistantCitations, + expandAssistantCitationsForProvider, } from "@t3tools/shared/assistantCitations"; import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, COMPOSER_CONTEXT_MAX_RECORDS, COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS, ComposerContextId, @@ -40,6 +42,13 @@ import { const isMessageContext = Schema.is(OrchestrationMessageContext); const decodeMessageContext = Schema.decodeUnknownOption(OrchestrationMessageContext); +export function assistantCitationDraftWithinLimit(text: string): boolean { + return ( + text.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS && + expandAssistantCitationsForProvider(text).length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS + ); +} + export function composerContextSendBlockReason( context?: OrchestrationMessageContext, ): string | null { diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 26cc26fd6b3c4..efd7ba0e9a94f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -218,7 +218,7 @@ export function ComposerEditor({ ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` : token.type === "citation" ? token.value - : basename(token.value), + : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon), tintsIcon: model ? modelChipIcon(model).tintsIcon : icon.kind === "chip", diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index 15ab04f93a2b2..e369e5257d8fc 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -200,7 +200,8 @@ export function ComposerEditor({ token.type === "mention" ? token.value : contextRecordFileName(record), ); return { - type: token.type === "model" ? "skill" : token.type === "citation" ? "context" : token.type, + type: + token.type === "model" ? "skill" : token.type === "citation" ? "context" : token.type, source: token.source, start: token.start, end: token.end, @@ -218,7 +219,7 @@ export function ComposerEditor({ ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` : token.type === "citation" ? token.value - : basename(token.value), + : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon), tintsIcon: model ? modelChipIcon(model).tintsIcon : icon.kind === "chip", From ca30f54290d7684556357cba1dbd2aefc9906031 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 12 Nov 2026 10:42:41 -0500 Subject: [PATCH 6/8] fix(citations): preserve mobile quote sources and bound expansion --- .../src/features/threads/ThreadFeed.tsx | 128 ++++++++++++------ .../lib/assistantCitationSelection.test.ts | 70 ++++++++++ .../src/lib/assistantCitationSelection.ts | 102 ++++++++++++++ .../ProviderTurnControlService.ts | 18 ++- .../ProviderTurnStartService.test.ts | 39 ++++++ .../ProviderTurnStartService.ts | 32 +++-- .../src/lib/assistantTextSelection.test.ts | 17 +++ apps/web/src/lib/assistantTextSelection.ts | 101 ++------------ docs/internals/assistant-citations.md | 6 +- docs/user/composer.md | 4 +- packages/contracts/src/assistantCitations.ts | 9 ++ .../shared/src/assistantCitations.test.ts | 16 +++ packages/shared/src/assistantCitations.ts | 112 ++++++++++++++- 13 files changed, 510 insertions(+), 144 deletions(-) create mode 100644 apps/mobile/src/lib/assistantCitationSelection.test.ts create mode 100644 apps/mobile/src/lib/assistantCitationSelection.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ad8f9cd3281a3..3e4748f723af0 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,7 @@ +import { Sheet } from "../../components/ui/Sheet"; +import { ListActionRow } from "../../components/ui/ListActionRow"; +import { ListSection } from "../../components/ui/ListSection"; +import { collectMobileAssistantTextSelectors } from "../../lib/assistantCitationSelection"; import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { ASSISTANT_CITATION_MAX_TEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; @@ -117,6 +121,7 @@ import { } from "react"; import { Image, + ScrollView, Platform, type LayoutChangeEvent, type NativeScrollEvent, @@ -1147,6 +1152,19 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly renderImage: MarkdownImageRenderer; readonly skills?: ReadonlyArray | undefined; }) { + const [quoteChoices, setQuoteChoices] = useState< + ReturnType + >([]); + const cite = (selector: (typeof quoteChoices)[number]) => { + if (!props.citationSource || !props.onCiteAssistantText) return; + setQuoteChoices([]); + props.onCiteAssistantText({ + version: 1, + environmentId: props.environmentId, + ...props.citationSource, + ...selector, + }); + }; const models = useModelMentions(props.environmentId, props.markdown); const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown); @@ -1157,48 +1175,68 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { }; return ( - { - if (!text.trim()) return; - if (text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) { - Dialog.alert( - "Quote is too long", - `Select up to ${ASSISTANT_CITATION_MAX_TEXT_LENGTH.toLocaleString()} characters to cite.`, - ); - return; + <> + { + if (!text.trim()) return; + if (text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH) { + Dialog.alert( + "Quote is too long", + `Select up to ${ASSISTANT_CITATION_MAX_TEXT_LENGTH.toLocaleString()} characters to cite.`, + ); + return; + } + const selectors = collectMobileAssistantTextSelectors(liveMarkdown, path, text); + if (selectors.length === 0) { + Dialog.alert( + "Unable to locate quote", + "Select more surrounding text to cite its source accurately.", + ); + return; + } + if (selectors.length === 1) cite(selectors[0]!); + else setQuoteChoices(selectors); } - // Native offsets are local to Markdown blocks; navigation resolves the quote in its message. - if (!props.citationSource) return; - props.onCiteAssistantText?.({ - version: 1, - environmentId: props.environmentId, - ...props.citationSource, - text, - start: 0, - end: text.replace(/\s+/g, " ").length, - prefix: "", - suffix: "", - }); - } - : undefined - } - markdownStyles={props.markdownStyles} - linkHandlers={linkHandlers} - onUseArtifactTemplate={props.onUseArtifactTemplate} - renderImage={props.renderImage} - skills={props.skills} - models={models} - /> + : undefined + } + markdownStyles={props.markdownStyles} + linkHandlers={linkHandlers} + onUseArtifactTemplate={props.onUseArtifactTemplate} + renderImage={props.renderImage} + skills={props.skills} + models={models} + /> + {quoteChoices.length > 0 && props.onCiteAssistantText ? ( + setQuoteChoices([])}> + + + {quoteChoices.map((selector) => ( + cite(selector)} + /> + ))} + + + + ) : null} + ); }); // Folds split first, so an artifact card inside a fold stays inside it. function AssistantMarkdownBlocks(props: { readonly markdown: string; - readonly onCiteText?: (text: string) => void; + readonly citationPath?: string; + readonly onCiteText?: (text: string, path: string) => void; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; @@ -1215,7 +1253,7 @@ function AssistantMarkdownBlocks(props: { ), [props.markdown], ); - return blocks.map((block) => { + return blocks.map((block, blockIndex) => { if (block.kind === "details") { return ( - + ); } - return block.segments.map((segment) => { + return block.segments.map((segment, segmentIndex) => { const offset = block.sourceOffset + segment.sourceOffset; if (segment.kind === "artifact-template") { return ( @@ -1246,7 +1288,15 @@ function AssistantMarkdownBlocks(props: { skills={props.skills} models={props.models} textStyle={props.markdownStyles.nativeTextStyle} - onCiteText={props.onCiteText} + onCiteText={ + props.onCiteText + ? (text) => + props.onCiteText?.( + text, + `${props.citationPath ?? ""}${blockIndex}:${segmentIndex}`, + ) + : undefined + } {...props.linkHandlers} renderImage={props.renderImage} /> diff --git a/apps/mobile/src/lib/assistantCitationSelection.test.ts b/apps/mobile/src/lib/assistantCitationSelection.test.ts new file mode 100644 index 0000000000000..6b2739394040f --- /dev/null +++ b/apps/mobile/src/lib/assistantCitationSelection.test.ts @@ -0,0 +1,70 @@ +import { expect, it } from "vite-plus/test"; +import { collectMobileAssistantTextSelectors } from "./assistantCitationSelection"; +import { + findAssistantCitationText, + serializeAssistantCitation, + expandAssistantCitationsForProvider, +} from "@t3tools/shared/assistantCitations"; +import { EnvironmentId, ThreadId, MessageId } from "@t3tools/contracts"; + +it("offers distinct source choices within a block and maps recursive folds without merging occurrences", () => { + const paragraph = `${"x".repeat(40)}**quote**${"x".repeat(40)}`; + const markdown = `${paragraph}\n\n
Outer\n\n
Inner\n\n${paragraph}\n\n
\n\n
`; + const first = collectMobileAssistantTextSelectors(markdown, "0:0", "quote")[0]; + const second = collectMobileAssistantTextSelectors(markdown, "1/1/0:0", "quote")[0]; + expect(first?.occurrence).toEqual({ index: 0, count: 2 }); + expect(second?.occurrence).toEqual({ index: 1, count: 2 }); + const rendered = `${"x".repeat(40)}quote${"x".repeat(40)} ${"x".repeat(40)}quote${"x".repeat(40)}`; + expect(findAssistantCitationText(rendered, second!)).toEqual({ + start: rendered.lastIndexOf("quote"), + end: rendered.lastIndexOf("quote") + 5, + }); + const markers = [first!, second!].map((selector) => + serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("assistant"), + ...selector, + }), + ); + expect(markers[0]).not.toBe(markers[1]); + expect(expandAssistantCitationsForProvider(markers.join(" "))).toContain( + '"id": "assistant-quote-2"', + ); + const sameBlock = collectMobileAssistantTextSelectors( + "first **quote** then **quote** last", + "0:0", + "quote", + ); + expect(sameBlock[1]?.start).toBe(17); + expect(sameBlock[1]?.prefix).toBe("first quote then "); +}); + +it("rejects unmappable or blank selections instead of inventing a source", () => { + expect(collectMobileAssistantTextSelectors("quote quote", "0:0", " ")).toEqual([]); + expect(collectMobileAssistantTextSelectors("quote ".repeat(21), "0:0", "quote")).toEqual([]); + expect(collectMobileAssistantTextSelectors("quote quote", "0:0", "other")).toEqual([]); + expect(collectMobileAssistantTextSelectors("quote quote", "missing", "quote")).toEqual([]); +}); + +it("uses the web math representation around a selected passage", () => { + for (const formula of ["$x$", "\\(x\\)"]) { + const [selector] = collectMobileAssistantTextSelectors( + `Before ${formula} after`, + "0:0", + "after", + ); + expect(selector?.prefix).toBe("Before \\(x\\) "); + expect(findAssistantCitationText("Before \\(x\\) after", selector!)).toEqual({ + start: 13, + end: 18, + }); + } + const [display] = collectMobileAssistantTextSelectors( + "Before\n\n$$\nx\n$$\n\nafter", + "0:0", + "after", + ); + expect(display?.prefix).toBe("Before \\[ x \\] "); +}); diff --git a/apps/mobile/src/lib/assistantCitationSelection.ts b/apps/mobile/src/lib/assistantCitationSelection.ts new file mode 100644 index 0000000000000..350b5e4d8fc44 --- /dev/null +++ b/apps/mobile/src/lib/assistantCitationSelection.ts @@ -0,0 +1,102 @@ +import { markdownMathRanges } from "@t3tools/shared/markdownMath"; +import { createAssistantTextSelector } from "@t3tools/shared/assistantCitations"; +import { + splitCodexArtifactTemplateMarkdown, + renderCodexInlineDirectivesAsMarkdown, +} from "@t3tools/shared/codexMarkdownDirectives"; +import { splitMarkdownDetails } from "./markdownDetails"; +import { parseMarkdownDocument, type MarkdownNode } from "./markdownDocument"; + +function renderedText(node: MarkdownNode, restoreMath: (value: string) => string): string { + if ( + node.type === "definition" || + node.type === "image" || + node.type === "imageReference" || + node.type === "html" + ) + return ""; + if ("value" in node) return restoreMath(node.value); + if ("children" in node) { + const inline = + node.type === "paragraph" || + node.type === "heading" || + node.type === "link" || + node.type === "linkReference" || + node.type === "emphasis" || + node.type === "strong" || + node.type === "delete" || + node.type === "tableCell"; + return node.children.map((child) => renderedText(child, restoreMath)).join(inline ? "" : "\n"); + } + return node.type === "break" ? "\n" : ""; +} + +function citationText(markdown: string): string { + const formulas = markdownMathRanges(markdown); + let marker = "\ue000t3math"; + while (markdown.includes(marker)) marker += "_"; + const tokens = formulas.map((_, index) => `${marker}${index}\ue001`); + let cursor = 0; + let prose = ""; + for (const [index, formula] of formulas.entries()) { + prose += markdown.slice(cursor, formula.start) + tokens[index]; + cursor = formula.end; + } + prose += markdown.slice(cursor); + return renderedText(parseMarkdownDocument(prose), (value) => + value.replace(new RegExp(`${marker}(\\d+)\ue001`, "g"), (_, index: string) => { + const formula = formulas[Number(index)]!; + const tex = formula.tex.trim(); + return formula.display ? `\\[\n${tex}\n\\]` : `\\(${tex}\\)`; + }), + ); +} + +function citationSegments(markdown: string, path = ""): Array<{ path: string; text: string }> { + return splitMarkdownDetails(markdown).flatMap((block, index) => { + if (block.kind === "details") return citationSegments(block.body, `${path}${index}/`); + return splitCodexArtifactTemplateMarkdown(block.markdown).flatMap((segment, segmentIndex) => + segment.kind === "markdown" + ? [ + { + path: `${path}${index}:${segmentIndex}`, + text: citationText(renderCodexInlineDirectivesAsMarkdown(segment.markdown)), + }, + ] + : [], + ); + }); +} + +function occurrences(text: string, quote: string): number[] { + const result: number[] = []; + for (let index = text.indexOf(quote); index !== -1; index = text.indexOf(quote, index + 1)) + result.push(index); + return result; +} + +export function collectMobileAssistantTextSelectors(markdown: string, path: string, text: string) { + if (!text.trim()) return []; + const normalize = (value: string) => value.replace(/\s+/g, " "); + const quote = normalize(text); + const segments = citationSegments(markdown); + const segmentIndex = segments.findIndex((segment) => segment.path === path); + const segment = segments[segmentIndex]; + if (!segment) return []; + const leading = segments + .slice(0, segmentIndex) + .map((entry) => entry.text) + .join("\n"); + const fullText = normalize(segments.map((entry) => entry.text).join("\n")); + const offset = normalize(leading + (segmentIndex > 0 ? "\n" : "")).length; + const matches = occurrences(fullText, quote); + const localMatches = occurrences(normalize(segment.text), quote); + if (localMatches.length > 20) return []; + return localMatches.flatMap((localStart) => { + const start = offset + localStart; + const index = matches.indexOf(start); + if (index === -1) return []; + const selector = createAssistantTextSelector(fullText, start, start + quote.length); + return selector ? [{ ...selector, text, occurrence: { index, count: matches.length } }] : []; + }); +} diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 0973be037e39a..7b1c34405e063 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,6 +1,7 @@ import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { MessageId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ModelSelection, type NodeId, ProviderSessionId, @@ -401,6 +402,18 @@ export const layer: Layer.Layer< cause: "The persisted steering message or target run is missing.", }); } + const text = projectComposerContextForProvider({ + text: message.text, + records: message.context?.records ?? [], + }); + if (text.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + return yield* new ProviderTurnControlError({ + threadId: input.threadId, + operation: "steer", + providerTurnId: input.providerTurnId, + cause: `Message exceeds ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")} characters after expanding context and quotes. Shorten it and try again.`, + }); + } yield* loaded.session.value .steerTurn({ threadId: input.threadId, @@ -412,10 +425,7 @@ export const layer: Layer.Layer< : { modelSelection: input.modelSelection }), message: { messageId: message.id, - text: projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], - }), + text, attachments: message.attachments, createdBy: message.createdBy, creationSource: message.creationSource, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index ac2692907cbbc..9960d9ad45084 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1,3 +1,7 @@ +import { + serializeAssistantCitation, + expandAssistantCitationsForProvider, +} from "@t3tools/shared/assistantCitations"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it, vi } from "vite-plus/test"; @@ -19,6 +23,8 @@ import { RunId, ThreadId, ProjectId, + EnvironmentId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type OrchestrationV2ThreadProjection, OrchestrationV2DomainEvent, } from "@t3tools/contracts"; @@ -1936,3 +1942,36 @@ for (const previousMessages of [[], ["/compact", " /COMPACT "]]) { }), ); } + +effectIt.effect("rejects citation expansion before opening a provider session", () => + Effect.gen(function* () { + const marker = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("assistant"), + text: "\u0001".repeat(8_000), + start: 0, + end: 8_000, + prefix: "", + suffix: "", + }); + const text = "x".repeat(80_000) + marker; + expect(text.length).toBeLessThan(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(expandAssistantCitationsForProvider(text).length).toBeGreaterThan( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ); + const harness = makeLocalCommandHarness({ text }); + yield* harness.start; + expect(harness.open).not.toHaveBeenCalled(); + expect(harness.startTurn).not.toHaveBeenCalled(); + expect(harness.projection().runs.at(-1)?.status).toBe("failed"); + expect(harness.projection().turnItems).toMatchObject([ + { + type: "error", + failure: { class: "validation_error", message: expect.stringContaining("120,000") }, + }, + ]); + expect(harness.projection().messages[0]?.text).toBe(text); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 2de9fcbc70d9d..ed452aee07f30 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -2,6 +2,7 @@ import { modelSelectionsEqual } from "@t3tools/shared/model"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { CommandId, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, latestProviderTurnForAttempt, type OrchestrationV2DomainEvent, type OrchestrationV2ContextHandoff, @@ -565,6 +566,28 @@ export const layer: Layer.Layer< }); }, ); + const userText = projectComposerContextForProvider({ + text: message.text, + records: message.context?.records ?? [], + }); + if (userText.length > PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { + yield* settleRunBeforeStart({ + signal: "expanded-input-too-long", + status: "failed", + now: yield* DateTime.now, + providerInstanceId: run.providerInstanceId, + itemProviderThreadId: providerThread.id, + item: { + type: "error", + title: "Message is too long", + failure: makeProviderFailure({ + class: "validation_error", + message: `Message exceeds ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")} characters after expanding context and quotes. Shorten it and try again.`, + }), + }, + }); + return; + } if (message.attachments.length === 0 && message.text.trimStart().startsWith("/")) { const isEmptyCompaction = message.text.trim().toLowerCase() === "/compact" && !projection.hasConversation; @@ -597,10 +620,7 @@ export const layer: Layer.Layer< : yield* Effect.result( providerAuth.tryHandlePromptCommand({ instanceId: authInstanceId, - text: projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], - }), + text: userText, hasAttachments: false, }), ); @@ -865,10 +885,6 @@ export const layer: Layer.Layer< }); let effectiveHandoffs = handoffs; let resumeFellBack = false; - const userText = projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], - }); // Native goal commands go verbatim; handoff history waits a turn. const goalDriver = session.driver === "codex" || diff --git a/apps/web/src/lib/assistantTextSelection.test.ts b/apps/web/src/lib/assistantTextSelection.test.ts index d06261cb856aa..a25972be7700a 100644 --- a/apps/web/src/lib/assistantTextSelection.test.ts +++ b/apps/web/src/lib/assistantTextSelection.test.ts @@ -507,3 +507,20 @@ describe("findAssistantCitationText", () => { }); }); }); + +it("resolves a repeated native quote only while its occurrence count is unchanged", () => { + const repeated = "x".repeat(40) + "quote" + "x".repeat(40); + const text = `${repeated} / ${repeated}`; + const second = text.lastIndexOf("quote"); + const citation = { + ...createAssistantTextSelector(text, second, second + 5)!, + occurrence: { index: 1, count: 2 }, + }; + expect(findAssistantCitationText(text, citation)).toEqual({ start: second, end: second + 5 }); + expect(findAssistantCitationText(`${text} / ${repeated}`, citation)).toBeNull(); + const unique = { + ...createAssistantTextSelector("old quote", 4, 9)!, + occurrence: { index: 0, count: 1 }, + }; + expect(findAssistantCitationText("new quote", unique)).toEqual({ start: 4, end: 9 }); +}); diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index 9f83cd829eed0..b81d3de6135b5 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -1,14 +1,18 @@ -import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { type AssistantCitation } from "@t3tools/contracts"; import { mathWrapperOf } from "../markdown-clipboard"; -export type AssistantTextSelector = { - readonly text: string; - readonly start: number; - readonly end: number; - readonly prefix: string; - readonly suffix: string; -}; +export { + createAssistantTextSelector, + findAssistantCitationText, +} from "@t3tools/shared/assistantCitations"; +import { + createAssistantTextSelector, + findAssistantCitationText, + splitsSurrogatePair, + type AssistantTextSelector, +} from "@t3tools/shared/assistantCitations"; +export type { AssistantTextSelector } from "@t3tools/shared/assistantCitations"; export type AssistantCitationSourceAnchor = { source: HTMLElement; @@ -39,87 +43,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; -} - -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; - 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), - }; -} - -/** Offsets use normalized UTF-16 units. Saved offsets may drift, so they cannot break context ties. */ -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); -} - /** A text node, or a whole rendered formula standing for its TeX. */ type TextChunk = { node: Text | Element; start: number; end: number }; diff --git a/docs/internals/assistant-citations.md b/docs/internals/assistant-citations.md index ec830f75bc031..7b440875a50c2 100644 --- a/docs/internals/assistant-citations.md +++ b/docs/internals/assistant-citations.md @@ -11,8 +11,10 @@ Source navigation is best effort. [Text selectors](../../apps/web/src/lib/assist refer to rendered text after whitespace normalization, measured in UTF-16 units. They cannot be applied to raw Markdown. The original excerpt keeps its whitespace; normalization is only for locating it. Repeated text needs an unambiguous context -match, even if one occurrence still sits at the saved offsets. Guessing could -highlight the wrong claim. +match, even if one occurrence still sits at the saved offsets. Mobile records an +explicit occurrence choice when the native renderer cannot identify its block; +that choice resolves only while the rendered occurrence count and context still +match. Guessing could highlight the wrong claim. Navigation owns the highlight's lifetime across virtual-row remounts. The source row must be fetched, unfolded, mounted, and measured before quote positioning diff --git a/docs/user/composer.md b/docs/user/composer.md index 7bff24f0dab48..db1975450bdc1 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -143,7 +143,9 @@ with its quote, and sending tells the agent which words were quoted and which co The quoted text and comment count toward the message limit. On mobile, long-press an assistant response, adjust the text selection, and choose -**Cite** to insert a quote at the composer cursor. Tap a quote in a draft to add +**Cite** to insert a quote at the composer cursor. If the passage appears more than +once in that section, choose its source occurrence. Select more surrounding text +if a short passage appears too often. Tap a quote in a draft to add or edit its comment, or to remove it. To open the source message, tap a quote in a draft or sent message, then choose **Go to message**. diff --git a/packages/contracts/src/assistantCitations.ts b/packages/contracts/src/assistantCitations.ts index 96572a4bfa656..ee16537fc5b71 100644 --- a/packages/contracts/src/assistantCitations.ts +++ b/packages/contracts/src/assistantCitations.ts @@ -22,6 +22,15 @@ export const AssistantCitation = Schema.Struct({ ), start: NonNegativeInt.check(Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)), end: NonNegativeInt.check(Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)), + occurrence: Schema.optional( + Schema.Struct({ + index: NonNegativeInt.check(Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER)), + count: NonNegativeInt.check( + Schema.isGreaterThan(0), + Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER), + ), + }).check(Schema.makeFilter((value) => value.index < value.count)), + ), prefix: Schema.String.check(Schema.isMaxLength(ASSISTANT_CITATION_CONTEXT_LENGTH)), suffix: Schema.String.check(Schema.isMaxLength(ASSISTANT_CITATION_CONTEXT_LENGTH)), }).check( diff --git a/packages/shared/src/assistantCitations.test.ts b/packages/shared/src/assistantCitations.test.ts index 7dbaaf9d8bdeb..a698e482ee4f1 100644 --- a/packages/shared/src/assistantCitations.test.ts +++ b/packages/shared/src/assistantCitations.test.ts @@ -95,6 +95,10 @@ describe("assistant citation references", () => { "t3-citation://v1/a/b/c?text=quote&start=5&end=0&prefix=&suffix=", "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&text=other", "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&unknown=value", + "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=0", + "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=1&occurrences=1", + "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=0&occurrences=0", + "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&occurrence=0&occurrences=9007199254740992", "t3-citation://v1/a/b/c?text=quote&start=0&end=5&prefix=&suffix=&comment=one&comment=two", ])("leaves invalid or unsupported references unchanged: %s", (href) => { expect(parseAssistantCitationHref(href)).toBeNull(); @@ -282,3 +286,15 @@ describe("assistant citation references", () => { ); }); }); + +it("retains distinct repeated occurrences through serialization and provider expansion", () => { + const first = { ...citation, occurrence: { index: 0, count: 2 } }; + const second = { ...citation, occurrence: { index: 1, count: 2 } }; + expect(parseAssistantCitationHref(formatAssistantCitationHref(second))).toEqual(second); + const expanded = readProviderContext( + expandAssistantCitationsForProvider( + `${serializeAssistantCitation(first)} ${serializeAssistantCitation(second)}`, + ), + ); + expect(expanded).toHaveLength(2); +}); diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index bb072e462a45e..d776af320a059 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -51,6 +51,10 @@ export function formatAssistantCitationHref(citation: AssistantCitation): string prefix: citation.prefix, suffix: citation.suffix, }); + if (citation.occurrence !== undefined) { + query.set("occurrence", String(citation.occurrence.index)); + query.set("occurrences", String(citation.occurrence.count)); + } if (citation.comment !== undefined) query.set("comment", citation.comment); return `${CITATION_HREF_PREFIX}${path}?${query}`; } @@ -75,8 +79,14 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu } const requiredKeys = ["text", "start", "end", "prefix", "suffix"]; const comment = url.searchParams.get("comment"); + const occurrence = url.searchParams.get("occurrence"); + const occurrences = url.searchParams.get("occurrences"); + if ((occurrence === null) !== (occurrences === null)) return null; + if (occurrence !== null && (!/^\d{1,16}$/.test(occurrence) || !/^\d{1,16}$/.test(occurrences!))) + return null; if ( - url.searchParams.size !== requiredKeys.length + (comment === null ? 0 : 1) || + url.searchParams.size !== + requiredKeys.length + (comment === null ? 0 : 1) + (occurrence === null ? 0 : 2) || requiredKeys.some((key) => url.searchParams.getAll(key).length !== 1) ) { return null; @@ -96,6 +106,9 @@ export function parseAssistantCitationHref(href: string): AssistantCitation | nu prefix: url.searchParams.get("prefix"), suffix: url.searchParams.get("suffix"), ...(comment === null ? {} : { comment }), + ...(occurrence === null + ? {} + : { occurrence: { index: Number(occurrence), count: Number(occurrences) } }), }), ); } catch { @@ -198,3 +211,100 @@ export function renderAssistantCitationsAsText(prompt: string): string { } return text + prompt.slice(cursor); } + +export type AssistantTextSelector = { + readonly text: string; + readonly start: number; + readonly end: number; + readonly prefix: string; + readonly suffix: string; + readonly occurrence?: AssistantCitation["occurrence"]; +}; + +function normalizeWhitespace(text: string): string { + return text.replace(/\s+/g, " "); +} + +export 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; +} + +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; + 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), + }; +} + +/** Offsets use normalized UTF-16 units. Saved offsets may drift, so they cannot break context ties. */ +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; + let occurrenceMatch: { start: number; end: number } | null = null; + + for ( + let start = normalized.indexOf(quote); + start !== -1; + start = normalized.indexOf(quote, start + 1) + ) { + const end = start + quote.length; + if (selector.occurrence?.index === quoteCount) occurrenceMatch = { start, end }; + quoteCount += 1; + onlyQuote = { start, end }; + if (!matchesContext(start, end)) continue; + if (match !== null && match.start !== start && selector.occurrence === undefined) return null; + match = { start, end }; + } + + if (selector.occurrence !== undefined) { + if (quoteCount !== selector.occurrence.count || occurrenceMatch === null) return null; + if (quoteCount === 1) return occurrenceMatch; + return matchesContext(occurrenceMatch.start, occurrenceMatch.end) ? occurrenceMatch : null; + } + return match ?? (quoteCount === 1 ? onlyQuote : null); +} From 4451d207c672aad4e73d5bed6e8a93f91195f67e Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 12 Nov 2026 10:50:27 -0500 Subject: [PATCH 7/8] test(mobile): isolate citation picker presentation in previews --- apps/mobile/src/lib/filePreview.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 47f00f0e2f1fb..d25b8ffe7d0fb 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -232,6 +232,8 @@ vi.mock("expo-haptics", () => ({ selectionAsync: async () => {} })); vi.mock("expo-image", () => ({})); vi.mock("uniwind", () => ({})); vi.mock("../components/ui/Dialog", () => ({})); +vi.mock("../components/ui/Sheet", () => ({ Sheet: () => null })); +vi.mock("../components/ui/ListActionRow", () => ({ ListActionRow: () => null })); vi.mock("../components/ui/NativePresentation", () => ({ PresentationSource: ({ children }: { children?: ReactNode }) => children, })); From f719dadc1e1d6b87154adb8229db41708d22f3b7 Mon Sep 17 00:00:00 2001 From: Anas Bashir Date: Thu, 12 Nov 2026 16:49:29 +0300 Subject: [PATCH 8/8] fix(mobile): Go to message highlights the cited text Web pulses the quoted text after jumping to a citation's source; mobile only scrolled. The jump now carries the quote and its occurrence, the feed locates that occurrence's Markdown segment with the same model Cite uses, and the segment wraps its match as a native `==highlight==` for 3 seconds in web's blue. Matching skips bold and italic markers, so quotes that run into formatting still highlight. A quote whose chosen match is in code, or that crosses a paragraph break, scrolls without a highlight. Co-Authored-By: Claude Opus 5.5 --- .../src/components/AssistantCitationSheet.tsx | 5 +- .../src/features/threads/ThreadFeed.tsx | 72 +++++++++++++++--- .../src/features/threads/threadMessageJump.ts | 18 ++++- .../lib/assistantCitationSelection.test.ts | 18 ++++- .../src/lib/assistantCitationSelection.ts | 27 +++++++ apps/mobile/src/lib/citationHighlight.test.ts | 58 ++++++++++++++ apps/mobile/src/lib/citationHighlight.ts | 75 +++++++++++++++++++ .../src/native/EnrichedMarkdownText.tsx | 25 ++++++- .../src/native/SelectableMarkdownText.tsx | 11 ++- .../native/SelectableMarkdownText.types.ts | 5 ++ 10 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/lib/citationHighlight.test.ts create mode 100644 apps/mobile/src/lib/citationHighlight.ts diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx index 62185cc3b9f89..ed67deadf377f 100644 --- a/apps/mobile/src/components/AssistantCitationSheet.tsx +++ b/apps/mobile/src/components/AssistantCitationSheet.tsx @@ -99,7 +99,10 @@ export function AssistantCitationSheet(props: { onPress={() => { if (commentTooLong) return; close(); - requestThreadMessageJump(scopedThreadKey(citation), citation.messageId); + requestThreadMessageJump(scopedThreadKey(citation), citation.messageId, { + text: citation.text, + occurrence: citation.occurrence, + }); openLinkedThread(citation); }} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3e4748f723af0..3686be8c9be30 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,7 +1,10 @@ import { Sheet } from "../../components/ui/Sheet"; import { ListActionRow } from "../../components/ui/ListActionRow"; import { ListSection } from "../../components/ui/ListSection"; -import { collectMobileAssistantTextSelectors } from "../../lib/assistantCitationSelection"; +import { + collectMobileAssistantTextSelectors, + locateMobileAssistantCitation, +} from "../../lib/assistantCitationSelection"; import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { ASSISTANT_CITATION_MAX_TEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; @@ -165,6 +168,7 @@ import { type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; +import { pendingThreadMessageJumpQuote, type ThreadMessageJumpQuote } from "./threadMessageJump"; import { AppText as Text } from "../../components/ui/AppText"; import { VideoPreviewModal, type VideoPreviewSource } from "../../components/ui/VideoPreviewModal"; @@ -1146,6 +1150,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly environmentId: EnvironmentId; readonly citationSource?: Pick; readonly onCiteAssistantText?: (citation: AssistantCitation) => void; + readonly highlight?: ThreadMessageJumpQuote | undefined; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; @@ -1168,6 +1173,11 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { const models = useModelMentions(props.environmentId, props.markdown); const liveMarkdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); const threadIcons = useThreadChipIcons(props.environmentId, liveMarkdown); + const located = props.highlight + ? locateMobileAssistantCitation(liveMarkdown, props.highlight.text, props.highlight.occurrence) + : null; + const highlight = + located && props.highlight ? { ...located, text: props.highlight.text } : undefined; const linkHandlers: MarkdownLinkHandlers = { ...props.linkHandlers, linkCustomization: (href) => @@ -1178,6 +1188,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { <> { @@ -1236,6 +1247,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { function AssistantMarkdownBlocks(props: { readonly markdown: string; readonly citationPath?: string; + readonly highlight?: { readonly path: string; readonly index: number; readonly text: string }; readonly onCiteText?: (text: string, path: string) => void; readonly markdownStyles: MarkdownStyleSet; readonly linkHandlers: MarkdownLinkHandlers; @@ -1281,6 +1293,7 @@ function AssistantMarkdownBlocks(props: { ); } if (segment.markdown.trim().length === 0) return null; + const citationPath = `${props.citationPath ?? ""}${blockIndex}:${segmentIndex}`; return ( - props.onCiteText?.( - text, - `${props.citationPath ?? ""}${blockIndex}:${segmentIndex}`, - ) - : undefined + props.onCiteText ? (text) => props.onCiteText?.(text, citationPath) : undefined } + highlightText={props.highlight?.path === citationPath ? props.highlight.text : undefined} + highlightOccurrence={props.highlight?.index} {...props.linkHandlers} renderImage={props.renderImage} /> @@ -1699,6 +1708,14 @@ function RunFoldButton({ ); } +interface CitationHighlight { + readonly messageId: MessageId; + readonly quote: ThreadMessageJumpQuote; +} + +// Matches web's citation pulse: long enough to find the quote after the jump. +const CITATION_HIGHLIGHT_MS = 3000; + function renderFeedEntry( info: { item: ThreadFeedRow; index: number }, props: Pick< @@ -1713,6 +1730,7 @@ function renderFeedEntry( | "workspaceRoot" > & { readonly copiedRowId: string | null; + readonly citationHighlight: CitationHighlight | null; readonly expandedWorkRows: Record; readonly workRowSizing: ReturnType; readonly workGroupScrollPositions: Map; @@ -2338,6 +2356,11 @@ function renderFeedEntry( environmentId={props.environmentId} citationSource={{ threadId: props.threadId, messageId: message.id }} onCiteAssistantText={props.onCiteAssistantText} + highlight={ + props.citationHighlight?.messageId === message.id + ? props.citationHighlight.quote + : undefined + } markdownStyles={styles} linkHandlers={props.markdownLinkHandlers} onUseArtifactTemplate={props.onUseArtifactTemplate} @@ -2791,6 +2814,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const { themeAppearance } = useAppearancePreferences(); const copyFeedbackTimeoutRef = useRef | null>(null); + const citationHighlightTimeoutRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); @@ -2816,15 +2840,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { readonly expandedWorkRows: Record; readonly expandedTurnIds: ReadonlySet; readonly revealedMessageId: MessageId | null; + readonly citationHighlight: CitationHighlight | null; }>({ copiedRowId: null, expandedWorkGroups: {}, expandedWorkRows: {}, expandedTurnIds: new Set(), revealedMessageId: null, + citationHighlight: null, }); - const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds, revealedMessageId } = - interactionState; + const { + copiedRowId, + expandedWorkGroups, + expandedWorkRows, + expandedTurnIds, + revealedMessageId, + citationHighlight, + } = interactionState; const markdownLinkRequest = useRef(null); const onInlinePlaybackStart = () => { markdownLinkRequest.current?.abort(); @@ -3345,6 +3377,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { dispatchingMessageId: props.dispatchingMessageId, unsettledTurnId, copiedRowId, + citationHighlight, expandedWorkGroups, expandedWorkRows, workRowSizing, @@ -3437,6 +3470,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const jumpIndex = jumpReady ? presentedFeed.findIndex((entry) => entry.type === "message" && entry.id === jumpToMessageId) : null; + const highlightJumpQuote = () => { + const quote = pendingThreadMessageJumpQuote(feedThreadKey); + if (!quote || !jumpToMessageId) return; + const highlight = { messageId: jumpToMessageId, quote }; + setInteractionState((current) => ({ ...current, citationHighlight: highlight })); + if (citationHighlightTimeoutRef.current) clearTimeout(citationHighlightTimeoutRef.current); + citationHighlightTimeoutRef.current = setTimeout(() => { + setInteractionState((current) => + current.citationHighlight === highlight ? { ...current, citationHighlight: null } : current, + ); + citationHighlightTimeoutRef.current = null; + }, CITATION_HIGHLIGHT_MS); + }; const landJump = useEffectEvent((index: number) => { const list = listRef.current; if (!list) return; @@ -3444,7 +3490,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const positioning = index === -1 ? undefined : list.scrollToIndex({ index, viewPosition: 0.2, animated: false }); void Promise.resolve(positioning).then(() => { - if (!cancelled && listRef.current === list) props.onJumpedToMessage?.(); + if (cancelled || listRef.current !== list) return; + if (index !== -1) highlightJumpQuote(); + props.onJumpedToMessage?.(); }); return () => { cancelled = true; @@ -3508,6 +3556,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { useEffect( () => () => { if (copyFeedbackTimeoutRef.current) clearTimeout(copyFeedbackTimeoutRef.current); + if (citationHighlightTimeoutRef.current) clearTimeout(citationHighlightTimeoutRef.current); if (disclosureMotionTimer.current !== null) clearTimeout(disclosureMotionTimer.current); if (disclosureSettleFrameRef.current !== null) { cancelAnimationFrame(disclosureSettleFrameRef.current); @@ -3757,6 +3806,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onCiteAssistantText: props.onCiteAssistantText, threadId: props.threadId, copiedRowId, + citationHighlight, expandedWorkRows, workRowSizing, workGroupScrollPositions, diff --git a/apps/mobile/src/features/threads/threadMessageJump.ts b/apps/mobile/src/features/threads/threadMessageJump.ts index 405084016138a..ff1bbc810ae37 100644 --- a/apps/mobile/src/features/threads/threadMessageJump.ts +++ b/apps/mobile/src/features/threads/threadMessageJump.ts @@ -1,4 +1,6 @@ -import type { MessageId } from "@t3tools/contracts"; +import type { AssistantCitation, MessageId } from "@t3tools/contracts"; + +export type ThreadMessageJumpQuote = Pick; // A palette message result opens its thread at that message. The thread // screen reads the request while it shows that thread, and finishes it once @@ -8,6 +10,7 @@ const PENDING_JUMP_MS = 30_000; let pending: { readonly threadKey: string; readonly messageId: MessageId; + readonly quote: ThreadMessageJumpQuote | null; readonly at: number; } | null = null; const listeners = new Set<() => void>(); @@ -16,8 +19,12 @@ function notify() { for (const listener of listeners) listener(); } -export function requestThreadMessageJump(threadKey: string, messageId: MessageId): void { - pending = { threadKey, messageId, at: Date.now() }; +export function requestThreadMessageJump( + threadKey: string, + messageId: MessageId, + quote: ThreadMessageJumpQuote | null = null, +): void { + pending = { threadKey, messageId, quote, at: Date.now() }; notify(); } @@ -28,6 +35,11 @@ export function pendingThreadMessageJump(threadKey: string): MessageId | null { : null; } +/** The quoted text to highlight once the pending jump lands, if a citation asked for one. */ +export function pendingThreadMessageJumpQuote(threadKey: string): ThreadMessageJumpQuote | null { + return pendingThreadMessageJump(threadKey) === null ? null : (pending?.quote ?? null); +} + export function finishThreadMessageJump(threadKey: string): void { if (pending?.threadKey !== threadKey) return; pending = null; diff --git a/apps/mobile/src/lib/assistantCitationSelection.test.ts b/apps/mobile/src/lib/assistantCitationSelection.test.ts index 6b2739394040f..c5d5f1e48474c 100644 --- a/apps/mobile/src/lib/assistantCitationSelection.test.ts +++ b/apps/mobile/src/lib/assistantCitationSelection.test.ts @@ -1,5 +1,8 @@ import { expect, it } from "vite-plus/test"; -import { collectMobileAssistantTextSelectors } from "./assistantCitationSelection"; +import { + collectMobileAssistantTextSelectors, + locateMobileAssistantCitation, +} from "./assistantCitationSelection"; import { findAssistantCitationText, serializeAssistantCitation, @@ -14,6 +17,19 @@ it("offers distinct source choices within a block and maps recursive folds witho const second = collectMobileAssistantTextSelectors(markdown, "1/1/0:0", "quote")[0]; expect(first?.occurrence).toEqual({ index: 0, count: 2 }); expect(second?.occurrence).toEqual({ index: 1, count: 2 }); + expect(locateMobileAssistantCitation(markdown, "quote", first!.occurrence)).toEqual({ + path: "0:0", + index: 0, + }); + expect(locateMobileAssistantCitation(markdown, "quote", second!.occurrence)).toEqual({ + path: "1/1/0:0", + index: 0, + }); + expect(locateMobileAssistantCitation("go now, then go now", "go now", { index: 1 })).toEqual({ + path: "0:0", + index: 1, + }); + expect(locateMobileAssistantCitation(markdown, "quote", { index: 2 })).toBeNull(); const rendered = `${"x".repeat(40)}quote${"x".repeat(40)} ${"x".repeat(40)}quote${"x".repeat(40)}`; expect(findAssistantCitationText(rendered, second!)).toEqual({ start: rendered.lastIndexOf("quote"), diff --git a/apps/mobile/src/lib/assistantCitationSelection.ts b/apps/mobile/src/lib/assistantCitationSelection.ts index 350b5e4d8fc44..e878580daf373 100644 --- a/apps/mobile/src/lib/assistantCitationSelection.ts +++ b/apps/mobile/src/lib/assistantCitationSelection.ts @@ -100,3 +100,30 @@ export function collectMobileAssistantTextSelectors(markdown: string, path: stri return selector ? [{ ...selector, text, occurrence: { index, count: matches.length } }] : []; }); } + +/** The Markdown segment holding a citation's quote, and which of its matches the citation means. */ +export function locateMobileAssistantCitation( + markdown: string, + text: string, + occurrence?: { readonly index: number }, +): { path: string; index: number } | null { + const normalize = (value: string) => value.replace(/\s+/g, " "); + if (!text.trim()) return null; + const quote = normalize(text); + const segments = citationSegments(markdown); + const target = occurrences(normalize(segments.map((entry) => entry.text).join("\n")), quote)[ + occurrence?.index ?? 0 + ]; + if (target === undefined) return null; + // Offsets as `collectMobileAssistantTextSelectors` computes them, so whitespace collapses the same. + for (const [segmentIndex, segment] of segments.entries()) { + const leading = segments + .slice(0, segmentIndex) + .map((entry) => entry.text) + .join("\n"); + const offset = normalize(leading + (segmentIndex > 0 ? "\n" : "")).length; + const index = occurrences(normalize(segment.text), quote).indexOf(target - offset); + if (index !== -1) return { path: segment.path, index }; + } + return null; +} diff --git a/apps/mobile/src/lib/citationHighlight.test.ts b/apps/mobile/src/lib/citationHighlight.test.ts new file mode 100644 index 0000000000000..9f18c45f48bc8 --- /dev/null +++ b/apps/mobile/src/lib/citationHighlight.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vite-plus/test"; +import { highlightQuoteInMarkdown } from "./citationHighlight"; + +describe("highlightQuoteInMarkdown", () => { + it.each([ + [ + "plain text", + "Glad the second install worked.", + "Glad the second", + "==Glad the second== install worked.", + ], + ["first of repeated text", "go now, then go now", "go now", "==go now==, then go now"], + ["text wrapped onto the next line", "one two\nthree", "two three", "one ==two\nthree=="], + ["regex characters", "Run (a+b)*c today", "(a+b)*c", "Run ==(a+b)*c== today"], + [ + "text inside bold", + "create **spring tides** now", + "spring", + "create **==spring== tides** now", + ], + [ + "text running into bold", + "These changes create **spring tides and neap tides**.", + "create spring tides", + "These changes ==create== **==spring tides== and neap tides**.", + ], + ["text across bold", "the **bold** word", "the bold word", "==the== **==bold==** ==word=="], + ["text across italics", "an _odd_ one", "an odd one", "==an== _==odd==_ ==one=="], + ["snake_case as text", "set my_var now", "my_var now", "set ==my_var now=="], + ])("highlights %s", (_name, markdown, quote, expected) => { + expect(highlightQuoteInMarkdown(markdown, quote)).toBe(expected); + }); + + it.each([ + ["the second of repeated text", "go now, then go now", "go now", 1, "go now, then ==go now=="], + [ + "prose after the same text in code", + "`go now` then go now", + "go now", + 1, + "`go now` then ==go now==", + ], + ["a match in code", "`go now` then go now", "go now", 0, null], + ["a match past the last", "go now", "go now", 1, null], + ])("picks %s", (_name, markdown, quote, occurrence, expected) => { + expect(highlightQuoteInMarkdown(markdown, quote, occurrence)).toBe(expected); + }); + + it.each([ + ["an empty quote", "text", " "], + ["text across a paragraph break", "one two\n\nthree", "two three"], + ["text only inside inline code", "run `vp test` now", "vp test"], + ["text only inside a code fence", "```sh\nvp test\n```", "vp test"], + ["missing text", "nothing here", "absent"], + ])("leaves %s unhighlighted", (_name, markdown, quote) => { + expect(highlightQuoteInMarkdown(markdown, quote)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/citationHighlight.ts b/apps/mobile/src/lib/citationHighlight.ts new file mode 100644 index 0000000000000..0dbda7ec60c91 --- /dev/null +++ b/apps/mobile/src/lib/citationHighlight.ts @@ -0,0 +1,75 @@ +const CODE_RANGES = /^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*$|`[^`\n]+`/gm; +const WORD_CHAR = /[\p{L}\p{N}]/u; + +function escapeRegExp(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// `*` and `~` always mark emphasis; `_` only at a word edge, so snake_case stays text. +function isEmphasisMarker(markdown: string, index: number) { + const char = markdown[index]; + if (char === "*" || char === "~") return true; + if (char !== "_") return false; + const before = markdown[index - 1] ?? ""; + const after = markdown[index + 1] ?? ""; + return !(WORD_CHAR.test(before) && WORD_CHAR.test(after)); +} + +/** + * Wraps match `occurrence` of `quote` in `==…==` for the native renderer. Matching skips emphasis + * markers and wraps each plain run on its own to keep the Markdown valid. Matches in code still + * count toward `occurrence`, like rendered text, but are not highlighted. + */ +export function highlightQuoteInMarkdown( + markdown: string, + quote: string, + occurrence = 0, +): string | null { + const words = quote.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return null; + const code = [...markdown.matchAll(CODE_RANGES)].map((match) => ({ + start: match.index, + end: match.index + match[0].length, + })); + // Words may wrap onto the next line, but never across a blank line. + const pattern = new RegExp(words.map(escapeRegExp).join("(?:[ \\t]+|[ \\t]*\\n[ \\t]*)"), "g"); + const outsideCode = (start: number, end: number) => + !code.some((range) => start < range.end && end > range.start); + const sourceIndex: number[] = []; + let plain = ""; + for (let index = 0; index < markdown.length; index += 1) { + if (isEmphasisMarker(markdown, index)) continue; + sourceIndex.push(index); + plain += markdown[index]; + } + const match = [...plain.matchAll(pattern)][occurrence]; + if (!match) { + // A quote with a literal `*` or `_` only matches the raw source. + const raw = occurrence === 0 ? [...markdown.matchAll(pattern)][0] : undefined; + if (!raw) return null; + const end = raw.index + raw[0].length; + if (!outsideCode(raw.index, end)) return null; + return `${markdown.slice(0, raw.index)}==${raw[0]}==${markdown.slice(end)}`; + } + const start = sourceIndex[match.index]!; + const end = sourceIndex[match.index + match[0].length - 1]! + 1; + if (!outsideCode(start, end)) return null; + let output = markdown.slice(0, start); + let run = ""; + const flush = () => { + const text = run.trim(); + if (text) { + const leading = run.slice(0, run.indexOf(text)); + output += `${leading}==${text}==${run.slice(leading.length + text.length)}`; + } else output += run; + run = ""; + }; + for (let index = start; index < end; index += 1) { + if (isEmphasisMarker(markdown, index)) { + flush(); + output += markdown[index]; + } else run += markdown[index]; + } + flush(); + return output + markdown.slice(end); +} diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index a9a4570c22b6d..0e5ae584f5ad4 100644 --- a/apps/mobile/src/native/EnrichedMarkdownText.tsx +++ b/apps/mobile/src/native/EnrichedMarkdownText.tsx @@ -32,6 +32,7 @@ import { useRetryingImageSource } from "./useRetryingImageSource"; import * as Dialog from "../components/ui/Dialog"; import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import type { HighlightScheme } from "../lib/highlights"; +import { highlightQuoteInMarkdown } from "../lib/citationHighlight"; import { ENRICHED_INLINE_FILE_LINK_REGEX, enrichedTextLinkRegex, @@ -353,9 +354,25 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { }, [props.textStyle, props.textAlign, themeAppearance, fontScale]); // Link variants change as a message streams. The code renderer reads only the base style, so // it stays the same function and the library keeps its rendered blocks. + const highlightedMarkdown = props.highlightText + ? highlightQuoteInMarkdown(markdown, props.highlightText, props.highlightOccurrence) + : null; const markdownStyle = useMemo( - () => ({ ...baseMarkdownStyle, linkVariants }), - [baseMarkdownStyle, linkVariants], + () => ({ + ...baseMarkdownStyle, + linkVariants, + highlight: { + color: props.textStyle.color, + backgroundColor: props.textStyle.highlightColor ?? props.textStyle.selectionColor, + }, + }), + [ + baseMarkdownStyle, + linkVariants, + props.textStyle.color, + props.textStyle.highlightColor, + props.textStyle.selectionColor, + ], ); const renderers = { image: renderImage @@ -405,7 +422,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { }} > void; readonly onCiteText?: (text: string) => void; + /** Quoted text to highlight, such as a citation's source after "Go to message". */ + readonly highlightText?: string | undefined; + /** Which match of `highlightText` to highlight, counting from 0. */ + readonly highlightOccurrence?: number | undefined; readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined; readonly onFileContextMenuAction?: (href: string, actionId: string) => void; readonly linkCustomization?: (href: string) => MarkdownLinkCustomization | undefined;