Update sources styling (#1838)

This commit is contained in:
Logan Yang 2025-09-22 17:19:17 -07:00 committed by GitHub
parent feeea21127
commit 047d42c5a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 229 additions and 81 deletions

View file

@ -96,7 +96,8 @@ More content
const dashHeading = "Content\nSources -\n[^1]: [[Doc]]";
expect(hasExistingCitations(dashHeading)).toBe(true);
const summary = "<details><summary>Sources</summary>List</details>";
const summary =
'<details><summary class="copilot-sources__summary">Sources</summary>List</details>';
expect(hasExistingCitations(summary)).toBe(true);
});
@ -188,9 +189,10 @@ More content
[^2]: [[Document 2]]`;
const result = processInlineCitations(content, true);
expect(result).toContain("Sources</summary>");
expect(result).toContain("Document 1");
expect(result).toContain("Document 2");
expect(result).toContain("copilot-sources__summary");
expect(result).toContain('copilot-sources__index">[1]');
expect(result).toContain('copilot-sources__text">[[Document 1]]');
expect(result).toContain('copilot-sources__text">[[Document 2]]');
});
it("should use simple expandable list when disabled", () => {
@ -201,9 +203,10 @@ More content
- [[Document 2]]`;
const result = processInlineCitations(content, false);
expect(result).toContain("Sources</summary>");
expect(result).toContain("Document 1");
expect(result).toContain("Document 2");
expect(result).toContain("copilot-sources__summary");
expect(result).toContain('copilot-sources__index">[1]');
expect(result).toContain('copilot-sources__text">[[Document 1]]');
expect(result).toContain('copilot-sources__text">[[Document 2]]');
});
it("should return unchanged content when no sources section", () => {
@ -350,10 +353,18 @@ More content
// [^2] (third mention) -> [3] -> should map to [[2024-03-26]]
// [^18] (fourth mention) -> [4] -> should map to [[2024-04-08]]
expect(result).toContain("<strong>[1]</strong> [[2024-03-13]]"); // [^9] -> [1]
expect(result).toContain("<strong>[2]</strong> [[2024-03-18]]"); // [^1] -> [2]
expect(result).toContain("<strong>[3]</strong> [[2024-03-26]]"); // [^2] -> [3]
expect(result).toContain("<strong>[4]</strong> [[2024-04-08]]"); // [^18] -> [4]
expect(result).toContain(
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[2024-03-13]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[2024-03-18]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[3]</span><span class="copilot-sources__text">[[2024-03-26]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[4]</span><span class="copilot-sources__text">[[2024-04-08]]</span>'
);
// Verify the citations in text are renumbered correctly
expect(result).toContain("Dolce Arts Studio [1]"); // [^9] -> [1]
@ -380,10 +391,14 @@ More content
const result = processInlineCitations(content, true);
// Should consolidate the 3 "How to Make Wealth" entries into 1
expect(result).toContain("<strong>[1]</strong> [[How to Make Wealth]]");
expect(result).toContain("<strong>[2]</strong> [[Superlinear Returns]]");
expect(result).not.toContain("<strong>[3]</strong>");
expect(result).not.toContain("<strong>[4]</strong>");
expect(result).toContain(
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[How to Make Wealth]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[Superlinear Returns]]</span>'
);
expect(result).not.toContain('copilot-sources__index">[3]');
expect(result).not.toContain('copilot-sources__index">[4]');
// Verify periods after citations are removed
expect(result).not.toContain("[1].");
@ -419,10 +434,16 @@ More content
// [^22] (fourth mention) -> [4] -> [[Document B]] (should consolidate with [^14])
// After consolidation, should only have 3 unique sources:
expect(result).toContain("<strong>[1]</strong> [[Document B]]"); // [^14] and [^22] consolidated
expect(result).toContain("<strong>[2]</strong> [[Document C]]"); // [^17]
expect(result).toContain("<strong>[3]</strong> [[Document A]]"); // [^3]
expect(result).not.toContain("<strong>[4]</strong>");
expect(result).toContain(
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[Document B]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[Document C]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[3]</span><span class="copilot-sources__text">[[Document A]]</span>'
);
expect(result).not.toContain('copilot-sources__index">[4]');
// Verify citations in text point to correct consolidated sources
expect(result).toContain("references [1] and also [2]"); // [^14]->[1], [^17]->[2]
@ -447,8 +468,12 @@ More content
// [^7] (first mention) -> [1] -> [[Superlinear Returns]]
// [^8] (second mention) -> [2] -> [[How to Do Great Work]]
expect(result).toContain("<strong>[1]</strong> [[Superlinear Returns]]"); // [^7] -> [1]
expect(result).toContain("<strong>[2]</strong> [[How to Do Great Work]]"); // [^8] -> [2]
expect(result).toContain(
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[Superlinear Returns]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[How to Do Great Work]]</span>'
);
// CRITICAL: Both citations in text must be converted (not partial like [4][^8])
expect(result).toContain("thresholds). [1][2]"); // [^7][^8] -> [1][2]

View file

@ -151,7 +151,7 @@ export function hasExistingCitations(response: string): boolean {
const content = response || "";
const hasMarkdownHeading = /(^|\n)\s*#{1,6}\s*Sources\b/i.test(content);
const hasPlainLabel = /(^|\n)\s*Sources\s*(?:[:-]\s*)?(\n|$)/i.test(content);
const hasSummaryTag = /<summary>\s*Sources\s*<\/summary>/i.test(content);
const hasSummaryTag = /<summary[^>]*>\s*Sources\s*<\/summary>/i.test(content);
// More robust detection: look for ANY line starting with [^digits]:
const hasFootnoteDefinitions = /(^|\n)\s*\[\^\d+\]:\s*/.test(content);
return hasMarkdownHeading || hasPlainLabel || hasSummaryTag || hasFootnoteDefinitions;
@ -403,6 +403,93 @@ export function updateCitationsForConsolidation(
});
}
interface SourcesDisplayItem {
index: number;
html: string;
}
/**
* Escapes HTML-sensitive characters to avoid unintended markup injection.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Type guard that filters out nullish values when mapping optional entries.
*/
function isNonNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
/**
* Converts a normalized sources block into display-ready list items.
*/
function parseSimpleSources(sourcesBlock: string): SourcesDisplayItem[] {
const lines = sourcesBlock.split("\n");
const items: SourcesDisplayItem[] = [];
lines.forEach((rawLine) => {
const trimmed = rawLine.trim();
if (!trimmed) {
return;
}
const content = trimmed
.replace(/^<li>/i, "")
.replace(/<\/li>$/i, "")
.replace(/^[-*]\s*/, "")
.trim();
if (!content) {
return;
}
const markdownLink = content.match(/\[([^\]]+)\]\(([^)]+)\)/);
const wikiLink = content.match(/\[\[(.*?)\]\]/);
let html: string;
if (markdownLink) {
html = `<a href="${markdownLink[2]}">${markdownLink[1]}</a>`;
} else if (wikiLink) {
html = `[[${wikiLink[1]}]]`;
} else {
html = escapeHtml(content);
}
items.push({ index: items.length + 1, html });
});
return items;
}
/**
* Appends a styled, collapsible sources list to the main message content.
*/
function buildSourcesDetails(mainContent: string, items: SourcesDisplayItem[]): string {
if (items.length === 0) {
return mainContent;
}
const listItems = items
.map(
({ index, html }) =>
`<li class="copilot-sources__item"><span class="copilot-sources__index">[${index}]</span><span class="copilot-sources__text">${html}</span></li>`
)
.join("\n");
return (
`${mainContent}\n\n<br/>\n` +
`<details class="copilot-sources"><summary class="copilot-sources__summary">Sources</summary>\n` +
`<ul class="copilot-sources__list">\n${listItems}\n</ul>\n</details>`
);
}
/**
* Main function to process inline citations in content.
* Uses settings to determine if inline citations should be processed.
@ -416,46 +503,15 @@ export function processInlineCitations(content: string, useInlineCitations: bool
// If inline citations are disabled, use simple expandable sources list
if (!useInlineCitations) {
const sourceLinks = sourcesBlock
.split("\n")
.map((line) => {
const match = line.match(/- \[\[(.*?)\]\]/);
if (match) {
return `<li>[[${match[1]}]]</li>`;
}
return line;
})
.join("\n");
return (
mainContent +
"\n\n<br/>\n<details><summary>Sources</summary>\n<ul>\n" +
sourceLinks +
"\n</ul>\n</details>"
);
const simpleItems = parseSimpleSources(sourcesBlock);
return buildSourcesDetails(mainContent, simpleItems);
}
// Process inline citations
const footnoteLines = parseFootnoteDefinitions(sourcesBlock);
if (footnoteLines.length === 0) {
// Not footnote format, use simple sources list
const sourceLinks = sourcesBlock
.split("\n")
.map((line) => {
const match = line.match(/- \[\[(.*?)\]\]/);
if (match) {
return `<li>[[${match[1]}]]</li>`;
}
return line;
})
.join("\n");
return (
mainContent +
"\n\n<br/>\n<details><summary>Sources</summary>\n<ul>\n" +
sourceLinks +
"\n</ul>\n</details>"
);
const simpleItems = parseSimpleSources(sourcesBlock);
return buildSourcesDetails(mainContent, simpleItems);
}
// Process footnote-style citations
@ -471,31 +527,16 @@ export function processInlineCitations(content: string, useInlineCitations: bool
items = uniqueItems;
}
// Build sources list maintaining proper citation mapping
// The old code used .filter().map() which destroyed the mapping by renumbering
// Now we build the list respecting the citation numbers used in the text
const maxCitationNum = Math.max(
...Array.from(citationMap.values()).concat(
consolidationMap.size > 0 ? Array.from(consolidationMap.values()) : []
)
);
const detailedItems = items
.map<SourcesDisplayItem | null>((item, index) => {
if (!item) {
return null;
}
return { index: index + 1, html: item };
})
.filter(isNonNull);
const sourcesList: string[] = [];
for (let i = 1; i <= maxCitationNum; i++) {
const item = items[i - 1];
if (item) {
sourcesList.push(`<li><strong>[${i}]</strong> ${item}</li>`);
}
}
const formattedSourcesList = sourcesList.join("\n");
return (
mainContent +
"\n\n<br/>\n<details><summary>Sources</summary>\n<ul>\n" +
formattedSourcesList +
"\n</ul>\n</details>"
);
return buildSourcesDetails(mainContent, detailedItems);
}
// ===== SOURCE CATALOG UTILITIES =====

View file

@ -881,3 +881,85 @@ If your plugin does not need CSS, delete this file.
.workspace-leaf-content[data-type="copilot-chat-view"] .view-content {
padding-bottom: max(var(--safe-area-inset-bottom), var(--size-4-8)) !important;
}
/* Collapsible sources styling */
.copilot-sources {
margin-top: var(--size-4-1);
padding: calc(var(--size-4-2) / 2) var(--size-4-2);
border-radius: var(--radius-s);
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 70%, transparent);
background-color: color-mix(
in srgb,
var(--background-primary) 94%,
var(--background-modifier-border) 6%
);
width: min(100%, 32rem);
}
.copilot-sources__summary {
font-size: var(--font-ui-smaller);
color: color-mix(in srgb, var(--text-muted) 90%, transparent);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--size-4-2);
padding: calc(var(--size-4-2) / 4) 0;
padding-left: var(--size-4-2);
padding-right: var(--size-4-2);
margin: 0;
}
.copilot-sources__summary::-webkit-details-marker {
color: color-mix(in srgb, var(--text-muted) 80%, transparent);
}
.copilot-sources__summary::after {
content: ">";
font-size: var(--font-ui-smaller);
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
transition: transform 0.15s ease;
}
.copilot-sources[open] .copilot-sources__summary {
margin-bottom: calc(var(--size-4-2) / 2);
}
.copilot-sources[open] .copilot-sources__summary::after {
transform: rotate(90deg);
}
.copilot-sources__list {
list-style: none;
margin: 0;
padding: 0;
padding-left: var(--size-4-2);
padding-right: var(--size-4-2);
display: flex;
flex-direction: column;
gap: calc(var(--size-4-2) / 2);
font-size: var(--font-ui-smaller);
color: color-mix(in srgb, var(--text-muted) 85%, transparent);
}
.copilot-sources__item {
display: flex;
gap: var(--size-4-2);
align-items: baseline;
}
.copilot-sources__index {
color: color-mix(in srgb, var(--text-muted) 65%, transparent);
font-weight: var(--font-medium);
min-width: var(--size-4-3);
text-align: right;
}
.copilot-sources__text {
color: color-mix(in srgb, var(--text-muted) 90%, transparent);
line-height: 1.4;
}
.copilot-sources__text a {
color: var(--text-accent);
}