Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
codexArtifactTemplatePresentationLabel,
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
import { renderCodexCitationsAsMarkdown } from "@t3tools/client-runtime/codex-citations";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
Expand Down Expand Up @@ -726,15 +727,19 @@ interface MarkdownLinkHandlers {

const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
readonly markdown: string;
readonly isStreaming: boolean;
readonly markdownStyles: MarkdownStyleSet;
readonly linkHandlers: MarkdownLinkHandlers;
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
readonly renderImage: MarkdownImageRenderer;
readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
}) {
const segments = useMemo(
() => splitCodexArtifactTemplateMarkdown(props.markdown),
[props.markdown],
() =>
splitCodexArtifactTemplateMarkdown(
renderCodexCitationsAsMarkdown(props.markdown, { isStreaming: props.isStreaming }),
),
[props.markdown, props.isStreaming],
);

return segments.map((segment) => {
Expand Down Expand Up @@ -1514,6 +1519,7 @@ function renderFeedEntry(
{renderedText.trim().length > 0 ? (
<AssistantMarkdownContent
markdown={renderedText}
isStreaming={message.streaming}
markdownStyles={styles}
linkHandlers={props.markdownLinkHandlers}
onUseArtifactTemplate={props.onUseArtifactTemplate}
Expand Down Expand Up @@ -1548,7 +1554,7 @@ function renderFeedEntry(
<View className="mt-1 flex-row items-center gap-1">
<CopyTextButton
accessibilityLabel="Copy message"
text={renderedText}
text={renderCodexCitationsAsMarkdown(renderedText)}
tintColor={iconSubtleColor}
buttonSize={28}
iconSize={13}
Expand Down
47 changes: 25 additions & 22 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import {
type CodexArtifactTemplate,
type CodexArtifactTemplateKind,
} from "@t3tools/client-runtime/codex-artifact-templates";
import {
CODEX_CITATION_HAST_PROPERTIES,
codexCitationFromHastProperties,
remarkCodexCitations,
} from "@t3tools/client-runtime/codex-citations";
import {
classifyMarkdownImageSource,
markdownImageSourceFragment,
Expand Down Expand Up @@ -72,6 +77,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { CodexCitationChip } from "./chat/CodexCitationChip";
import remarkGfm from "remark-gfm";
import { remarkGithubAlerts } from "../markdown-github-alerts";
import {
Expand Down Expand Up @@ -381,6 +387,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
span: [...(defaultSchema.attributes?.span ?? []), ...CODEX_CITATION_HAST_PROPERTIES],
a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
},
Expand All @@ -391,25 +398,6 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
},
} satisfies Parameters<typeof rehypeSanitize>[0];

const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkGfm,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
remarkPreserveCodeMeta,
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkGfm,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
remarkBreaks,
remarkPreserveCodeMeta,
remarkNormalizeLinksAndTagInlineCode,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
rehypeRaw,
rehypePreserveImageSourceMeta,
Expand Down Expand Up @@ -2342,6 +2330,14 @@ function ChatMarkdown({
};

return {
span({ node, children, ...props }) {
const citation = codexCitationFromHastProperties(node?.properties);
return citation ? (
<CodexCitationChip citation={citation} />
) : (
<span {...props}>{children}</span>
);
},
div({ node, children, ...props }) {
const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
if (artifactTemplate) {
Expand Down Expand Up @@ -2766,12 +2762,19 @@ function ChatMarkdown({
]);
/* eslint-enable react/no-unstable-nested-components */

const remarkPlugins = useMemo(
const remarkPlugins = useMemo<NonNullable<ReactMarkdownOptions["remarkPlugins"]>>(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
remarkGfm,
remarkGithubAlerts,
remarkNormalizeListItemIndentation,
remarkCodexDirectives,
[remarkCodexCitations, { isStreaming }],
...(lineBreaks ? [remarkBreaks] : []),
remarkPreserveCodeMeta,
remarkNormalizeLinksAndTagInlineCode,
...extraRemarkPlugins,
],
[extraRemarkPlugins, lineBreaks],
[extraRemarkPlugins, isStreaming, lineBreaks],
);

// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/components/chat/CodexCitationChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
codexCitationMarkdown,
codexCitationText,
type CodexCitation,
} from "@t3tools/client-runtime/codex-citations";
import { cn } from "~/lib/utils";
import { CHAT_INLINE_CHIP_CLASS_NAME } from "../composerInlineChip";
import {
Popover,
PopoverDescription,
PopoverPopup,
PopoverTitle,
PopoverTrigger,
} from "../ui/popover";

/** A source reference stays inspectable even when Codex does not supply its destination. */
export function CodexCitationChip({ citation }: { citation: CodexCitation }) {
const sourceNumbers = citation.sources.map((source) => source.number).join(", ");
const label = `${citation.sources.length === 1 ? "Source" : "Sources"} ${sourceNumbers}`;

return (
<span
className="chat-markdown-source-citation"
data-markdown-copy={codexCitationMarkdown(citation)}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
data-markdown-copy-text={codexCitationText(citation)}
>
<Popover>
<PopoverTrigger
className={cn(
CHAT_INLINE_CHIP_CLASS_NAME,
"cursor-pointer align-baseline text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring",
)}
aria-label={`View ${label.toLowerCase()}`}
title={`${label}. Source URL unavailable.`}
>
<span className="truncate tabular-nums">{sourceNumbers}</span>
</PopoverTrigger>
<PopoverPopup side="top" align="start" className="w-72 max-w-[calc(100vw-2rem)]">
<PopoverTitle className="text-sm">{label}</PopoverTitle>
<dl className="mt-3 space-y-2 text-xs">
{citation.sources.map((source) => (
<div key={source.id} className="flex items-baseline gap-3">
<dt className="shrink-0 text-muted-foreground">Source {source.number}</dt>
<dd className="min-w-0 font-mono wrap-anywhere select-text">{source.id}</dd>
</div>
))}
{citation.locator ? (
<div className="flex items-baseline gap-3">
<dt className="shrink-0 text-muted-foreground">Lines</dt>
<dd className="min-w-0 font-mono wrap-anywhere select-text">{citation.locator}</dd>
</div>
) : null}
</dl>
<PopoverDescription className="mt-3 text-xs">
Source URL unavailable. Codex supplied source IDs without destination links.
</PopoverDescription>
</PopoverPopup>
</Popover>
</span>
);
}
53 changes: 53 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,59 @@ describe("resolveAssistantMessageCopyState", () => {
visible: true,
});
});

it("copies readable source references while preserving citation examples in code", () => {
const citation = "\uE200cite\uE202turn0view0\uE201";
expect(
resolveAssistantMessageCopyState({
showCopyButton: true,
text: `The report supports this. ${citation}\n\nExample: \`${citation}\``,
streaming: false,
}),
).toEqual({
text: `The report supports this. \\[Source 1: turn0view0\\]\n\nExample: \`${citation}\``,
visible: true,
});
});

it("keeps artifact names literal without shifting copied source numbers", () => {
const artifactName = "\uE200cite\uE202turn0view0\uE201";
const citation = "\uE200cite\uE202turn1search2\uE201";
expect(
resolveAssistantMessageCopyState({
showCopyButton: true,
text: [
`::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="${artifactName}" artifact_kind="document"}`,
"",
`The report supports this. ${citation}`,
].join("\n"),
streaming: false,
}),
).toEqual({
text: `${artifactName} (Document template)\n\nThe report supports this. \\[Source 1: turn1search2\\]`,
visible: true,
});
});

it("does not rewrite an unfinished response for a hidden copy button", () => {
const text = "The report supports this. \uE200cite\uE202turn0view0";
expect(
resolveAssistantMessageCopyState({ showCopyButton: true, text, streaming: true }),
).toEqual({ text, visible: false });
});

it("keeps citation numbering consistent when copying recovered list content", () => {
expect(
resolveAssistantMessageCopyState({
showCopyButton: true,
text: "- First. \uE200cite\uE202turn0view0\uE201\n\nSecond. \uE200cite\uE202turn1view0\uE201",
streaming: false,
}),
).toEqual({
text: "- First. \\[Source 1: turn0view0\\]\n\nSecond. \\[Source 2: turn1view0\\]",
visible: true,
});
});
});

describe("workEntryIsVisibleInGroup", () => {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Equal from "effect/Equal";
import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
import { renderCodexCitationsAsMarkdown } from "@t3tools/client-runtime/codex-citations";
import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
import {
liveActivityToolStatus,
Expand Down Expand Up @@ -435,7 +436,11 @@ export function resolveAssistantMessageCopyState({
const hasText = text !== null && text.trim().length > 0;
const visible = showCopyButton && hasText && !streaming;
return {
text: hasText ? (visible ? renderCodexDirectivesForCopy(text) : text) : null,
text: hasText
? visible
? renderCodexDirectivesForCopy(renderCodexCitationsAsMarkdown(text))
: text
: null,
visible,
};
}
Expand Down
Loading
Loading