diff --git a/apps/mobile/src/components/AssistantCitationSheet.tsx b/apps/mobile/src/components/AssistantCitationSheet.tsx new file mode 100644 index 0000000000000..ed67deadf377f --- /dev/null +++ b/apps/mobile/src/components/AssistantCitationSheet.tsx @@ -0,0 +1,126 @@ +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; + 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 (commentTooLong) return; + if (props.onCommentChange && commentChanged) { + props.onCommentChange(comment); + } + props.onClose(); + }; + return ( + + + + + + {contextBefore(citation.prefix)} + {citation.text} + {contextAfter(citation.suffix)} + + + {props.onCommentChange ? ( + + + {commentTooLong ? ( + + This comment makes the message too long to send. Shorten it to save and close. + + ) : null} + + ) : citation.comment ? ( + + + {citation.comment} + + + ) : null} + + { + if (commentTooLong) return; + close(); + requestThreadMessageJump(scopedThreadKey(citation), citation.messageId, { + text: citation.text, + occurrence: citation.occurrence, + }); + openLinkedThread(citation); + }} + /> + copyTextWithHaptic(citation.text)} + /> + {props.onRemove ? ( + + ) : null} + + + + ); +} 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 f7118b95b425f..ea527efdb8e94 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 { @@ -34,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, @@ -169,6 +176,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 +186,39 @@ 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); + 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 }); + changeText(text); + }; return ( <> {/* Reset revision history with the recreated iOS view without aborting context imports. */} @@ -226,6 +269,26 @@ export function ComposerEditor({ {importing ? ( Copying context… ) : null} + {selectedCitation ? ( + 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) ? ( 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..c6c987f8838da 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -130,7 +130,15 @@ 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 { assistantCitationDraftWithinLimit } from "../../lib/composerContext"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import type { AssistantCitation } from "@t3tools/contracts"; import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { ANDROID_READING_MAX_WIDTH, @@ -211,6 +219,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; @@ -1381,6 +1390,39 @@ function ThreadDetailBody( composerEditorRef.current?.blur(); }; + const canCiteAssistantText = + props.composerDraftKey !== null && + !isProviderSubagent && + !composerSlotHidden && + !props.isSavingQueuedEdit && + !props.draftMode?.interactionLocked && + !( + voiceInputSession.ownerKey === props.composerDraftKey && + voiceInputBlocksSubmission(voiceInputSession.controller.currentState) + ); + const handleCiteAssistantText = (citation: AssistantCitation) => { + 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]!) ? " " : ""; + 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(); + }; + const handleUseArtifactTemplate = (template: CodexArtifactTemplate) => { const draftMessage = props.composerDraftKey === null ? "" : getComposerDraftSnapshot(props.composerDraftKey).text; @@ -1518,6 +1560,7 @@ function ThreadDetailBody( onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} onIsAtEndChange={setIsAtEnd} skills={selectedProviderSkills} + onCiteAssistantText={canCiteAssistantText ? handleCiteAssistantText : undefined} onUseArtifactTemplate={handleUseArtifactTemplate} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index dbbcaa1fd9cff..3686be8c9be30 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,5 +1,13 @@ +import { Sheet } from "../../components/ui/Sheet"; +import { ListActionRow } from "../../components/ui/ListActionRow"; +import { ListSection } from "../../components/ui/ListSection"; +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"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; @@ -60,7 +68,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"; @@ -117,6 +124,7 @@ import { } from "react"; import { Image, + ScrollView, Platform, type LayoutChangeEvent, type NativeScrollEvent, @@ -160,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"; @@ -347,6 +356,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,15 +1148,36 @@ function ArtifactTemplateCard(props: { const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; 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; 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); + 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) => @@ -1154,21 +1185,70 @@ 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; + } + 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); + } + : 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 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; readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; @@ -1185,7 +1265,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 ( @@ -1209,6 +1293,7 @@ function AssistantMarkdownBlocks(props: { ); } if (segment.markdown.trim().length === 0) return null; + const citationPath = `${props.citationPath ?? ""}${blockIndex}:${segmentIndex}`; return ( props.onCiteText?.(text, citationPath) : undefined + } + highlightText={props.highlight?.path === citationPath ? props.highlight.text : undefined} + highlightOccurrence={props.highlight?.index} {...props.linkHandlers} renderImage={props.renderImage} /> @@ -1485,7 +1575,7 @@ function ThreadAutomationMessageRow(props: { & { readonly copiedRowId: string | null; + readonly citationHighlight: CitationHighlight | null; readonly expandedWorkRows: Record; readonly workRowSizing: ReturnType; readonly workGroupScrollPositions: Map; @@ -1855,7 +1955,7 @@ function renderFeedEntry( const { message } = entry; const isUser = message.role === "user"; const presentation = resolveUserMessagePresentation(message); - const renderedText = renderAssistantCitationsAsText(presentation.text); + const renderedText = presentation.text; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTimestamp(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; @@ -2254,6 +2354,13 @@ function renderFeedEntry( | null>(null); + const citationHighlightTimeoutRef = useRef | null>(null); const headerMaterialVisibleRef = useRef(false); const { width: windowWidth, fontScale } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); @@ -2732,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(); @@ -3255,11 +3371,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listAppearanceData = { disclosureLayout, reduceMotion, + onCiteAssistantText: props.onCiteAssistantText, worktreeSetup: props.worktreeSetup, setupWorkingStartedAt: props.setupWorkingStartedAt, dispatchingMessageId: props.dispatchingMessageId, unsettledTurnId, copiedRowId, + citationHighlight, expandedWorkGroups, expandedWorkRows, workRowSizing, @@ -3352,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; @@ -3359,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; @@ -3423,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); @@ -3669,8 +3803,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { dispatchingMessageId: props.dispatchingMessageId, onEditPendingMessage: props.onEditPendingMessage, onUseArtifactTemplate: props.onUseArtifactTemplate, + 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 new file mode 100644 index 0000000000000..c5d5f1e48474c --- /dev/null +++ b/apps/mobile/src/lib/assistantCitationSelection.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "vite-plus/test"; +import { + collectMobileAssistantTextSelectors, + locateMobileAssistantCitation, +} 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 }); + 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"), + 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..e878580daf373 --- /dev/null +++ b/apps/mobile/src/lib/assistantCitationSelection.ts @@ -0,0 +1,129 @@ +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 } }] : []; + }); +} + +/** 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/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index f61aa5c787071..6f2113dc564c5 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -34,6 +34,7 @@ const CONTEXT_CHIP_PRESENTATIONS = { icon: "mouse-pointer-click", }, "review-comment": { accent: "#8a70dd", icon: "message-circle" }, + citation: { accent: "#8a70dd", icon: "message-circle" }, "pull-request": { accent: "#7079e4", icon: "git-pull-request-arrow", diff --git a/apps/mobile/src/lib/composerContext.test.ts b/apps/mobile/src/lib/composerContext.test.ts index 2e1ee64831d08..657970c28a999 100644 --- a/apps/mobile/src/lib/composerContext.test.ts +++ b/apps/mobile/src/lib/composerContext.test.ts @@ -1,9 +1,14 @@ import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ComposerContextId, + EnvironmentId, + MessageId, + ThreadId, type OrchestrationMessageContext, type ServerProviderSkill, } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, collectComposerContextReferences, @@ -12,6 +17,7 @@ import { } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { + assistantCitationDraftWithinLimit, collectComposerModelTokens, composerContextEditorTokens, composerSkillLabels, @@ -201,9 +207,45 @@ 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 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 +264,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 +274,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..403e01da6efd2 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -7,6 +7,12 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { filePreviewKind } from "@t3tools/shared/filePreview"; 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, @@ -36,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 { @@ -194,13 +207,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 +231,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/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, })); diff --git a/apps/mobile/src/native/EnrichedMarkdownText.tsx b/apps/mobile/src/native/EnrichedMarkdownText.tsx index 86bf8021bdd0f..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) { }} > props.onCiteText?.(text), + }, + ] + : undefined + } selectionClipboard={selectionClipboard} selectionColor={props.textStyle.selectionColor} selectionHandleColor={props.textStyle.selectionHandleColor} @@ -428,6 +456,8 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) { hardSoftBreaks: props.preserveSoftBreaks ?? false, admonitions: true, preserveWindowsPaths: true, + // Only while highlighting, so a literal `==` in a message stays text. + highlight: highlightedMarkdown !== null, }} enableTaskListItemToggle={props.onTaskListItemPress !== undefined} onTaskListItemPress={props.onTaskListItemPress} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index e49405af4dd4f..98bcfee9a23bc 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,8 +1,13 @@ 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"; -import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { flattenThemeColor, themeColorWithAlpha } from "../lib/mobileTheme"; import { useUniwindTheme } from "../lib/useUniwindTheme"; export type { @@ -17,12 +22,63 @@ 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"]; + // Matches web's citation highlight; the native renderer drops translucent colors. + const highlightColor = flattenThemeColor( + themeColorWithAlpha(theme["--color-focus"], 0.45), + theme["--color-screen"], + ); const textStyle = Platform.OS === "android" - ? { selectionColor, selectionHandleColor, ...props.textStyle } - : props.textStyle; - return ; + ? { selectionColor, selectionHandleColor, highlightColor, ...props.textStyle } + : { highlightColor, ...props.textStyle }; + 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..69a1a4a3a678e 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.types.ts +++ b/apps/mobile/src/native/SelectableMarkdownText.types.ts @@ -1,6 +1,7 @@ export interface NativeMarkdownTextStyle { readonly selectionColor?: string; readonly selectionHandleColor?: string; + readonly highlightColor?: string; readonly color: string; readonly strongColor: string; readonly mutedColor: string; @@ -77,6 +78,11 @@ export interface SelectableMarkdownTextProps { readonly models?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => 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; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 437bcda4d012d..efd7ba0e9a94f 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,7 +216,9 @@ export function ComposerEditor({ ? model.label : token.type === "context" ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` - : basename(token.value), + : token.type === "citation" + ? token.value + : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", 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 5aabd8e0d6071..e369e5257d8fc 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -200,8 +200,8 @@ 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,7 +217,9 @@ export function ComposerEditor({ ? model.label : token.type === "context" ? `${token.label}${props.context?.records.some((record) => record.contextId === token.contextId) ? "" : " · unavailable"}` - : basename(token.value), + : token.type === "citation" + ? token.value + : basename(token.value), detail: token.type === "context" ? composerChipSizeSuffix(record) : "", iconUri: model ? modelChipIconUri(model) : contextChipIconUri(icon), tintsIcon: model ? modelChipIcon(model).tintsIcon : icon.kind === "chip", 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/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 df880e49c6ca5..db1975450bdc1 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -142,8 +142,12 @@ 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. 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**. ## Recall a sent prompt 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); +}