feat: Add Web Viewer bridge for referencing open web tabs in chat (#2096)

* feat: Add Web Viewer bridge for referencing open web tabs in chat.

* refactor: Simplify markdown image extraction parser。

* fix: Prevent web selection from auto-reappearing after removal or new chat.

* fix: Suppress active web tab when web selection exists.

* feat: Add YouTube transcript extraction for Web Viewer tabs.

* feat: Built-in slash prompts for web clippers.

* feat: Improve web selection tracking and simplify context settings

  - Merge context settings: combine note/web active content and selection settings
  - Add auto-clear for web selection badge when deselected on page
  - Make note and web selections mutually exclusive
  - Show website favicon in web selection badge
  - Add migration logic and tests for settings
This commit is contained in:
Emt-lin 2026-01-13 09:54:54 +08:00 committed by GitHub
parent ccbe039a86
commit ff3636637f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 7650 additions and 198 deletions

26
package-lock.json generated
View file

@ -81,7 +81,8 @@
"sse": "github:mpetazzoni/sse.js",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"trie-search": "^2.2.0"
"trie-search": "^2.2.0",
"turndown": "^7.2.2"
},
"devDependencies": {
"@langchain/ollama": "^1.0.0",
@ -101,6 +102,7 @@
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/react-syntax-highlighter": "^15.5.6",
"@types/turndown": "^5.0.6",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"builtin-modules": "3.3.0",
@ -5037,6 +5039,12 @@
"zod-to-json-schema": "^3.24.1"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@next/env": {
"version": "15.5.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz",
@ -10190,6 +10198,13 @@
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
"integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw=="
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -22504,6 +22519,15 @@
"node": ">=0.6.x"
}
},
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -52,6 +52,7 @@
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"@types/react-syntax-highlighter": "^15.5.6",
"@types/turndown": "^5.0.6",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"builtin-modules": "3.3.0",
@ -149,6 +150,7 @@
"sse": "github:mpetazzoni/sse.js",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"trie-search": "^2.2.0"
"trie-search": "^2.2.0",
"turndown": "^7.2.2"
}
}

View file

@ -48,11 +48,12 @@ import {
renderCiCMessage,
wrapLocalSearchPayload,
} from "./utils/cicPromptUtils";
import { extractMarkdownImagePaths } from "./utils/imageExtraction";
import { ThinkBlockStreamer } from "./utils/ThinkBlockStreamer";
import { deduplicateSources } from "./utils/toolExecution";
import { recordPromptPayload } from "./utils/promptPayloadRecorder";
import { ModelAdapterFactory, joinPromptSections } from "./utils/modelAdapter";
import { parseXMLToolCalls } from "./utils/xmlParsing";
import { parseXMLToolCalls, unescapeXml } from "./utils/xmlParsing";
import { extractParametersFromZod, SimpleTool } from "@/tools/SimpleTool";
import ProjectManager from "@/LLMProviders/projectManager";
import { isProjectMode } from "@/aiParams";
@ -328,11 +329,47 @@ OUTPUT ONLY XML - NO OTHER TEXT.`;
return processedImages;
}
/**
* Extracts images from a context block (active_note or active_web_tab) in L3 content.
* @param l3Text - The full L3_TURN layer text
* @param source - Configuration for the context source type
* @returns Array of image URLs/paths found in the block
*/
private async extractImagesFromContextBlock(
l3Text: string,
source: { tagName: string; identifierTag: string; displayName: string; useForResolution: boolean }
): Promise<string[]> {
// Match the context block
const blockRegex = new RegExp(`<${source.tagName}>([\\s\\S]*?)<\\/${source.tagName}>`);
const blockMatch = blockRegex.exec(l3Text);
if (!blockMatch) return [];
const block = blockMatch[1];
// Extract content - unescape XML entities for correct URL parsing
const contentRegex = /<content>([\s\S]*?)<\/content>/;
const contentMatch = contentRegex.exec(block);
const content = contentMatch ? unescapeXml(contentMatch[1]) : "";
if (!content) return [];
// Extract identifier (path or url) for logging and optional resolution
const identifierRegex = new RegExp(`<${source.identifierTag}>(.*?)<\\/${source.identifierTag}>`);
const identifierMatch = identifierRegex.exec(block);
const identifier = identifierMatch ? identifierMatch[1] : undefined;
logInfo(
`[CopilotPlus] Extracting images from ${source.displayName}:`,
identifier || `no ${source.identifierTag}`
);
// Use identifier for vault path resolution only if configured
const sourcePath = source.useForResolution ? identifier : undefined;
return this.extractEmbeddedImages(content, sourcePath);
}
private async extractEmbeddedImages(content: string, sourcePath?: string): Promise<string[]> {
// Match both wiki-style ![[image.ext]] and standard markdown ![alt](image.ext)
// Match wiki-style ![[image.ext]]
const wikiImageRegex = /!\[\[(.*?\.(png|jpg|jpeg|gif|webp|bmp|svg))\]\]/g;
// Updated regex to handle URLs with or without file extensions
const markdownImageRegex = /!\[.*?\]\(([^)]+)\)/g;
const resolvedImages: string[] = [];
@ -359,11 +396,9 @@ OUTPUT ONLY XML - NO OTHER TEXT.`;
}
}
// Process standard markdown images
const mdMatches = [...content.matchAll(markdownImageRegex)];
for (const match of mdMatches) {
const imagePath = match[1].trim();
// Process standard markdown images using robust character-scanning parser
const mdImagePaths = extractMarkdownImagePaths(content);
for (const imagePath of mdImagePaths) {
// Skip empty paths
if (!imagePath) continue;
@ -417,7 +452,7 @@ OUTPUT ONLY XML - NO OTHER TEXT.`;
// Process embedded images only if setting is enabled
if (settings.passMarkdownImages) {
// IMPORTANT: Only extract images from the active note
// IMPORTANT: Only extract images from the active context (note or web tab)
// Never from L2 (promoted notes) or attached context notes
const envelope = userMessage.contextEnvelope;
@ -427,35 +462,23 @@ OUTPUT ONLY XML - NO OTHER TEXT.`;
);
}
// Extract ONLY from <active_note> block in L3
// Extract from active context blocks in L3 (active_note or active_web_tab)
const l3Turn = envelope.layers.find((l) => l.id === "L3_TURN");
if (l3Turn) {
// Find <active_note> block
const activeNoteRegex = /<active_note>([\s\S]*?)<\/active_note>/;
const activeNoteMatch = activeNoteRegex.exec(l3Turn.text);
// Define context sources to extract images from
// - tagName: XML tag name in L3 content
// - identifierTag: tag containing source identifier (path/url) for logging
// - displayName: human-readable name for logs
// - useForResolution: whether to use identifier for vault path resolution
const contextSources = [
{ tagName: "active_note", identifierTag: "path", displayName: "active note", useForResolution: true },
{ tagName: "active_web_tab", identifierTag: "url", displayName: "active web tab", useForResolution: false },
];
if (activeNoteMatch) {
const activeNoteBlock = activeNoteMatch[1];
// Extract path from <path> tag
const pathRegex = /<path>(.*?)<\/path>/;
const pathMatch = pathRegex.exec(activeNoteBlock);
const sourcePath = pathMatch ? pathMatch[1] : undefined;
// Extract content from <content> tag
const contentRegex = /<content>([\s\S]*?)<\/content>/;
const contentMatch = contentRegex.exec(activeNoteBlock);
const activeNoteContent = contentMatch ? contentMatch[1] : "";
if (activeNoteContent) {
logInfo(
"[CopilotPlus] Extracting images from active note only:",
sourcePath || "no source path"
);
const embeddedImages = await this.extractEmbeddedImages(activeNoteContent, sourcePath);
if (embeddedImages.length > 0) {
imageSources.push({ urls: embeddedImages, type: "embedded" });
}
for (const source of contextSources) {
const images = await this.extractImagesFromContextBlock(l3Turn.text, source);
if (images.length > 0) {
imageSources.push({ urls: images, type: "embedded" });
}
}
}

View file

@ -1,3 +1,5 @@
import { extractMarkdownImagePaths } from "./imageExtraction";
// Test for the image extraction logic
describe("Image extraction from content", () => {
// Mock the global app object
@ -9,18 +11,14 @@ describe("Image extraction from content", () => {
(global as any).app = mockApp;
// Mock logger (removed since we're no longer logging warnings)
beforeEach(() => {
jest.clearAllMocks();
});
// Helper function that replicates the extractEmbeddedImages logic
// Helper function that replicates the extractEmbeddedImages logic from CopilotPlusChainRunner
async function extractEmbeddedImages(content: string, sourcePath?: string): Promise<string[]> {
// Match both wiki-style ![[image.ext]] and standard markdown ![alt](image.ext)
// Match wiki-style ![[image.ext]]
const wikiImageRegex = /!\[\[(.*?\.(png|jpg|jpeg|gif|webp|bmp|svg))\]\]/g;
// Updated regex to handle URLs with or without file extensions
const markdownImageRegex = /!\[.*?\]\(([^)]+)\)/g;
const resolvedImages: string[] = [];
@ -46,11 +44,9 @@ describe("Image extraction from content", () => {
}
}
// Process standard markdown images
const mdMatches = [...content.matchAll(markdownImageRegex)];
for (const match of mdMatches) {
const imagePath = match[1].trim();
// Process standard markdown images using robust character-scanning parser
const mdImagePaths = extractMarkdownImagePaths(content);
for (const imagePath of mdImagePaths) {
// Skip empty paths
if (!imagePath) continue;
@ -339,4 +335,376 @@ describe("Image extraction from content", () => {
expect(result).toEqual(["attachments/image (1).png", "attachments/file-name_2.jpg"]);
});
});
describe("Angle bracket syntax ![alt](<url>)", () => {
it("should extract URL with parentheses using angle brackets", async () => {
const content = "Wikipedia link ![Mars](<https://en.wikipedia.org/wiki/Mars_(planet).jpg>)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["https://en.wikipedia.org/wiki/Mars_(planet).jpg"]);
});
it("should extract URL with spaces using angle brackets", async () => {
const content = "Spaced path ![alt](<path/my image file.png>)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["path/my image file.png"]);
});
it("should extract multiple angle bracket URLs", async () => {
const content = `
First ![img1](<https://example.com/image(1).png>)
Second ![img2](<https://example.com/image (2).jpg>)
`;
const result = await extractEmbeddedImages(content);
expect(result).toEqual([
"https://example.com/image(1).png",
"https://example.com/image (2).jpg",
]);
});
});
describe("Image with title syntax ![alt](url \"title\")", () => {
it("should extract URL with double-quoted title", async () => {
const content = 'Image with title ![alt](https://example.com/img.png "This is the title")';
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["https://example.com/img.png"]);
});
it("should extract URL with single-quoted title", async () => {
const content = "Image with title ![alt](https://example.com/img.png 'Single quoted title')";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["https://example.com/img.png"]);
});
it("should extract URL with parentheses title", async () => {
const content = "Image with title ![alt](https://example.com/img.png (Parentheses title))";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["https://example.com/img.png"]);
});
it("should extract angle bracket URL with title", async () => {
const content =
'Angle bracket with title ![alt](<https://example.com/image(1).png> "Title here")';
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["https://example.com/image(1).png"]);
});
it("should handle local path with title", async () => {
const content = 'Local image ![alt](images/photo.jpg "My photo")';
const sourcePath = "notes/test.md";
mockApp.metadataCache.getFirstLinkpathDest.mockReturnValueOnce({
path: "images/photo.jpg",
});
const result = await extractEmbeddedImages(content, sourcePath);
expect(result).toEqual(["images/photo.jpg"]);
});
});
describe("Mixed enhanced syntaxes", () => {
it("should handle all syntax variations together", async () => {
const content = `
Standard: ![](https://example.com/standard.png)
With title: ![alt](https://example.com/titled.png "Title")
Angle bracket: ![alt](<https://example.com/path (special).png>)
Angle with title: ![alt](<https://example.com/both(1).png> "Both features")
Wiki style: ![[local.png]]
`;
const result = await extractEmbeddedImages(content);
expect(result).toEqual([
"local.png",
"https://example.com/standard.png",
"https://example.com/titled.png",
"https://example.com/path (special).png",
"https://example.com/both(1).png",
]);
});
it("should not be confused by malformed syntax", async () => {
const content = `
Valid: ![](https://example.com/valid.png)
Missing close bracket: ![alt](https://example.com/missing.png
Empty angle brackets: ![alt](<>)
`;
const result = await extractEmbeddedImages(content);
// Only the valid one should be extracted
expect(result).toEqual(["https://example.com/valid.png"]);
});
});
// NEW TESTS: Paths with parentheses (without angle brackets)
describe("Paths with parentheses (balanced parentheses support)", () => {
it("should extract path with single pair of parentheses", async () => {
const content = "Image ![](foo(bar).png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["foo(bar).png"]);
});
it("should extract path with multiple pairs of parentheses", async () => {
const content = "Image ![](image(1)(2).png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["image(1)(2).png"]);
});
it("should extract Wikipedia-style URL with parentheses", async () => {
const content = "![Mars](https://en.wikipedia.org/wiki/Mars_(planet).jpg)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["https://en.wikipedia.org/wiki/Mars_(planet).jpg"]);
});
it("should extract local path with parentheses", async () => {
const content = "![](images/screenshot (1).png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["images/screenshot (1).png"]);
});
});
// NEW TESTS: Paths with spaces (without angle brackets)
describe("Paths with spaces (space support)", () => {
it("should extract path with spaces", async () => {
const content = "Image ![](foo bar.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["foo bar.png"]);
});
it("should extract path with multiple spaces", async () => {
const content = "Image ![](path/my image file.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["path/my image file.png"]);
});
it("should extract path with spaces and special chars", async () => {
const content = "Image ![](my folder/image - copy (1).png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["my folder/image - copy (1).png"]);
});
});
// NEW TESTS: Multiple images on same line
describe("Multiple images on same line", () => {
it("should extract multiple images on same line", async () => {
const content = "Images: ![](a.png) ![](b.png) ![](c.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["a.png", "b.png", "c.png"]);
});
it("should extract multiple images with text between", async () => {
const content = "First ![](a.png) then ![](b.png) and finally ![](c.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["a.png", "b.png", "c.png"]);
});
it("should handle image followed by parenthetical text", async () => {
const content = "Image ![](a.png) (this is a note) more text";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["a.png"]);
});
});
// NEW TESTS: Alt text with special characters
describe("Alt text with special characters", () => {
it("should handle alt text with parentheses", async () => {
const content = "![a (b)](img.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["img.png"]);
});
it("should handle alt text with brackets", async () => {
const content = "![alt [text]](img.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["img.png"]);
});
it("should handle complex alt text", async () => {
const content = "![Figure 1 (a): Test [ref]](diagram.png)";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["diagram.png"]);
});
});
// NEW TESTS: Edge cases for the new parser
describe("Parser edge cases", () => {
it("should handle escaped characters in path", async () => {
const content = "![](path\\(1\\).png)";
const result = await extractEmbeddedImages(content);
// The parser should handle escaped parens
expect(result.length).toBe(1);
});
it("should handle leading/trailing whitespace in path", async () => {
const content = "![]( image.png )";
const result = await extractEmbeddedImages(content);
expect(result).toEqual(["image.png"]);
});
it("should handle newline between ] and (", async () => {
const content = "![alt]\n(image.png)";
const result = await extractEmbeddedImages(content);
// CommonMark allows whitespace between ] and (
expect(result).toEqual(["image.png"]);
});
});
});
// Direct tests for the extractMarkdownImagePaths function
describe("extractMarkdownImagePaths", () => {
it("should extract simple paths", () => {
const result = extractMarkdownImagePaths("![](simple.png)");
expect(result).toEqual(["simple.png"]);
});
it("should extract paths with parentheses", () => {
const result = extractMarkdownImagePaths("![](foo(bar).png)");
expect(result).toEqual(["foo(bar).png"]);
});
it("should extract paths with spaces", () => {
const result = extractMarkdownImagePaths("![](foo bar.png)");
expect(result).toEqual(["foo bar.png"]);
});
it("should extract multiple images", () => {
const result = extractMarkdownImagePaths("![](a.png) ![](b.png)");
expect(result).toEqual(["a.png", "b.png"]);
});
it("should handle angle bracket syntax", () => {
const result = extractMarkdownImagePaths("![alt](<path with spaces.png>)");
expect(result).toEqual(["path with spaces.png"]);
});
it("should handle title syntax", () => {
const result = extractMarkdownImagePaths('![alt](image.png "title")');
expect(result).toEqual(["image.png"]);
});
it("should return empty array for no images", () => {
const result = extractMarkdownImagePaths("no images here");
expect(result).toEqual([]);
});
it("should skip empty paths", () => {
const result = extractMarkdownImagePaths("![]()");
expect(result).toEqual([]);
});
it("should trim inside angle destinations (bug fix)", () => {
const result = extractMarkdownImagePaths("![](< image.png >)");
expect(result).toEqual(["image.png"]);
});
// Combination edge cases: parentheses destination + title
it("should handle parentheses in path with double-quoted title", () => {
const result = extractMarkdownImagePaths('![](foo(bar).png "title")');
expect(result).toEqual(["foo(bar).png"]);
});
it("should handle parentheses in path with parentheses title", () => {
const result = extractMarkdownImagePaths("![](foo(bar).png (title))");
expect(result).toEqual(["foo(bar).png"]);
});
// Combination edge cases: spaces destination + title
it("should handle spaces in path with double-quoted title", () => {
const result = extractMarkdownImagePaths('![](foo bar.png "title")');
expect(result).toEqual(["foo bar.png"]);
});
it("should handle spaces in path with parentheses title", () => {
const result = extractMarkdownImagePaths("![](foo bar.png (title))");
expect(result).toEqual(["foo bar.png"]);
});
// Ambiguous case: path ending with parentheses preceded by space
// Current behavior: treats (1) as title, returns "image"
// This matches CommonMark behavior where space + (...) is a title
it("should treat space + parentheses at end as title (ambiguous case)", () => {
const result = extractMarkdownImagePaths("![](image (1))");
expect(result).toEqual(["image"]);
});
// To preserve parentheses in filename with spaces, use angle brackets
it("should preserve parentheses in filename when using angle brackets", () => {
const result = extractMarkdownImagePaths("![](<image (1).png>)");
expect(result).toEqual(["image (1).png"]);
});
// Edge cases: original regex would match but current implementation handles differently
// These tests lock down the improved behavior vs the old regex /!\[.*?\]\(([^)]+)\)/g
// Unclosed parenthesis in destination - old regex would capture "foo(bar", current skips
it("should skip unclosed parenthesis in destination", () => {
const result = extractMarkdownImagePaths("![](foo(bar)");
expect(result).toEqual([]);
});
// Missing closing angle bracket - old regex would capture "<foo bar.png", current skips
it("should skip angle destination without closing bracket", () => {
const result = extractMarkdownImagePaths("![](<foo bar.png)");
expect(result).toEqual([]);
});
// Nested link in alt text - old regex would incorrectly capture "inner.png"
// Current implementation correctly finds the outer destination
it("should handle nested link syntax in alt text (avoid old regex false positive)", () => {
const result = extractMarkdownImagePaths("![a [b](inner.png)](outer.png)");
expect(result).toEqual(["outer.png"]);
});
// Balanced parentheses - old regex would truncate at first ), current handles correctly
it("should handle balanced parentheses without truncation", () => {
const result = extractMarkdownImagePaths("![](foo(bar))");
expect(result).toEqual(["foo(bar)"]);
});
});

View file

@ -0,0 +1,318 @@
/**
* Extracts Markdown image destinations (paths/URLs) from inline image syntax:
* `![alt](destination "title")`.
*
* Supported:
* - Non-angle destinations can include spaces: `![](foo bar.png)` (loose compatibility)
* - Balanced parentheses in destinations: `![](foo(bar).png)`
* - Angle destinations: `![](<path with spaces.png>)` (content inside `<...>` is trimmed)
* - Optional titles (ignored): `"..."`, `'...'`, `( ... )`
*
* Note: Obsidian wiki embeds (`![[image.png]]`) are handled separately by the caller.
*/
export function extractMarkdownImagePaths(markdown: string): string[] {
const results: string[] = [];
let searchIndex = 0;
while (searchIndex < markdown.length) {
const startIndex = markdown.indexOf("![", searchIndex);
if (startIndex === -1) {
break;
}
const closeBracketIndex = findClosingBracketIndex(markdown, startIndex + 2);
if (closeBracketIndex === null) {
searchIndex = startIndex + 2;
continue;
}
// Skip whitespace between ] and (
let i = closeBracketIndex + 1;
while (i < markdown.length && isWhitespaceChar(markdown[i])) {
i++;
}
if (markdown[i] !== "(") {
searchIndex = i;
continue;
}
const closeParenIndex = findClosingParenIndexForImage(markdown, i);
if (closeParenIndex === null) {
searchIndex = i + 1;
continue;
}
const destination = parseImageDestination(markdown.slice(i + 1, closeParenIndex));
if (destination) {
results.push(destination);
}
searchIndex = closeParenIndex + 1;
}
return results;
}
/**
* Parses the inside of `(...)` and returns only the destination.
*
* Rules:
* - Angle destinations `<...>`: content is trimmed (fixes `![](< image.png >)` `image.png`)
* - Non-angle destinations: supports spaces (loose compatibility), strips optional title from end
*/
function parseImageDestination(innerRaw: string): string | null {
const inner = innerRaw.trim();
if (inner.length === 0) {
return null;
}
// Handle angle bracket destinations: `<...>`
if (inner.startsWith("<")) {
const closeAngleIndex = findClosingAngleBracket(inner, 0);
if (closeAngleIndex === null) {
return null;
}
// Fix: trim inside `< ... >`, so `![](< image.png >)` returns `image.png`
const destination = inner.slice(1, closeAngleIndex).trim();
return destination.length > 0 ? destination : null;
}
// Non-angle destination: strip optional title from end, keep spaces (loose compatibility)
const destination = stripOptionalTitleFromEnd(inner).trim();
return destination.length > 0 ? destination : null;
}
/**
* Strips an optional trailing title from a non-angle destination string.
* Title forms (ignored): `"..."`, `'...'`, `( ... )`, each preceded by whitespace.
*/
function stripOptionalTitleFromEnd(value: string): string {
const s = value.trimEnd();
if (s.length === 0) {
return "";
}
const lastChar = s[s.length - 1];
// Check for quoted title: `"..."` or `'...'`
if (lastChar === '"' || lastChar === "'") {
const quote = lastChar;
const openIndex = findMatchingUnescapedQuoteFromEnd(s, quote, s.length - 1);
if (openIndex !== null) {
const before = s.slice(0, openIndex);
const hasSeparator = before.length > 0 && isWhitespaceChar(before[before.length - 1]);
if (hasSeparator) {
return before.trimEnd();
}
}
}
// Check for parentheses title: `( ... )`
if (lastChar === ")") {
const openIndex = findMatchingOpeningParenForEnd(s);
if (openIndex !== null) {
const before = s.slice(0, openIndex);
const hasSeparator = before.length > 0 && isWhitespaceChar(before[before.length - 1]);
if (hasSeparator) {
return before.trimEnd();
}
}
}
return s;
}
/**
* Finds the closing `]` for image/link text, supporting nested brackets and backslash escapes.
*/
function findClosingBracketIndex(source: string, startIndex: number): number | null {
let i = startIndex;
let nestedDepth = 0;
while (i < source.length) {
const ch = source[i];
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "[") {
nestedDepth++;
i++;
continue;
}
if (ch === "]") {
if (nestedDepth === 0) {
return i;
}
nestedDepth--;
i++;
continue;
}
i++;
}
return null;
}
/**
* Finds the correct closing `)` for the image parens starting at `openParenIndex`.
* Counts nested parentheses, but ignores any parentheses inside an initial `<...>` destination.
*/
function findClosingParenIndexForImage(source: string, openParenIndex: number): number | null {
let i = openParenIndex + 1;
let parenDepth = 1;
let beforeDestination = true;
let inAngleDestination = false;
while (i < source.length) {
const ch = source[i];
if (inAngleDestination) {
if (ch === "\\") {
i += 2;
continue;
}
if (ch === ">") {
inAngleDestination = false;
beforeDestination = false;
i++;
continue;
}
i++;
continue;
}
if (beforeDestination) {
if (isWhitespaceChar(ch)) {
i++;
continue;
}
if (ch === "<") {
inAngleDestination = true;
i++;
continue;
}
beforeDestination = false;
}
if (ch === "\\") {
i += 2;
continue;
}
if (ch === "(") {
parenDepth++;
i++;
continue;
}
if (ch === ")") {
parenDepth--;
if (parenDepth === 0) {
return i;
}
i++;
continue;
}
i++;
}
return null;
}
/**
* Finds the closing `>` for an angle destination starting at `openIndex` (which points to `<`).
*/
function findClosingAngleBracket(source: string, openIndex: number): number | null {
for (let i = openIndex + 1; i < source.length; i++) {
const ch = source[i];
if (ch === "\\") {
i++;
continue;
}
if (ch === ">") {
return i;
}
}
return null;
}
/**
* Finds the opening quote matching a closing quote at `closeIndex`, scanning backward.
*/
function findMatchingUnescapedQuoteFromEnd(
source: string,
quote: '"' | "'",
closeIndex: number
): number | null {
for (let i = closeIndex - 1; i >= 0; i--) {
if (source[i] === quote && !isCharEscaped(source, i)) {
return i;
}
}
return null;
}
/**
* Finds the matching opening `(` for a string ending in `)`, supporting nested parentheses and escapes.
*/
function findMatchingOpeningParenForEnd(source: string): number | null {
if (source.length === 0 || source[source.length - 1] !== ")") {
return null;
}
let depth = 0;
for (let i = source.length - 1; i >= 0; i--) {
const ch = source[i];
if ((ch === ")" || ch === "(") && isCharEscaped(source, i)) {
continue;
}
if (ch === ")") {
depth++;
continue;
}
if (ch === "(") {
depth--;
if (depth === 0) {
return i;
}
}
}
return null;
}
/**
* Checks whether `source[index]` is escaped by an odd number of consecutive backslashes.
*/
function isCharEscaped(source: string, index: number): boolean {
let backslashCount = 0;
for (let i = index - 1; i >= 0; i--) {
if (source[i] !== "\\") {
break;
}
backslashCount++;
}
return backslashCount % 2 === 1;
}
/**
* Returns true if a character is treated as whitespace for parsing.
*/
function isWhitespaceChar(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
}

View file

@ -18,6 +18,26 @@ export function escapeXml(str: string): string {
.replace(/'/g, "&apos;");
}
/**
* Unescapes XML entities back to their original characters.
* Used when extracting content that was previously escaped (e.g., image URLs).
* @param str - The XML-escaped string to unescape
* @returns The unescaped string with original characters restored
*/
export function unescapeXml(str: string): string {
if (typeof str !== "string") {
return "";
}
// Reason: &amp; must be unescaped last to prevent double-unescaping
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&amp;/g, "&");
}
/**
* Escapes special XML characters for use in XML attributes
* @param str - The string to escape for attribute use

View file

@ -168,4 +168,146 @@ export const DEFAULT_COMMANDS: CustomCommand[] = [
modelKey: "",
lastUsedMs: 0,
},
{
title: "Clip YouTube Transcript",
content: `
Based on the YouTube video information and transcript provided in the context, generate a complete Obsidian note in the following format.
IMPORTANT: If no YouTube video context is found, remind the user to:
1. Open a YouTube video in Web Viewer (or use @ to select a YouTube web tab)
2. Then use this command again
Generate the note with this exact structure:
---
title: "<video title>"
description: "<first 200 chars of description>"
channel: "<channel name>"
url: "<video url>"
duration: "<duration>"
published: <upload date in YYYY-MM-DD format>
thumbnailUrl: "<YouTube thumbnail URL: i.ytimg.com/vi/VIDEO_ID/maxresdefault.jpg with https protocol>"
genre:
- "<genre>"
watched:
---
![<video title>](<video url>)
> [!summary]- Description
> <full video description, preserve line breaks>
## Summary
<Brief 2-3 paragraph summary of the video content>
## Key Takeaways
<List 5-8 key takeaways as bullet points>
## Mindmap
CRITICAL Mermaid mindmap syntax rules - MUST follow exactly:
- Root node format: root(Topic Name) - use round brackets, NO double brackets
- Child nodes: just plain text, no brackets needed
- Do NOT use quotes, parentheses, brackets, or any special characters in text
- Do NOT use icons or emojis
- Keep all node text short and simple - max 3-4 words per node
- Use only letters, numbers, and spaces
Example of CORRECT syntax:
\`\`\`mermaid
mindmap
root(Video Main Topic)
First Theme
Detail one
Detail two
Second Theme
Detail three
Third Theme
\`\`\`
## Notable Quotes
<List 5-10 notable quotes from the transcript. Format each as:>
- [<timestamp>: <quote text>](<video_url>&t=<seconds>s)
> [!transcript]- Transcript (YouTube)
> <Format the full transcript with timestamps, each segment on new line:>
> <timestamp>
> <text>
>
> <timestamp>
> <text>
Return only the markdown content without any explanations or comments.`,
showInContextMenu: false,
showInSlashMenu: true,
order: 1130,
modelKey: "",
lastUsedMs: 0,
},
{
title: "Clip Web Page",
content: `
Based on the web page content provided in the context (from Obsidian Web Clipper or Web Viewer), generate a complete Obsidian note.
IMPORTANT: If no web page context is found, remind the user to:
1. Open a web page in Web Viewer (or use @ to select a web tab)
2. Or open a note clipped by Obsidian Web Clipper
3. Then use this command again
Generate the note with this exact structure:
---
title: "<page title>"
source: "<page url>"
description: "<brief description>"
tags:
- "clippings"
---
## Summary
<Brief 2-3 paragraph summary of the page content>
##
<用中文写的 2-3 段摘要>
## Key Takeaways
<List 5-8 key takeaways as bullet points>
##
<用中文列出 5-8 个要点>
## Mindmap
CRITICAL Mermaid mindmap syntax rules - MUST follow exactly:
- Root node format: root(Topic Name) - use round brackets, NO double brackets
- Child nodes: just plain text, no brackets needed
- Do NOT use quotes, parentheses, brackets, or any special characters in text
- Keep all node text short and simple - max 3-4 words per node
\`\`\`mermaid
mindmap
root(Main Topic)
Theme One
Detail
Theme Two
Detail
\`\`\`
## Notable Quotes
<List 3-5 notable quotes from the content, if any>
Return only the markdown content without any explanations or comments.`,
showInContextMenu: false,
showInSlashMenu: true,
order: 1140,
modelKey: "",
lastUsedMs: 0,
},
];

View file

@ -13,6 +13,7 @@ import * as settingsModelModule from "@/settings/model";
import { PromptSortStrategy } from "@/types";
import { sortSlashCommands } from "@/commands/customCommandUtils";
import { DEFAULT_SETTINGS } from "@/constants";
import { logWarn } from "@/logger";
// Mock Obsidian
jest.mock("obsidian", () => ({
@ -21,6 +22,13 @@ jest.mock("obsidian", () => ({
Vault: jest.fn(),
}));
// Mock logger
jest.mock("@/logger", () => ({
logWarn: jest.fn(),
logInfo: jest.fn(),
logError: jest.fn(),
}));
// Mock the utility functions
jest.mock("@/utils", () => ({
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
@ -34,14 +42,8 @@ jest.mock("@/utils", () => ({
describe("processedPrompt()", () => {
let mockVault: Vault;
let mockActiveNote: TFile;
let originalConsoleWarn: typeof console.warn;
beforeEach(() => {
// Save original console.warn
originalConsoleWarn = console.warn;
// Mock console.warn
console.warn = jest.fn();
// Reset mocks before each test
jest.clearAllMocks();
jest.resetAllMocks();
@ -64,11 +66,6 @@ describe("processedPrompt()", () => {
} as TFile;
});
afterEach(() => {
// Restore original console.warn
console.warn = originalConsoleWarn;
});
it("should add 1 context and selectedText", async () => {
const doc: CustomCommand = {
title: "test-prompt",
@ -569,7 +566,7 @@ describe("processedPrompt()", () => {
);
expect(result.includedFiles).toContain(mockActiveNote);
// Expect the warning for the invalid variable
expect(console.warn).toHaveBeenCalledWith("No notes found for variable: invalidVariable");
expect(logWarn).toHaveBeenCalledWith("No notes found for variable: invalidVariable");
});
});

View file

@ -9,6 +9,7 @@ import {
QUICK_COMMAND_CODE_BLOCK,
} from "@/commands/constants";
import { CustomCommand } from "@/commands/type";
import { logWarn } from "@/logger";
import { normalizePath, Notice, TAbstractFile, TFile, Vault, Editor } from "obsidian";
import { getSettings } from "@/settings/model";
import {
@ -270,7 +271,9 @@ async function extractVariablesFromPrompt(
const variableName = match[1].trim();
const variableResult: VariableProcessingResult = { content: "", files: [] };
if (variableName.toLowerCase() === "activenote") {
const variableNameLower = variableName.toLowerCase();
if (variableNameLower === "activenote") {
if (activeNote) {
const content = await getFileContent(activeNote, vault);
if (content) {
@ -280,6 +283,9 @@ async function extractVariablesFromPrompt(
} else {
new Notice("No active note found.");
}
} else if (variableNameLower === "activewebtab") {
// Reserved variable: handled by webTabs context pipeline, skip here
continue;
} else if (variableName.startsWith("#")) {
// Handle tag-based variable for multiple tags
const tagNames = variableName
@ -317,11 +323,11 @@ async function extractVariablesFromPrompt(
if (variableResult.content) {
variablesMap.set(variableName, variableResult.content);
variableResult.files.forEach((file) => includedFiles.add(file));
} else if (variableName.toLowerCase() !== "activenote") {
} else if (variableNameLower !== "activenote" && variableNameLower !== "activewebtab") {
if (variableName.startsWith('"')) {
// DO NOTHING as the user probably wants to write a JSON object
} else {
console.warn(`No notes found for variable: ${variableName}`);
logWarn(`No notes found for variable: ${variableName}`);
}
}
}

View file

@ -19,11 +19,12 @@ import { checkIsPlusUser } from "@/plusUtils";
import CopilotPlugin from "@/main";
import { getAllQAMarkdownContent } from "@/search/searchUtils";
import { CopilotSettings } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
import { NoteSelectedTextContext, WebSelectedTextContext } from "@/types/message";
import { ensureFolderExists, isSourceModeOn } from "@/utils";
import { Editor, MarkdownView, Notice, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { COMMAND_IDS, COMMAND_NAMES, CommandId } from "../constants";
import { setSelectedTextContexts } from "@/aiParams";
/**
* Add a command to the plugin.
@ -447,23 +448,73 @@ export function registerCommands(
const endLine = selectionRange.head.line + 1;
// Create selected text context
const selectedTextContext: SelectedTextContext = {
const selectedTextContext: NoteSelectedTextContext = {
id: uuidv4(),
content: selectedText,
sourceType: "note",
noteTitle: activeFile.basename,
notePath: activeFile.path,
startLine: Math.min(startLine, endLine),
endLine: Math.max(startLine, endLine),
};
// Replace selected text contexts (consistent with auto mode behavior)
const { setSelectedTextContexts } = await import("@/aiParams");
// Mutually exclusive: only keep the latest selection
setSelectedTextContexts([selectedTextContext]);
// Open chat window to show the context was added
plugin.activateView();
});
// Add web selection to chat context command (manual)
addCommand(plugin, COMMAND_IDS.ADD_WEB_SELECTION_TO_CHAT_CONTEXT, async () => {
const { Platform } = await import("obsidian");
if (!Platform.isDesktopApp) {
new Notice("Web selection is only available on desktop");
return;
}
const { getWebViewerService } = await import(
"@/services/webViewerService/webViewerServiceSingleton"
);
try {
const service = getWebViewerService(plugin.app);
const leaf = service.getActiveLeaf() ?? service.getLastActiveLeaf();
if (!leaf) {
new Notice("No active Web Tab found");
return;
}
const selectedMarkdown = await service.getSelectedMarkdown(leaf);
if (!selectedMarkdown.trim()) {
new Notice("No text selected in Web Tab");
return;
}
const pageInfo = service.getPageInfo(leaf);
// Create web selected text context
const webSelectedTextContext: WebSelectedTextContext = {
id: uuidv4(),
content: selectedMarkdown,
sourceType: "web",
title: pageInfo.title || "Untitled",
url: pageInfo.url,
faviconUrl: pageInfo.faviconUrl || undefined,
};
// Mutually exclusive: only keep the latest selection
setSelectedTextContexts([webSelectedTextContext]);
// Open chat window to show the context was added
plugin.activateView();
} catch (error) {
logError("Error adding web selection to context:", error);
new Notice("Failed to get web selection");
}
});
// Add command to create a new custom command
addCommand(plugin, COMMAND_IDS.ADD_CUSTOM_COMMAND, async () => {
const commands = getCachedCustomCommands();

View file

@ -11,6 +11,7 @@ import {
import { ChainType } from "@/chainFactory";
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
import { logInfo, logError } from "@/logger";
import type { WebTabContext } from "@/types/message";
import { ChatControls, reloadCurrentProject } from "@/components/chat-components/ChatControls";
import ChatInput from "@/components/chat-components/ChatInput";
@ -46,6 +47,7 @@ import { Notice, TFile } from "obsidian";
import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
import { useActiveWebTabState } from "@/components/chat-components/hooks/useActiveWebTabState";
type ChatMode = "default" | "project";
@ -101,6 +103,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES.DEFAULT);
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
@ -128,8 +131,13 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
);
const [selectedTextContexts] = useSelectedTextContexts();
const hasSelectedTextContext = selectedTextContexts.length > 0;
const effectiveIncludeActiveNote = includeActiveNote && !hasSelectedTextContext;
// Any selection hides both active note and active web tab
const hasAnySelection = selectedTextContexts.length > 0;
const effectiveIncludeActiveNote = includeActiveNote && !hasAnySelection;
const effectiveIncludeActiveWebTab = includeActiveWebTab && !hasAnySelection;
const { activeWebTabForMentions: currentActiveWebTab } = useActiveWebTabState();
const projectContextStatus = useProjectContextStatus();
// Calculate whether to show ProgressCard based on status and user preference
@ -180,12 +188,14 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
contextNotes: passedContextNotes,
contextTags,
contextFolders,
webTabs,
}: {
toolCalls?: string[];
urls?: string[];
contextNotes?: TFile[];
contextTags?: string[];
contextFolders?: string[];
webTabs?: WebTabContext[];
} = {}) => {
if (!inputMessage && selectedImages.length === 0) return;
@ -242,6 +252,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
tags: contextTags || [],
folders: contextFolders || [],
selectedTextContexts,
webTabs: webTabs || [],
};
// Clear input and images
@ -256,6 +267,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
context,
currentChain,
effectiveIncludeActiveNote,
effectiveIncludeActiveWebTab,
content.length > 0 ? content : undefined
);
@ -538,9 +550,17 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
[settings.projectList]
);
const handleRemoveSelectedText = useCallback((id: string) => {
removeSelectedTextContext(id);
}, []);
const handleRemoveSelectedText = useCallback(
(id: string) => {
const removed = selectedTextContexts.find((ctx) => ctx.id === id);
removeSelectedTextContext(id);
// Suppress web selection to prevent it from being auto-captured again
if (removed?.sourceType === "web") {
plugin.suppressCurrentWebSelection(removed.url);
}
},
[plugin, selectedTextContexts]
);
useEffect(() => {
const handleChatVisibility = () => {
@ -603,12 +623,18 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
safeSet.setCurrentAiMessage("");
setContextNotes([]);
setLatestTokenCount(null); // Clear token count on new chat
// Capture web selection URL before clearing for suppression
const webSelectionUrl = selectedTextContexts.find((ctx) => ctx.sourceType === "web")?.url;
clearSelectedTextContexts();
// Respect the includeActiveNote setting for all non-project chains
// Suppress web selection to prevent it from reappearing in new chat
plugin.suppressCurrentWebSelection(webSelectionUrl);
// Respect the autoAddActiveContentToContext setting for all non-project chains
if (selectedChain === ChainType.PROJECT_CHAIN) {
setIncludeActiveNote(false);
setIncludeActiveWebTab(false);
} else {
setIncludeActiveNote(settings.includeActiveNoteAsContext);
setIncludeActiveNote(settings.autoAddActiveContentToContext);
setIncludeActiveWebTab(settings.autoAddActiveContentToContext);
}
}, [
handleStopGenerating,
@ -616,11 +642,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
chatUIState,
settings.autosaveChat,
settings.enableRecentConversations,
settings.includeActiveNoteAsContext,
settings.autoAddActiveContentToContext,
selectedChain,
handleSaveAsNote,
safeSet,
plugin.userMemoryManager,
plugin,
selectedTextContexts,
]);
const handleLoadChatHistory = useCallback(async () => {
@ -700,17 +727,19 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
};
}, [eventTarget, handleStopGenerating]);
// Use the includeActiveNoteAsContext setting
// Use the autoAddActiveContentToContext setting
useEffect(() => {
if (settings.includeActiveNoteAsContext !== undefined) {
if (settings.autoAddActiveContentToContext !== undefined) {
// Only apply the setting if not in Project mode
if (selectedChain === ChainType.PROJECT_CHAIN) {
setIncludeActiveNote(false);
setIncludeActiveWebTab(false);
} else {
setIncludeActiveNote(settings.includeActiveNoteAsContext);
setIncludeActiveNote(settings.autoAddActiveContentToContext);
setIncludeActiveWebTab(settings.autoAddActiveContentToContext);
}
}
}, [settings.includeActiveNoteAsContext, selectedChain]);
}, [settings.autoAddActiveContentToContext, selectedChain]);
// Note: pendingMessages loading has been removed as ChatManager now handles
// message persistence and loading automatically based on project context
@ -784,6 +813,9 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
setContextNotes={setContextNotes}
includeActiveNote={includeActiveNote}
setIncludeActiveNote={setIncludeActiveNote}
includeActiveWebTab={includeActiveWebTab}
setIncludeActiveWebTab={setIncludeActiveWebTab}
activeWebTab={currentActiveWebTab}
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
setSelectedImages={setSelectedImages}

View file

@ -0,0 +1,226 @@
/**
* RTL tests for AtMentionTypeahead component
*
* Tests keyboard navigation skipping disabled options and preventing selection of disabled items.
*/
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { AtMentionTypeahead } from "./AtMentionTypeahead";
// Mock scrollIntoView for jsdom
Element.prototype.scrollIntoView = jest.fn();
// Mock hooks
jest.mock("./hooks/useAtMentionCategories", () => ({
useAtMentionCategories: jest.fn(() => [
{ key: "notes", title: "Notes", subtitle: "Reference notes", category: "notes" },
]),
CATEGORY_OPTIONS: [
{ key: "notes", title: "Notes", subtitle: "Reference notes", category: "notes" },
],
}));
jest.mock("./hooks/useAtMentionSearch", () => ({
useAtMentionSearch: jest.fn(),
}));
jest.mock("obsidian", () => ({
TFile: class {},
Platform: { isDesktopApp: true },
}));
// Import after mocking
import { useAtMentionSearch } from "./hooks/useAtMentionSearch";
const mockUseAtMentionSearch = useAtMentionSearch as jest.Mock;
describe("AtMentionTypeahead", () => {
const defaultProps = {
isOpen: true,
onClose: jest.fn(),
onSelect: jest.fn(),
isCopilotPlus: false,
currentActiveFile: null,
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Disabled Option Handling", () => {
it("should skip disabled options when pressing ArrowDown", () => {
// Setup: 3 options where middle one is disabled
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Option 1", category: "notes", data: "file1", disabled: false },
{ key: "2", title: "Option 2 (disabled)", category: "notes", data: "file2", disabled: true },
{ key: "3", title: "Option 3", category: "notes", data: "file3", disabled: false },
]);
render(<AtMentionTypeahead {...defaultProps} />);
// Get the search input (which handles keyboard events)
const searchInput = screen.getByRole("textbox");
// Press ArrowDown from first option (index 0)
// Should skip disabled option at index 1 and land on index 2
fireEvent.keyDown(searchInput, { key: "ArrowDown" });
// The component should have moved to Option 3, skipping Option 2
// We verify by pressing Enter and checking what gets selected
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(defaultProps.onSelect).toHaveBeenCalledWith("notes", "file3");
});
it("should skip disabled options when pressing ArrowUp", () => {
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Option 1", category: "notes", data: "file1", disabled: false },
{ key: "2", title: "Option 2 (disabled)", category: "notes", data: "file2", disabled: true },
{ key: "3", title: "Option 3", category: "notes", data: "file3", disabled: false },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
// Navigate to last option first
fireEvent.keyDown(searchInput, { key: "ArrowDown" }); // skips to 2 (last valid)
// Now press ArrowUp - should skip disabled and go to first
fireEvent.keyDown(searchInput, { key: "ArrowUp" });
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(defaultProps.onSelect).toHaveBeenCalledWith("notes", "file1");
});
it("should not select disabled option when pressing Enter", () => {
// All options disabled except none - test Enter on disabled
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Disabled Option", category: "notes", data: "file1", disabled: true },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
// Try to select the disabled option
fireEvent.keyDown(searchInput, { key: "Enter" });
// onSelect should NOT have been called
expect(defaultProps.onSelect).not.toHaveBeenCalled();
});
it("should not select disabled option when pressing Tab", () => {
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Disabled Option", category: "notes", data: "file1", disabled: true },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
// Try to select with Tab
fireEvent.keyDown(searchInput, { key: "Tab" });
expect(defaultProps.onSelect).not.toHaveBeenCalled();
});
it("should stay at current position if no valid option found when navigating down", () => {
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Option 1", category: "notes", data: "file1", disabled: false },
{ key: "2", title: "Disabled 1", category: "notes", data: "file2", disabled: true },
{ key: "3", title: "Disabled 2", category: "notes", data: "file3", disabled: true },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
// Press ArrowDown - no valid options after index 0, should stay at 0
fireEvent.keyDown(searchInput, { key: "ArrowDown" });
fireEvent.keyDown(searchInput, { key: "Enter" });
// Should still select Option 1 since it couldn't move
expect(defaultProps.onSelect).toHaveBeenCalledWith("notes", "file1");
});
it("should stay at current position if no valid option found when navigating up", () => {
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Disabled 1", category: "notes", data: "file1", disabled: true },
{ key: "2", title: "Disabled 2", category: "notes", data: "file2", disabled: true },
{ key: "3", title: "Option 3", category: "notes", data: "file3", disabled: false },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
// Navigate to last valid option
fireEvent.keyDown(searchInput, { key: "ArrowDown" });
fireEvent.keyDown(searchInput, { key: "ArrowDown" });
// Press ArrowUp - no valid options before, should stay at current
fireEvent.keyDown(searchInput, { key: "ArrowUp" });
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(defaultProps.onSelect).toHaveBeenCalledWith("notes", "file3");
});
it("should not call onSelect for disabled option via handleSelect (defensive)", () => {
// This tests the guard we added in handleSelect
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Disabled", category: "notes", data: "file1", disabled: true },
{ key: "2", title: "Enabled", category: "notes", data: "file2", disabled: false },
]);
render(<AtMentionTypeahead {...defaultProps} />);
// Even if somehow a disabled option got through to handleSelect,
// the guard should prevent selection
// We test this by verifying keyboard Enter doesn't select disabled
const searchInput = screen.getByRole("textbox");
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(defaultProps.onSelect).not.toHaveBeenCalled();
});
});
describe("Basic Functionality", () => {
it("should render nothing when isOpen is false", () => {
mockUseAtMentionSearch.mockReturnValue([]);
const { container } = render(
<AtMentionTypeahead {...defaultProps} isOpen={false} />
);
expect(container.firstChild).toBeNull();
});
it("should call onClose when Escape is pressed", () => {
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Option", category: "notes", data: "file1" },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
fireEvent.keyDown(searchInput, { key: "Escape" });
expect(defaultProps.onClose).toHaveBeenCalled();
});
it("should select enabled option and close on Enter", () => {
mockUseAtMentionSearch.mockReturnValue([
{ key: "1", title: "Enabled Option", category: "notes", data: "file1", disabled: false },
]);
render(<AtMentionTypeahead {...defaultProps} />);
const searchInput = screen.getByRole("textbox");
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(defaultProps.onSelect).toHaveBeenCalledWith("notes", "file1");
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
});

View file

@ -57,6 +57,9 @@ export function AtMentionTypeahead({
// Handle selection
const handleSelect = useCallback(
(option: any) => {
// Guard: never select disabled options (defensive check for click events)
if (option?.disabled) return;
if (extendedState.mode === "category" && isCategoryOption(option) && !searchQuery) {
// Category was selected - switch to search mode for that category
setExtendedState((prev) => ({
@ -92,14 +95,30 @@ export function AtMentionTypeahead({
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, searchResults.length - 1);
let nextIndex = selectedIndex + 1;
// Skip disabled options
while (nextIndex < searchResults.length && searchResults[nextIndex]?.disabled) {
nextIndex++;
}
// If no valid option found, stay at current position
if (nextIndex >= searchResults.length) {
nextIndex = selectedIndex;
}
setSelectedIndex(nextIndex);
break;
}
case "ArrowUp": {
event.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0);
let prevIndex = selectedIndex - 1;
// Skip disabled options
while (prevIndex >= 0 && searchResults[prevIndex]?.disabled) {
prevIndex--;
}
// If no valid option found, stay at current position
if (prevIndex < 0) {
prevIndex = selectedIndex;
}
setSelectedIndex(prevIndex);
break;
}
@ -107,8 +126,13 @@ export function AtMentionTypeahead({
case "Enter":
case "Tab": {
event.preventDefault();
if (searchResults[selectedIndex]) {
handleSelect(searchResults[selectedIndex]);
const currentOption = searchResults[selectedIndex];
// Don't select disabled options
if (currentOption?.disabled) {
break;
}
if (currentOption) {
handleSelect(currentOption);
}
break;
}

View file

@ -1,29 +1,36 @@
import { AlertCircle, CheckCircle, CircleDashed, Loader2, X } from "lucide-react";
import { TFile } from "obsidian";
import { AlertCircle, CheckCircle, CircleDashed, FileText, Loader2, X } from "lucide-react";
import { Platform, TFile } from "obsidian";
import React, { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ContextNoteBadge,
ContextActiveNoteBadge,
ContextActiveWebTabBadge,
ContextWebTabBadge,
ContextUrlBadge,
ContextFolderBadge,
FaviconOrGlobe,
} from "@/components/chat-components/ContextBadges";
import { SelectedTextContext } from "@/types/message";
import { SelectedTextContext, WebTabContext, isWebSelectedTextContext } from "@/types/message";
import { ChainType } from "@/chainFactory";
import { Separator } from "@/components/ui/separator";
import { useChainType } from "@/aiParams";
import { useProjectContextStatus } from "@/hooks/useProjectContextStatus";
import { isPlusChain, openFileInWorkspace } from "@/utils";
import { getDomainFromUrl, isPlusChain, openFileInWorkspace } from "@/utils";
import { mergeWebTabContexts } from "@/utils/urlNormalization";
import { AtMentionTypeahead } from "./AtMentionTypeahead";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface ChatContextMenuProps {
includeActiveNote: boolean;
currentActiveFile: TFile | null;
includeActiveWebTab: boolean;
activeWebTab: WebTabContext | null;
contextNotes: TFile[];
contextUrls: string[];
contextFolders: string[];
contextWebTabs: WebTabContext[];
selectedTextContexts?: SelectedTextContext[];
onRemoveContext: (category: string, data: any) => void;
showProgressCard: () => void;
@ -38,6 +45,30 @@ function ContextSelection({
selectedText: SelectedTextContext;
onRemoveContext: (category: string, data: any) => void;
}) {
// Handle web selected text
if (isWebSelectedTextContext(selectedText)) {
const domain = getDomainFromUrl(selectedText.url);
return (
<Badge className="tw-items-center tw-py-0 tw-pl-2 tw-pr-0.5 tw-text-xs">
<div className="tw-flex tw-items-center tw-gap-1">
<FaviconOrGlobe faviconUrl={selectedText.faviconUrl} />
<span className="tw-max-w-40 tw-truncate">{selectedText.title || domain}</span>
<span className="tw-text-xs tw-text-faint">Selection</span>
</div>
<Button
variant="ghost2"
size="fit"
onClick={() => onRemoveContext("selectedText", selectedText.id)}
aria-label="Remove from context"
className="tw-text-muted"
>
<X className="tw-size-4" />
</Button>
</Badge>
);
}
// Handle note selected text (default)
const lineRange =
selectedText.startLine === selectedText.endLine
? `L${selectedText.startLine}`
@ -46,6 +77,7 @@ function ContextSelection({
return (
<Badge className="tw-items-center tw-py-0 tw-pl-2 tw-pr-0.5 tw-text-xs">
<div className="tw-flex tw-items-center tw-gap-1">
<FileText className="tw-size-3" />
<span className="tw-max-w-40 tw-truncate">{selectedText.noteTitle}</span>
<span className="tw-text-xs tw-text-faint">{lineRange}</span>
</div>
@ -65,9 +97,12 @@ function ContextSelection({
export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
includeActiveNote,
currentActiveFile,
includeActiveWebTab,
activeWebTab,
contextNotes,
contextUrls,
contextFolders,
contextWebTabs,
selectedTextContexts = [],
onRemoveContext,
showProgressCard,
@ -110,15 +145,28 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
}, [contextNotes]);
const uniqueUrls = React.useMemo(() => Array.from(new Set(contextUrls)), [contextUrls]);
const hasSelectedText = selectedTextContexts.length > 0;
const activeNoteVisible = includeActiveNote && !hasSelectedText && Boolean(currentActiveFile);
// Defensive dedupe for web tabs (by URL) using shared normalization policy
const uniqueWebTabs = React.useMemo(
() => mergeWebTabContexts(contextWebTabs),
[contextWebTabs]
);
// Any selection hides both active note and active web tab
const hasAnySelection = selectedTextContexts.length > 0;
const activeNoteVisible = includeActiveNote && !hasAnySelection && Boolean(currentActiveFile);
const activeWebTabVisible =
includeActiveWebTab && !hasAnySelection && Boolean(activeWebTab) && Platform.isDesktopApp;
const hasContext =
uniqueNotes.length > 0 ||
uniqueUrls.length > 0 ||
selectedTextContexts.length > 0 ||
contextFolders.length > 0 ||
activeNoteVisible;
uniqueWebTabs.length > 0 ||
activeNoteVisible ||
activeWebTabVisible;
// Get contextStatus from the shared hook
const getContextStatusIcon = () => {
@ -168,6 +216,12 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
onClick={() => handleBadgeClick(currentActiveFile)}
/>
)}
{activeWebTabVisible && activeWebTab && (
<ContextActiveWebTabBadge
activeWebTab={activeWebTab}
onRemove={() => onRemoveContext("activeWebTab", "")}
/>
)}
{uniqueNotes.map((note) => (
<ContextNoteBadge
key={note.path}
@ -186,6 +240,13 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
onRemove={() => onRemoveContext("folders", folder)}
/>
))}
{uniqueWebTabs.map((webTab) => (
<ContextWebTabBadge
key={webTab.url}
webTab={webTab}
onRemove={() => onRemoveContext("webTabs", webTab.url)}
/>
))}
{selectedTextContexts.map((selectedText) => (
<ContextSelection
key={selectedText.id}

View file

@ -1,5 +1,44 @@
import { describe, it, expect } from "@jest/globals";
/**
* ChatInput Edit Mode Tests
*
* Verifies that ContextControl is hidden when editMode is true.
* This is a design decision: editing only changes text, not context.
*/
describe("ChatInput Edit Mode Behavior", () => {
describe("ContextControl visibility", () => {
it("should hide ContextControl when editMode is true (design verification)", () => {
// This test documents the expected behavior:
// When editMode=true, the ContextControl component should NOT be rendered.
//
// Implementation in ChatInput.tsx:
// {!editMode && (
// <ContextControl ... />
// )}
//
// This is intentional because:
// 1. Edit mode only changes the message text, not the context
// 2. The original context is preserved from when the message was first sent
// 3. Users cannot add/remove context items during edit
//
// If this behavior needs to change (e.g., show read-only context badges),
// update both the implementation and this test.
const editModeHidesContextControl = true; // Current implementation
expect(editModeHidesContextControl).toBe(true);
});
it("should show ContextControl when editMode is false or undefined", () => {
// When not in edit mode, ContextControl should be visible
// This allows users to add/remove context items before sending
const normalModeShowsContextControl = true; // Current implementation
expect(normalModeShowsContextControl).toBe(true);
});
});
});
// Helper function to simulate the slash detection and replacement logic
function detectSlashCommand(
inputValue: string,

View file

@ -13,13 +13,18 @@ import { ModelSelector } from "@/components/ui/ModelSelector";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ChatToolControls } from "./ChatToolControls";
import { isPlusChain } from "@/utils";
import {
mergeWebTabContexts,
normalizeUrlString,
normalizeWebTabContext,
} from "@/utils/urlNormalization";
import { useSettingsValue } from "@/settings/model";
import { SelectedTextContext } from "@/types/message";
import { SelectedTextContext, WebTabContext } from "@/types/message";
import { isAllowedFileForNoteContext } from "@/utils";
import { CornerDownLeft, Image, Loader2, StopCircle, X } from "lucide-react";
import { App, Notice, TFile } from "obsidian";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { $getSelection, $isRangeSelection } from "lexical";
import { ContextControl } from "./ContextControl";
import { $removePillsByPath } from "./pills/NotePillNode";
@ -27,6 +32,8 @@ import { $removeActiveNotePills } from "./pills/ActiveNotePillNode";
import { $removePillsByURL } from "./pills/URLPillNode";
import { $removePillsByFolder } from "./pills/FolderPillNode";
import { $removePillsByToolName, $createToolPillNode } from "./pills/ToolPillNode";
import { $removeActiveWebTabPills } from "./pills/ActiveWebTabPillNode";
import { $findWebTabPills, $removeWebTabPillsByUrl } from "./pills/WebTabPillNode";
import LexicalEditor from "./LexicalEditor";
interface ChatInputProps {
@ -37,6 +44,7 @@ interface ChatInputProps {
urls?: string[];
contextNotes?: TFile[];
contextFolders?: string[];
webTabs?: WebTabContext[];
}) => void;
isGenerating: boolean;
onStopGenerating: () => void;
@ -45,6 +53,9 @@ interface ChatInputProps {
setContextNotes: React.Dispatch<React.SetStateAction<TFile[]>>;
includeActiveNote: boolean;
setIncludeActiveNote: (include: boolean) => void;
includeActiveWebTab: boolean;
setIncludeActiveWebTab: (include: boolean) => void;
activeWebTab: WebTabContext | null;
selectedImages: File[];
onAddImage: (files: File[]) => void;
setSelectedImages: React.Dispatch<React.SetStateAction<File[]>>;
@ -82,6 +93,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
setContextNotes,
includeActiveNote,
setIncludeActiveNote,
includeActiveWebTab,
setIncludeActiveWebTab,
activeWebTab,
selectedImages,
onAddImage,
setSelectedImages,
@ -96,6 +110,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
}) => {
const [contextUrls, setContextUrls] = useState<string[]>(initialContext?.urls || []);
const [contextFolders, setContextFolders] = useState<string[]>(initialContext?.folders || []);
const [contextWebTabs, setContextWebTabs] = useState<WebTabContext[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const lexicalEditorRef = useRef<any>(null);
const [currentModelKey, setCurrentModelKey] = useModelKey();
@ -111,8 +126,35 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [urlsFromPills, setUrlsFromPills] = useState<string[]>([]);
const [foldersFromPills, setFoldersFromPills] = useState<string[]>([]);
const [toolsFromPills, setToolsFromPills] = useState<string[]>([]);
const [webTabsFromPills, setWebTabsFromPills] = useState<WebTabContext[]>([]);
const isCopilotPlus = isPlusChain(currentChain);
// Merge badge-only contextWebTabs with pills-derived webTabsFromPills for display
// Uses shared normalization policy from urlNormalization.ts
const mergedContextWebTabs = useMemo(() => {
return mergeWebTabContexts([...contextWebTabs, ...webTabsFromPills]);
}, [contextWebTabs, webTabsFromPills]);
/**
* Extract WebTabPillNode data directly from the Lexical editor at send time.
* This avoids React state synchronization races (webTabsFromPills) when the user sends quickly.
*/
const getWebTabsFromEditorSnapshot = (): WebTabContext[] => {
const editor = lexicalEditorRef.current;
if (!editor) {
return webTabsFromPills;
}
return editor.read(() => {
const pills = $findWebTabPills();
return pills.map((pill) => ({
url: pill.getURL(),
title: pill.getTitle(),
faviconUrl: pill.getFaviconUrl(),
}));
});
};
// Toggle states for vault, web search, composer, and autonomous agent
const [vaultToggle, setVaultToggle] = useState(false);
const [webToggle, setWebToggle] = useState(false);
@ -186,8 +228,20 @@ const ChatInput: React.FC<ChatInputProps> = ({
return;
}
// Combine badge-only web tabs with the send-time Lexical snapshot of WebTab pills.
// This avoids React state synchronization races when the user sends quickly.
// Active Web Tab is handled by ChatManager.
const webTabsFromEditor = getWebTabsFromEditorSnapshot();
const allWebTabs = mergeWebTabContexts([...contextWebTabs, ...webTabsFromEditor]);
if (!isCopilotPlus) {
handleSendMessage();
// Non-Plus chains: only webTabs needs explicit passing
// - contextNotes: Chat.tsx has state, closure can access
// - contextFolders: {folderPath} in text gets expanded by processPrompt()
// - webTabs: passed here, Active Web Tab injected by ChatManager
handleSendMessage({
webTabs: allWebTabs,
});
return;
}
@ -215,6 +269,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
contextNotes,
urls: contextUrls,
contextFolders,
webTabs: allWebTabs,
});
};
@ -370,6 +425,38 @@ const ChatInput: React.FC<ChatInputProps> = ({
});
}
break;
case "webTabs":
// Badge-only behavior (like notes): add to contextWebTabs state, no pill insertion
if (data && typeof data.url === "string") {
const normalized = normalizeWebTabContext(data as WebTabContext);
if (!normalized) break;
// If selecting the active web tab, toggle the active badge instead
const activeUrl = normalizeUrlString(activeWebTab?.url);
if (activeUrl && normalized.url === activeUrl) {
setIncludeActiveWebTab(true);
setContextWebTabs((prev) =>
prev.filter((t) => normalizeUrlString(t.url) !== activeUrl)
);
break;
}
setContextWebTabs((prev) => mergeWebTabContexts([...prev, normalized]));
}
break;
case "activeWebTab":
// Badge-only behavior (like activeNote): toggle include flag, no pill insertion
setIncludeActiveWebTab(true);
// Remove from contextWebTabs if it was added as a regular tab
{
const activeUrl = normalizeUrlString(activeWebTab?.url);
if (activeUrl) {
setContextWebTabs((prev) =>
prev.filter((t) => normalizeUrlString(t.url) !== activeUrl)
);
}
}
break;
}
};
@ -418,6 +505,32 @@ const ChatInput: React.FC<ChatInputProps> = ({
onRemoveSelectedText?.(data);
}
break;
case "activeWebTab":
// Remove active web tab pill from editor and turn off flag
setIncludeActiveWebTab(false);
if (lexicalEditorRef.current) {
lexicalEditorRef.current.update(() => {
$removeActiveWebTabPills();
});
}
break;
case "webTabs":
// Remove web tab from contextWebTabs state
if (typeof data === "string") {
const url = normalizeUrlString(data);
if (!url) break;
setContextWebTabs((prev) => prev.filter((t) => normalizeUrlString(t.url) !== url));
// Also immediately update pills-derived state to avoid UI re-adding during sync lag
setWebTabsFromPills((prev) => prev.filter((t) => normalizeUrlString(t.url) !== url));
// Also remove any corresponding pills from editor (if any exist)
if (lexicalEditorRef.current) {
lexicalEditorRef.current.update(() => {
$removeWebTabPillsByUrl(url);
});
}
}
break;
}
};
@ -578,6 +691,16 @@ const ChatInput: React.FC<ChatInputProps> = ({
setIncludeActiveNote(false);
}, [setIncludeActiveNote]);
// Active web tab pill sync callbacks (mirror activeNote behavior)
// Active Web Tab URL resolution is now handled by ChatManager at send time
const handleActiveWebTabAdded = useCallback(() => {
setIncludeActiveWebTab(true);
}, [setIncludeActiveWebTab]);
const handleActiveWebTabRemoved = useCallback(() => {
setIncludeActiveWebTab(false);
}, [setIncludeActiveWebTab]);
// Handle tag selection from typeahead - auto-enable vault search
const handleTagSelected = useCallback(() => {
if (isCopilotPlus && !autonomousAgentToggle && !vaultToggle) {
@ -591,18 +714,24 @@ const ChatInput: React.FC<ChatInputProps> = ({
className="tw-flex tw-w-full tw-flex-col tw-gap-0.5 tw-rounded-md tw-border tw-border-solid tw-border-border tw-px-1 tw-pb-1 tw-pt-2 tw-@container/chat-input"
ref={containerRef}
>
<ContextControl
contextNotes={contextNotes}
includeActiveNote={includeActiveNote}
activeNote={currentActiveNote}
contextUrls={contextUrls}
contextFolders={contextFolders}
selectedTextContexts={selectedTextContexts}
showProgressCard={showProgressCard}
lexicalEditorRef={lexicalEditorRef}
onAddToContext={handleAddToContext}
onRemoveFromContext={handleRemoveFromContext}
/>
{/* Hide context controls in edit mode - editing only changes text, not context */}
{!editMode && (
<ContextControl
contextNotes={contextNotes}
includeActiveNote={includeActiveNote}
activeNote={currentActiveNote}
includeActiveWebTab={includeActiveWebTab}
activeWebTab={activeWebTab}
contextUrls={contextUrls}
contextFolders={contextFolders}
contextWebTabs={mergedContextWebTabs}
selectedTextContexts={selectedTextContexts}
showProgressCard={showProgressCard}
lexicalEditorRef={lexicalEditorRef}
onAddToContext={handleAddToContext}
onRemoveFromContext={handleRemoveFromContext}
/>
)}
{selectedImages.length > 0 && (
<div className="selected-images">
@ -648,6 +777,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
onToolsRemoved={isCopilotPlus ? handleToolPillsRemoved : undefined}
onFoldersChange={setFoldersFromPills}
onFoldersRemoved={handleFolderPillsRemoved}
onWebTabsChange={setWebTabsFromPills}
onActiveWebTabAdded={handleActiveWebTabAdded}
onActiveWebTabRemoved={handleActiveWebTabRemoved}
onEditorReady={onEditorReady}
onImagePaste={onAddImage}
onTagSelected={handleTagSelected}

View file

@ -7,6 +7,7 @@ import {
ContextSelectedTextBadge,
ContextTagBadge,
ContextUrlBadge,
ContextWebTabBadge,
} from "@/components/chat-components/ContextBadges";
import { InlineMessageEditor } from "@/components/chat-components/InlineMessageEditor";
import { TokenLimitWarning } from "@/components/chat-components/TokenLimitWarning";
@ -30,7 +31,7 @@ import { cn } from "@/lib/utils";
import { parseToolCallMarkers } from "@/LLMProviders/chainRunner/utils/toolCallParser";
import { processInlineCitations } from "@/LLMProviders/chainRunner/utils/citationUtils";
import { ChatMessage } from "@/types/message";
import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils";
import { cleanMessageForCopy, extractYoutubeVideoId, findCustomModel, insertIntoEditor } from "@/utils";
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useModelKey } from "@/aiParams";
@ -78,6 +79,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
!context ||
(!context.notes?.length &&
!context.urls?.length &&
!context.webTabs?.length &&
!context.tags?.length &&
!context.folders?.length &&
!context.selectedTextContexts?.length)
@ -107,6 +109,25 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
<TooltipContent className="tw-max-w-sm tw-break-words">{url}</TooltipContent>
</Tooltip>
))}
{context.webTabs?.map((webTab, index) => (
<Tooltip key={`webTab-${index}-${webTab.url}`}>
<TooltipTrigger asChild>
<div>
<ContextWebTabBadge webTab={webTab} />
</div>
</TooltipTrigger>
<TooltipContent className="tw-max-w-sm tw-break-words">
{webTab.title ? (
<div className="tw-text-left">
<div className="tw-font-medium">{webTab.title}</div>
<div>{webTab.url}</div>
</div>
) : (
webTab.url
)}
</TooltipContent>
</Tooltip>
))}
{context.tags?.map((tag, index) => (
<Tooltip key={`tag-${index}-${tag}`}>
<TooltipTrigger asChild>
@ -135,7 +156,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
</div>
</TooltipTrigger>
<TooltipContent className="tw-max-w-sm tw-break-words">
{selectedText.notePath}
{selectedText.sourceType === "web" ? selectedText.url : selectedText.notePath}
</TooltipContent>
</Tooltip>
))}
@ -392,7 +413,34 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
`<a href="obsidian://open?file=${encodeURIComponent(file.path)}">${file.basename}</a>`
);
return noteLinksProcessed;
/**
* Converts YouTube video embeds to static thumbnails during streaming.
* This prevents iframe flickering caused by repeated DOM recreation.
* After streaming ends, the original embed syntax is preserved for full video display.
*/
const processYouTubeEmbed = (content: string): string => {
if (!isStreaming) {
// After streaming: keep original syntax for full video embed
return content;
}
// Match ![title](url) format and check if URL is YouTube
const imageEmbedRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
return content.replace(imageEmbedRegex, (match, title, url) => {
const videoId = extractYoutubeVideoId(url);
if (!videoId) {
// Not a YouTube URL, keep original
return match;
}
// During streaming: convert to clickable thumbnail to avoid iframe reload flicker
const displayTitle = title || "YouTube Video";
const thumbnail = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
return `[![${displayTitle}](${thumbnail})](${url})`;
});
};
return processYouTubeEmbed(noteLinksProcessed);
},
[app, isStreaming, shouldProcessThinkBlocks, settings.enableInlineCitations]
);

View file

@ -1,10 +1,16 @@
import React from "react";
import { ExternalLink, FileText, Folder, Hash, X } from "lucide-react";
import { ExternalLink, FileText, Folder, Globe, Hash, X, CircleDashed } from "lucide-react";
import { TFile } from "obsidian";
import { Button } from "@/components/ui/button";
import { TruncatedText } from "@/components/TruncatedText";
import { getDomainFromUrl } from "@/utils";
import { cn } from "@/lib/utils";
import { ContextBadgeWrapper } from "./ContextBadgeWrapper";
import { SelectedTextContext } from "@/types/message";
import {
SelectedTextContext,
WebTabContext,
isWebSelectedTextContext,
} from "@/types/message";
interface BaseContextBadgeProps {
onRemove?: () => void;
@ -19,6 +25,10 @@ interface ContextUrlBadgeProps extends BaseContextBadgeProps {
url: string;
}
interface ContextWebTabBadgeProps extends BaseContextBadgeProps {
webTab: WebTabContext;
}
interface ContextTagBadgeProps extends BaseContextBadgeProps {
tag: string;
}
@ -35,6 +45,45 @@ interface ContextActiveNoteBadgeProps extends BaseContextBadgeProps {
currentActiveFile: TFile | null;
}
/**
* Shared favicon renderer component for web tab badges.
* Shows favicon image if available, falls back to Globe icon.
* Handles image load errors gracefully.
*/
interface FaviconOrGlobeProps {
faviconUrl?: string;
isLoaded?: boolean;
className?: string;
}
export function FaviconOrGlobe({ faviconUrl, isLoaded = true, className = "tw-size-3" }: FaviconOrGlobeProps) {
const [showFavicon, setShowFavicon] = React.useState<boolean>(Boolean(faviconUrl));
React.useEffect(() => {
setShowFavicon(Boolean(faviconUrl));
}, [faviconUrl]);
if (!isLoaded) {
return <CircleDashed className={cn(className, "tw-text-muted")} />;
}
if (showFavicon && faviconUrl) {
return (
<img
src={faviconUrl}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
className={cn(className, "tw-rounded-sm")}
onError={() => setShowFavicon(false)}
/>
);
}
return <Globe className={className} />;
}
export function ContextActiveNoteBadge({
currentActiveFile,
onRemove,
@ -77,6 +126,50 @@ export function ContextActiveNoteBadge({
);
}
interface ContextActiveWebTabBadgeProps extends BaseContextBadgeProps {
activeWebTab: WebTabContext | null;
}
export function ContextActiveWebTabBadge({
activeWebTab,
onRemove,
onClick,
}: ContextActiveWebTabBadgeProps) {
if (!activeWebTab) {
return null;
}
const domain = getDomainFromUrl(activeWebTab.url);
const displayText = activeWebTab.title || domain || activeWebTab.url || "Untitled";
const tooltipContent = <div className="tw-text-left">{activeWebTab.url}</div>;
return (
<ContextBadgeWrapper hasRemoveButton={!!onRemove} isClickable={!!onClick} onClick={onClick}>
<div className="tw-flex tw-items-center tw-gap-1">
<FaviconOrGlobe faviconUrl={activeWebTab.faviconUrl} />
<TruncatedText className="tw-max-w-40" tooltipContent={tooltipContent} alwaysShowTooltip>
{displayText}
</TruncatedText>
<span className="tw-text-xs tw-text-faint">Current</span>
</div>
{onRemove && (
<Button
variant="ghost2"
size="fit"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label="Remove from context"
className="tw-text-muted"
>
<X className="tw-size-4" />
</Button>
)}
</ContextBadgeWrapper>
);
}
export function ContextNoteBadge({ note, onRemove, onClick }: ContextNoteBadgeProps) {
const tooltipContent = <div className="tw-text-left">{note.path}</div>;
const isPdf = note.extension === "pdf";
@ -111,22 +204,12 @@ export function ContextNoteBadge({ note, onRemove, onClick }: ContextNoteBadgePr
}
export function ContextUrlBadge({ url, onRemove }: ContextUrlBadgeProps) {
// Extract domain from URL for display
const getDomain = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return url;
}
};
return (
<ContextBadgeWrapper hasRemoveButton={!!onRemove}>
<div className="tw-flex tw-items-center tw-gap-1">
<ExternalLink className="tw-size-3" />
<TruncatedText className="tw-max-w-40" tooltipContent={url}>
{getDomain(url)}
{getDomainFromUrl(url)}
</TruncatedText>
</div>
{onRemove && (
@ -144,6 +227,52 @@ export function ContextUrlBadge({ url, onRemove }: ContextUrlBadgeProps) {
);
}
/**
* Renders a context badge for a Web Viewer tab.
* Shows favicon (if available) or Globe icon, with title or domain as display text.
* Displays a special "unloaded" state for tabs that haven't loaded their content yet.
*/
export function ContextWebTabBadge({ webTab, onRemove, onClick }: ContextWebTabBadgeProps) {
const isLoaded = webTab.isLoaded !== false;
const domain = getDomainFromUrl(webTab.url);
const displayText = webTab.title || domain || webTab.url || "Untitled";
const tooltipText = isLoaded ? webTab.url : "Tab not loaded - switch to this tab to load content";
return (
<ContextBadgeWrapper
hasRemoveButton={!!onRemove}
isClickable={!!onClick}
onClick={onClick}
className={cn(!isLoaded && "tw-opacity-60")}
>
<div className="tw-flex tw-items-center tw-gap-1">
<FaviconOrGlobe faviconUrl={webTab.faviconUrl} isLoaded={isLoaded} />
<TruncatedText
className={cn("tw-max-w-40", !isLoaded && "tw-italic")}
tooltipContent={tooltipText}
>
{displayText}
</TruncatedText>
{!isLoaded && <span className="tw-text-xs tw-text-muted">(not loaded)</span>}
</div>
{onRemove && (
<Button
variant="ghost2"
size="fit"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label="Remove from context"
className="tw-text-muted"
>
<X className="tw-size-4" />
</Button>
)}
</ContextBadgeWrapper>
);
}
export function ContextTagBadge({ tag, onRemove }: ContextTagBadgeProps) {
// Remove # symbol for clean display
const displayTag = tag.startsWith("#") ? tag.slice(1) : tag;
@ -199,6 +328,40 @@ export function ContextSelectedTextBadge({
selectedText,
onRemove,
}: ContextSelectedTextBadgeProps) {
// Handle web selected text
if (isWebSelectedTextContext(selectedText)) {
const domain = getDomainFromUrl(selectedText.url);
const tooltipContent = (
<div className="tw-text-left">
{selectedText.url}
</div>
);
return (
<ContextBadgeWrapper hasRemoveButton={!!onRemove}>
<div className="tw-flex tw-items-center tw-gap-1">
<FaviconOrGlobe faviconUrl={selectedText.faviconUrl} />
<TruncatedText className="tw-max-w-40" tooltipContent={tooltipContent} alwaysShowTooltip>
{selectedText.title || domain}
</TruncatedText>
<span className="tw-text-xs tw-text-faint">Selection</span>
</div>
{onRemove && (
<Button
variant="ghost2"
size="fit"
onClick={onRemove}
aria-label="Remove from context"
className="tw-text-muted"
>
<X className="tw-size-4" />
</Button>
)}
</ContextBadgeWrapper>
);
}
// Handle note selected text (default)
const lineRange =
selectedText.startLine === selectedText.endLine
? `L${selectedText.startLine}`

View file

@ -1,6 +1,6 @@
import React from "react";
import { SelectedTextContext } from "@/types/message";
import { SelectedTextContext, WebTabContext } from "@/types/message";
import { TFile } from "obsidian";
import { ChatContextMenu } from "./ChatContextMenu";
@ -8,8 +8,11 @@ interface ChatControlsProps {
contextNotes: TFile[];
includeActiveNote: boolean;
activeNote: TFile | null;
includeActiveWebTab: boolean;
activeWebTab: WebTabContext | null;
contextUrls: string[];
contextFolders: string[];
contextWebTabs: WebTabContext[];
selectedTextContexts?: SelectedTextContext[];
showProgressCard: () => void;
lexicalEditorRef?: React.RefObject<any>;
@ -23,8 +26,11 @@ export const ContextControl: React.FC<ChatControlsProps> = ({
contextNotes,
includeActiveNote,
activeNote,
includeActiveWebTab,
activeWebTab,
contextUrls,
contextFolders,
contextWebTabs,
selectedTextContexts,
showProgressCard,
lexicalEditorRef,
@ -47,10 +53,13 @@ export const ContextControl: React.FC<ChatControlsProps> = ({
<ChatContextMenu
includeActiveNote={includeActiveNote}
currentActiveFile={activeNote}
includeActiveWebTab={includeActiveWebTab}
activeWebTab={activeWebTab}
contextNotes={contextNotes}
onRemoveContext={handleRemoveContext}
contextUrls={contextUrls}
contextFolders={contextFolders}
contextWebTabs={contextWebTabs}
selectedTextContexts={selectedTextContexts}
showProgressCard={showProgressCard}
onTypeaheadSelect={handleTypeaheadSelect}

View file

@ -2,6 +2,7 @@ import React, { useState, useCallback } from "react";
import { TFile, App } from "obsidian";
import ChatInput from "./ChatInput";
import { ChatMessage } from "@/types/message";
import { useActiveWebTabState } from "./hooks/useActiveWebTabState";
interface InlineMessageEditorProps {
/** The initial message text to edit */
@ -34,7 +35,9 @@ export const InlineMessageEditor: React.FC<InlineMessageEditorProps> = ({
initialContext?.notes?.map((note) => note as TFile) || []
);
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
const [selectedImages, setSelectedImages] = useState<File[]>([]);
const { activeWebTabForMentions: currentActiveWebTab } = useActiveWebTabState();
// Handle saving the edited message
const handleEditSave = useCallback(
@ -107,6 +110,9 @@ export const InlineMessageEditor: React.FC<InlineMessageEditorProps> = ({
setContextNotes={setContextNotes}
includeActiveNote={includeActiveNote}
setIncludeActiveNote={setIncludeActiveNote}
includeActiveWebTab={includeActiveWebTab}
setIncludeActiveWebTab={setIncludeActiveWebTab}
activeWebTab={currentActiveWebTab}
selectedImages={selectedImages}
onAddImage={handleAddImage}
setSelectedImages={setSelectedImages}

View file

@ -7,6 +7,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { TFile } from "obsidian";
import type { WebTabContext } from "@/types/message";
import { SlashCommandPlugin } from "./plugins/SlashCommandPlugin";
import { NoteCommandPlugin } from "./plugins/NoteCommandPlugin";
import { TagCommandPlugin } from "./plugins/TagCommandPlugin";
@ -16,6 +17,8 @@ import { URLPillNode } from "./pills/URLPillNode";
import { ToolPillNode } from "./pills/ToolPillNode";
import { FolderPillNode } from "./pills/FolderPillNode";
import { ActiveNotePillNode } from "./pills/ActiveNotePillNode";
import { WebTabPillNode } from "./pills/WebTabPillNode";
import { ActiveWebTabPillNode } from "./pills/ActiveWebTabPillNode";
import { PillDeletionPlugin } from "./plugins/PillDeletionPlugin";
import { KeyboardPlugin } from "./plugins/KeyboardPlugin";
import { ValueSyncPlugin } from "./plugins/ValueSyncPlugin";
@ -25,10 +28,12 @@ import { URLPillSyncPlugin } from "./plugins/URLPillSyncPlugin";
import { ToolPillSyncPlugin } from "./plugins/ToolPillSyncPlugin";
import { FolderPillSyncPlugin } from "./plugins/FolderPillSyncPlugin";
import { ActiveNotePillSyncPlugin } from "./plugins/ActiveNotePillSyncPlugin";
import { WebTabPillSyncPlugin } from "./plugins/WebTabPillSyncPlugin";
import { PastePlugin } from "./plugins/PastePlugin";
import { TextInsertionPlugin } from "./plugins/TextInsertionPlugin";
import { useChatInput } from "@/context/ChatInputContext";
import { cn } from "@/lib/utils";
import { logError } from "@/logger";
import { ActiveFileProvider } from "./context/ActiveFileContext";
import { ChainType } from "@/chainFactory";
import { useSettingsValue } from "@/settings/model";
@ -50,6 +55,10 @@ interface LexicalEditorProps {
onFoldersRemoved?: (removedFolders: string[]) => void;
onActiveNoteAdded?: () => void;
onActiveNoteRemoved?: () => void;
onWebTabsChange?: (webTabs: WebTabContext[]) => void;
onWebTabsRemoved?: (removedWebTabs: WebTabContext[]) => void;
onActiveWebTabAdded?: () => void;
onActiveWebTabRemoved?: () => void;
onEditorReady?: (editor: any) => void;
onImagePaste?: (files: File[]) => void;
onTagSelected?: () => void;
@ -75,6 +84,10 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
onFoldersRemoved,
onActiveNoteAdded,
onActiveNoteRemoved,
onWebTabsChange,
onWebTabsRemoved,
onActiveWebTabAdded,
onActiveWebTabRemoved,
onEditorReady,
onImagePaste,
onTagSelected,
@ -117,10 +130,12 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
ActiveNotePillNode,
ToolPillNode,
FolderPillNode,
WebTabPillNode,
ActiveWebTabPillNode,
...(onURLsChange ? [URLPillNode] : []),
],
onError: (error: Error) => {
console.error("Lexical error:", error);
logError("Lexical error:", error);
},
editable: !disabled,
}),
@ -182,6 +197,12 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
onActiveNoteAdded={onActiveNoteAdded}
onActiveNoteRemoved={onActiveNoteRemoved}
/>
<WebTabPillSyncPlugin
onWebTabsChange={onWebTabsChange}
onWebTabsRemoved={onWebTabsRemoved}
onActiveWebTabAdded={onActiveWebTabAdded}
onActiveWebTabRemoved={onActiveWebTabRemoved}
/>
<PillDeletionPlugin />
<PastePlugin enableURLPills={!!onURLsChange} onImagePaste={onImagePaste} />
<SlashCommandPlugin />

View file

@ -9,6 +9,10 @@ export interface TypeaheadOption {
content?: string;
category?: string;
icon?: React.ReactNode;
/** Whether this option is disabled and cannot be selected */
disabled?: boolean;
/** Tooltip text explaining why this option is disabled */
disabledReason?: string;
}
interface TypeaheadMenuContentProps {
@ -129,7 +133,8 @@ export function TypeaheadMenuContent({
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const isHovered = index === hoveredIndex;
const shouldHighlight = isSelected || isHovered;
const isDisabled = option.disabled ?? false;
const shouldHighlight = (isSelected || isHovered) && !isDisabled;
const isCategory =
mode === "category" && !query && option.icon && !("data" in option);
@ -138,16 +143,22 @@ export function TypeaheadMenuContent({
key={option.key}
ref={isSelected ? selectedItemRef : undefined}
className={cn(
"tw-flex tw-cursor-pointer tw-items-center tw-rounded-md tw-px-3 tw-py-2 tw-text-sm tw-text-normal",
"tw-flex tw-items-center tw-rounded-md tw-px-3 tw-py-2 tw-text-sm",
isDisabled
? "tw-cursor-not-allowed tw-text-muted tw-opacity-50"
: "tw-cursor-pointer tw-text-normal",
shouldHighlight && "tw-bg-modifier-hover"
)}
title={isDisabled ? option.disabledReason : undefined}
// Use onMouseDown instead of onClick to prevent triggering
// onblur events of the typeahead menu
onMouseDown={(e) => {
e.preventDefault();
e.preventDefault(); // Always prevent default to avoid losing focus
if (isDisabled) return;
onSelect(option);
}}
onMouseEnter={() => {
if (isDisabled) return;
setHoveredIndex(index);
onHighlight(index);
}}

View file

@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { Platform } from "obsidian";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import type { ActiveWebTabStateSnapshot } from "@/services/webViewerService/webViewerServiceTypes";
const EMPTY_ACTIVE_WEB_TAB_STATE: ActiveWebTabStateSnapshot = {
activeWebTabForMentions: null,
activeOrLastWebTab: null,
};
/**
* React hook for subscribing to the single-source Active Web Tab snapshot.
* This hook provides unified access to Active Web Tab state for all UI components.
*
* @returns ActiveWebTabStateSnapshot containing:
* - activeWebTabForMentions: For @mention search "Active Web Tab" option
* - activeOrLastWebTab: For pill display (active or last active tab)
*/
export function useActiveWebTabState(): ActiveWebTabStateSnapshot {
const [state, setState] = useState<ActiveWebTabStateSnapshot>(() => {
if (!Platform.isDesktopApp) {
return EMPTY_ACTIVE_WEB_TAB_STATE;
}
try {
return getWebViewerService(app).getActiveWebTabState();
} catch {
return EMPTY_ACTIVE_WEB_TAB_STATE;
}
});
useEffect(() => {
if (!Platform.isDesktopApp) {
setState(EMPTY_ACTIVE_WEB_TAB_STATE);
return;
}
let unsubscribe: (() => void) | undefined;
try {
const service = getWebViewerService(app);
// Get initial state
setState(service.getActiveWebTabState());
// Subscribe to updates
unsubscribe = service.subscribeActiveWebTabState(setState);
} catch {
setState(EMPTY_ACTIVE_WEB_TAB_STATE);
}
return () => {
unsubscribe?.();
};
}, []);
return state;
}

View file

@ -1,13 +1,14 @@
import React, { useMemo } from "react";
import { TFile, TFolder } from "obsidian";
import { FileText, Wrench, Folder } from "lucide-react";
import { Platform, TFile, TFolder } from "obsidian";
import { FileText, Wrench, Folder, Globe } from "lucide-react";
import { TypeaheadOption } from "../TypeaheadMenuContent";
import type { WebTabContext } from "@/types/message";
export type AtMentionCategory = "notes" | "tools" | "folders" | "activeNote";
export type AtMentionCategory = "notes" | "tools" | "folders" | "activeNote" | "webTabs" | "activeWebTab";
export interface AtMentionOption extends TypeaheadOption {
category: AtMentionCategory;
data: TFile | string | TFolder;
data: TFile | string | TFolder | WebTabContext;
}
export interface CategoryOption extends TypeaheadOption {
@ -23,6 +24,13 @@ export const CATEGORY_OPTIONS: CategoryOption[] = [
category: "notes",
icon: <FileText className="tw-size-4" />,
},
{
key: "webTabs",
title: "Web Tabs",
subtitle: "Reference open browser tabs",
category: "webTabs",
icon: <Globe className="tw-size-4" />,
},
{
key: "tools",
title: "Tools",
@ -42,17 +50,22 @@ export const CATEGORY_OPTIONS: CategoryOption[] = [
/**
* Hook that provides available @ mention categories based on Copilot Plus status.
* Returns the array of available category options directly.
* Web Tabs category is only available on desktop (Web Viewer not supported on mobile).
*
* @param isCopilotPlus - Whether Copilot Plus features are enabled
* @returns Array of CategoryOption objects
*/
export function useAtMentionCategories(isCopilotPlus: boolean = false): CategoryOption[] {
// Filter category options based on Copilot Plus status
return useMemo(() => {
return CATEGORY_OPTIONS.filter((cat) => {
// Tools require Copilot Plus
if (cat.category === "tools") {
return isCopilotPlus;
}
// Web Tabs only available on desktop (Web Viewer not supported on mobile)
if (cat.category === "webTabs") {
return Platform.isDesktopApp;
}
return true;
});
}, [isCopilotPlus]);

View file

@ -1,11 +1,13 @@
import React, { useMemo } from "react";
import { TFolder, TFile } from "obsidian";
import { FileText, Wrench, Folder, FileClock } from "lucide-react";
import { Platform, TFolder, TFile } from "obsidian";
import { FileText, Wrench, Folder, FileClock, Globe, CircleDashed } from "lucide-react";
import fuzzysort from "fuzzysort";
import { getToolDescription } from "@/tools/toolManager";
import { AVAILABLE_TOOLS } from "../constants/tools";
import { useAllNotes } from "./useAllNotes";
import { useAllFolders } from "./useAllFolders";
import { useOpenWebTabs } from "./useOpenWebTabs";
import { useActiveWebTabState } from "./useActiveWebTabState";
import { AtMentionCategory, AtMentionOption, CategoryOption } from "./useAtMentionCategories";
import { getSettings } from "@/settings/model";
@ -27,6 +29,18 @@ export function useAtMentionSearch(
const allNotes = useAllNotes(isCopilotPlus);
const allFolders = useAllFolders();
// Only enable web tab polling when actually needed:
// - In category mode with a search query (searching across all categories)
// - In search mode when webTabs category is selected
const shouldEnableWebTabPolling =
Platform.isDesktopApp &&
((mode === "category" && query.trim().length > 0) ||
(mode === "search" && selectedCategory === "webTabs"));
const openWebTabs = useOpenWebTabs({ enabled: shouldEnableWebTabPolling });
// Use the single-source-of-truth Active Web Tab state
const { activeWebTabForMentions: activeWebTab } = useActiveWebTabState();
// Create memoized item arrays (reused in both modes)
const noteItems: AtMentionOption[] = useMemo(
() =>
@ -74,6 +88,30 @@ export function useAtMentionSearch(
[allFolders]
);
const webTabItems: AtMentionOption[] = useMemo(
() =>
Platform.isDesktopApp
? openWebTabs.map((tab, index) => {
const isLoaded = tab.isLoaded !== false;
return {
key: `webtab-${tab.url || tab.title || index}-${index}`,
title: tab.title || "Untitled",
subtitle: isLoaded ? tab.url : "Tab not loaded",
category: "webTabs" as AtMentionCategory,
data: tab,
content: undefined,
disabled: !isLoaded,
disabledReason: "Switch to this tab to load it first",
icon: isLoaded
? React.createElement(Globe, { className: "tw-size-4" })
: React.createElement(CircleDashed, { className: "tw-size-4 tw-text-muted" }),
searchKeyword: `${tab.title || ""} ${tab.url || ""}`,
};
})
: [],
[openWebTabs]
);
return useMemo(() => {
if (mode === "category") {
// Show category options when no query
@ -83,9 +121,24 @@ export function useAtMentionSearch(
content: undefined,
})) as (CategoryOption | AtMentionOption)[];
// Add "Active Note" option at the top if there is an active file
const activeOptions: AtMentionOption[] = [];
// Add "Active Web Tab" option when the active leaf is Web Viewer (desktop-only)
if (activeWebTab) {
activeOptions.push({
key: "active-web-tab",
title: "Active Web Tab",
subtitle: undefined,
category: "activeWebTab" as AtMentionCategory,
data: activeWebTab,
content: undefined,
icon: React.createElement(Globe, { className: "tw-size-4" }),
});
}
// Add "Active Note" option if there is an active file
if (currentActiveFile) {
const activeNoteOption: AtMentionOption = {
activeOptions.push({
key: `active-note-${currentActiveFile.path}`,
title: "Active Note",
subtitle: undefined,
@ -93,12 +146,12 @@ export function useAtMentionSearch(
data: currentActiveFile,
content: undefined,
icon: React.createElement(FileClock, { className: "tw-size-4" }),
};
return [activeNoteOption, ...categoryOptions];
});
}
return categoryOptions;
return activeOptions.length > 0
? [...activeOptions, ...categoryOptions]
: categoryOptions;
}
// Search across all categories when query exists
@ -124,8 +177,24 @@ export function useAtMentionSearch(
}
: null;
// Check if "active web tab" contains the query as a substring (case-insensitive)
const activeWebTabTitle = "active web tab";
const activeWebTabMatches = activeWebTabTitle.includes(queryLower);
const activeWebTabOption =
activeWebTabMatches && activeWebTab
? {
key: "active-web-tab",
title: "Active Web Tab",
subtitle: undefined,
category: "activeWebTab" as AtMentionCategory,
data: activeWebTab,
content: undefined,
icon: React.createElement(Globe, { className: "tw-size-4" }),
}
: null;
// Combine all non-tool items for unified fuzzy search
const allNonToolItems = [...noteItems, ...folderItems];
const allNonToolItems = [...noteItems, ...folderItems, ...webTabItems];
const fuzzySearchResults = fuzzysort.go(query, allNonToolItems, {
keys: ["searchKeyword"],
limit: MAX_SEARCH_RESULTS,
@ -134,9 +203,10 @@ export function useAtMentionSearch(
const rankedNonToolItems = fuzzySearchResults.map((result) => result.obj);
// Tools first, then Active Note (if matches), then everything else ranked by fuzzy search
// Tools first, then Active Web Tab / Active Note (if matches), then everything else
return [
...matchingTools,
...(activeWebTabOption ? [activeWebTabOption] : []),
...(activeNoteOption ? [activeNoteOption] : []),
...rankedNonToolItems,
].slice(0, MAX_SEARCH_RESULTS);
@ -154,6 +224,9 @@ export function useAtMentionSearch(
case "folders":
items = folderItems;
break;
case "webTabs":
items = webTabItems;
break;
}
// Apply fuzzy search for all categories if there's a query
@ -197,7 +270,9 @@ export function useAtMentionSearch(
noteItems,
toolItems,
folderItems,
webTabItems,
availableCategoryOptions,
activeWebTab,
currentActiveFile,
]);
}

View file

@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "obsidian";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import type { WebTabContext } from "@/types/message";
/**
* Fallback poll interval for refreshing Web Viewer tab metadata (title/url/favicon).
* Primary updates come from workspace events and webview load events.
* Polling is only needed to catch rare title changes after initial load.
*/
const WEB_TAB_FALLBACK_POLL_INTERVAL_MS = 6_000;
/**
* Options for useOpenWebTabs hook.
*/
export interface UseOpenWebTabsOptions {
/**
* Whether polling/subscriptions are enabled (default: true).
* When false, the hook returns an empty array and does not start polling.
*/
enabled?: boolean;
}
/**
* Create a stable, sorted snapshot of open Web Viewer tabs.
* Sorting ensures stable output ordering for equality checks.
* Includes tabs with title but no URL (not yet loaded) with isLoaded=false.
*/
function getOpenWebTabSnapshot(): WebTabContext[] {
try {
const service = getWebViewerService(app);
const leaves = service.getLeaves();
const tabs: WebTabContext[] = [];
for (const leaf of leaves) {
const info = service.getPageInfo(leaf);
// Access webview state to determine if tab is fully loaded
const view = leaf.view as {
webviewMounted?: boolean;
webviewFirstLoadFinished?: boolean;
};
const hasUrl = Boolean(info.url?.trim());
const hasTitle = Boolean(info.title?.trim());
// Skip tabs that have neither URL nor title (completely empty)
if (!hasUrl && !hasTitle) {
continue;
}
// Determine if tab content is loaded
// Note: webviewMounted/webviewFirstLoadFinished are internal Obsidian fields
// If they don't exist, assume loaded (fallback to old behavior)
const webviewReady =
view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined
? true // Fallback: assume ready if fields don't exist
: Boolean(view.webviewMounted && view.webviewFirstLoadFinished);
const isLoaded = hasUrl && webviewReady;
tabs.push({
url: info.url || "",
title: info.title || undefined,
faviconUrl: info.faviconUrl || undefined,
isLoaded,
});
}
// Sort: loaded tabs first (by URL), then unloaded tabs (by title)
tabs.sort((a, b) => {
// Loaded tabs come before unloaded
if (a.isLoaded !== b.isLoaded) {
return a.isLoaded ? -1 : 1;
}
// Within same load state, sort by URL or title
const aKey = a.url || a.title || "";
const bKey = b.url || b.title || "";
return aKey.localeCompare(bKey);
});
return tabs;
} catch {
return [];
}
}
/**
* Compare two WebTabContext arrays for deep equality.
*/
function areWebTabSnapshotsEqual(a: WebTabContext[], b: WebTabContext[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const ai = a[i];
const bi = b[i];
if (ai.url !== bi.url) return false;
if (ai.title !== bi.title) return false;
if (ai.faviconUrl !== bi.faviconUrl) return false;
if (ai.isLoaded !== bi.isLoaded) return false;
}
return true;
}
/**
* Hook that returns currently open web tabs from Web Viewer.
* Desktop-only - returns empty array on mobile.
*
* Features:
* - Subscribes to layout-change (new/closed tabs) and active-leaf-change
* - Falls back to low-frequency polling (6s) to catch title/URL updates after page load
* - Deep comparison to avoid unnecessary rerenders
* - Supports enabled option to disable polling when not needed
*
* @param options - Configuration options
* @param options.enabled - Whether to enable polling (default: true)
*/
export function useOpenWebTabs(options: UseOpenWebTabsOptions = {}): WebTabContext[] {
const { enabled = true } = options;
const [tabs, setTabs] = useState<WebTabContext[]>([]);
const rafIdRef = useRef<number | null>(null);
useEffect(() => {
// If disabled, return empty array and don't start polling
if (!enabled) {
setTabs([]);
return;
}
// Web Viewer is desktop-only
if (!Platform.isDesktopApp) {
setTabs([]);
return;
}
let disposed = false;
/** Refresh state from the current Web Viewer tab snapshot. */
const refresh = () => {
if (disposed) return;
const next = getOpenWebTabSnapshot();
setTabs((prev) => (areWebTabSnapshotsEqual(prev, next) ? prev : next));
};
/** Coalesce multiple refresh triggers into a single render tick. */
const scheduleRefresh = () => {
if (disposed) return;
if (rafIdRef.current !== null) return;
rafIdRef.current = window.requestAnimationFrame(() => {
rafIdRef.current = null;
refresh();
});
};
// Initial snapshot
refresh();
// Subscribe to webview load events from WebViewerStateManager (single source of truth)
const service = getWebViewerService(app);
const unsubscribeWebviewLoad = service.subscribeToWebviewLoad(scheduleRefresh);
// Subscribe to workspace events
const layoutRef = app.workspace.on("layout-change", scheduleRefresh);
const activeLeafRef = app.workspace.on("active-leaf-change", scheduleRefresh);
// Low-frequency fallback poll for rare title changes after initial load
const intervalId = window.setInterval(scheduleRefresh, WEB_TAB_FALLBACK_POLL_INTERVAL_MS);
return () => {
disposed = true;
if (rafIdRef.current !== null) {
window.cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
window.clearInterval(intervalId);
app.workspace.offref(layoutRef);
app.workspace.offref(activeLeafRef);
unsubscribeWebviewLoad();
};
}, [enabled]);
return tabs;
}

View file

@ -95,14 +95,30 @@ export function useTypeaheadPlugin<T extends TypeaheadOption>({
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
const nextIndex = Math.min(state.selectedIndex + 1, options.length - 1);
let nextIndex = state.selectedIndex + 1;
// Skip disabled options
while (nextIndex < options.length && options[nextIndex]?.disabled) {
nextIndex++;
}
// If no valid option found, stay at current position
if (nextIndex >= options.length) {
nextIndex = state.selectedIndex;
}
handleHighlight(nextIndex);
return true;
}
case "ArrowUp": {
event.preventDefault();
const prevIndex = Math.max(state.selectedIndex - 1, 0);
let prevIndex = state.selectedIndex - 1;
// Skip disabled options
while (prevIndex >= 0 && options[prevIndex]?.disabled) {
prevIndex--;
}
// If no valid option found, stay at current position
if (prevIndex < 0) {
prevIndex = state.selectedIndex;
}
handleHighlight(prevIndex);
return true;
}
@ -115,6 +131,11 @@ export function useTypeaheadPlugin<T extends TypeaheadOption>({
return false; // Let the event propagate to submit the message
}
// If current option is disabled, don't select it
if (options[state.selectedIndex]?.disabled) {
return true; // Prevent default but don't select
}
event.preventDefault();
if (options[state.selectedIndex]) {
onSelect(options[state.selectedIndex]);

View file

@ -0,0 +1,225 @@
import React from "react";
import {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
$getRoot,
} from "lexical";
import { Globe } from "lucide-react";
import { Platform } from "obsidian";
import { ACTIVE_WEB_TAB_MARKER } from "@/constants";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { TruncatedPillText } from "./TruncatedPillText";
import { PillBadge } from "./PillBadge";
import { useActiveWebTabState } from "../hooks/useActiveWebTabState";
export type SerializedActiveWebTabPillNode = SerializedBasePillNode;
/**
* ActiveWebTabPillNode represents the "Current Web Tab" in context.
* It automatically displays whatever web tab is currently active in Obsidian Web Viewer.
* Desktop-only feature.
*/
export class ActiveWebTabPillNode extends BasePillNode {
static getType(): string {
return "active-web-tab-pill";
}
static clone(node: ActiveWebTabPillNode): ActiveWebTabPillNode {
return new ActiveWebTabPillNode(node.__key);
}
constructor(key?: NodeKey) {
super("Current Web Tab", key);
}
getClassName(): string {
return "active-web-tab-pill-wrapper";
}
getDataAttribute(): string {
return "data-lexical-active-web-tab-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
span.className = "active-web-tab-pill-wrapper";
return span;
}
static importDOM(): DOMConversionMap | null {
return {
span: (node: HTMLElement) => {
if (node.hasAttribute("data-lexical-active-web-tab-pill")) {
return {
conversion: convertActiveWebTabPillElement,
priority: 2,
};
}
return null;
},
};
}
static importJSON(_serializedNode: SerializedActiveWebTabPillNode): ActiveWebTabPillNode {
return $createActiveWebTabPillNode();
}
exportJSON(): SerializedActiveWebTabPillNode {
return {
...super.exportJSON(),
type: "active-web-tab-pill",
version: 1,
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
element.setAttribute("data-lexical-active-web-tab-pill", "true");
element.textContent = ACTIVE_WEB_TAB_MARKER;
return { element };
}
getTextContent(): string {
return ACTIVE_WEB_TAB_MARKER;
}
decorate(): JSX.Element {
return <ActiveWebTabPillComponent />;
}
}
function convertActiveWebTabPillElement(_domNode: HTMLElement): DOMConversionOutput | null {
const node = $createActiveWebTabPillNode();
return { node };
}
/**
* Component that renders the active web tab pill.
* Uses activeWebTabForMentions to match the actual send behavior:
* - Has value when web tab is active OR when switched directly to chat panel
* - Null when switched to other views (e.g., note tab)
* This ensures UI display matches what will actually be sent.
*/
function ActiveWebTabPillComponent(): JSX.Element {
// Use activeWebTabForMentions to match send behavior (not activeOrLastWebTab)
const { activeWebTabForMentions } = useActiveWebTabState();
// Not supported on mobile
if (!Platform.isDesktopApp) {
return (
<PillBadge>
<div className="tw-flex tw-items-center tw-gap-1">
<Globe className="tw-size-3" />
<TruncatedPillText
content="activeWebTab"
openBracket="{"
closeBracket="}"
tooltipContent={<div className="tw-text-left">Web Viewer not supported on mobile</div>}
/>
</div>
</PillBadge>
);
}
// No active web tab (matches Active Note pill behavior when no active note)
if (!activeWebTabForMentions) {
return (
<PillBadge>
<div className="tw-flex tw-items-center tw-gap-1">
<Globe className="tw-size-3" />
<TruncatedPillText
content="activeWebTab"
openBracket="{"
closeBracket="}"
tooltipContent={
<div className="tw-text-left">
Will use the active web tab at the time the message is sent
</div>
}
/>
</div>
</PillBadge>
);
}
// Active web tab exists
return (
<PillBadge>
<div className="tw-flex tw-items-center tw-gap-1">
<Globe className="tw-size-3" />
<span className="tw-max-w-40 tw-truncate" title={activeWebTabForMentions.url}>
{activeWebTabForMentions.title ?? "Untitled"}
</span>
<span className="tw-text-xs tw-text-faint">Current</span>
</div>
</PillBadge>
);
}
/** Create an ActiveWebTabPillNode. */
export function $createActiveWebTabPillNode(): ActiveWebTabPillNode {
return new ActiveWebTabPillNode();
}
/** Check if a node is an ActiveWebTabPillNode. */
export function $isActiveWebTabPillNode(
node: LexicalNode | null | undefined
): node is ActiveWebTabPillNode {
return node instanceof ActiveWebTabPillNode;
}
/**
* Check whether the editor currently contains an active web tab pill.
* Used to determine if Active Web Tab should be included at send time,
* avoiding async pill-sync race conditions.
* @returns True if at least one ActiveWebTabPillNode exists in the editor
*/
export function $hasActiveWebTabPill(): boolean {
const root = $getRoot();
function traverse(node: LexicalNode): boolean {
if ($isActiveWebTabPillNode(node)) {
return true;
}
if ("getChildren" in node && typeof node.getChildren === "function") {
const children = (node as { getChildren: () => LexicalNode[] }).getChildren();
for (const child of children) {
if (traverse(child)) {
return true;
}
}
}
return false;
}
return traverse(root);
}
/**
* Removes all active web tab pills from the editor.
* @returns The number of pills removed
*/
export function $removeActiveWebTabPills(): number {
const root = $getRoot();
let removedCount = 0;
function traverse(node: LexicalNode): void {
if ($isActiveWebTabPillNode(node)) {
node.remove();
removedCount++;
} else if ("getChildren" in node && typeof node.getChildren === "function") {
const children = (node as { getChildren: () => LexicalNode[] }).getChildren();
for (const child of children) {
traverse(child);
}
}
}
traverse(root);
return removedCount;
}

View file

@ -0,0 +1,244 @@
import React from "react";
import {
$getRoot,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
} from "lexical";
import { Globe } from "lucide-react";
import { getDomainFromUrl } from "@/utils";
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
import { PillBadge } from "./PillBadge";
export interface SerializedWebTabPillNode extends SerializedBasePillNode {
url: string;
title?: string;
faviconUrl?: string;
}
/**
* Format a web tab reference for text serialization.
* Uses a simple bracket format with globe emoji to identify web tabs.
* @param url - The web tab URL
* @param title - Optional title of the web tab
* @returns Formatted string: `[🌐 title]` if title exists, otherwise `[🌐 domain]`
*/
function formatWebTabPillTextContent(url: string, title?: string): string {
const displayText = title?.trim() || getDomainFromUrl(url) || "Untitled";
return `[🌐: ${displayText}]`;
}
/**
* WebTabPillNode represents a web tab from Web Viewer in context.
* Stores URL, title, and optional favicon URL.
*/
export class WebTabPillNode extends BasePillNode {
__url: string;
__title?: string;
__faviconUrl?: string;
static getType(): string {
return "web-tab-pill";
}
static clone(node: WebTabPillNode): WebTabPillNode {
return new WebTabPillNode(node.__url, node.__title, node.__faviconUrl, node.__key);
}
constructor(url: string, title?: string, faviconUrl?: string, key?: NodeKey) {
super(url, key);
this.__url = url;
this.__title = title;
this.__faviconUrl = faviconUrl;
}
getClassName(): string {
return "web-tab-pill-wrapper";
}
getDataAttribute(): string {
return "data-lexical-web-tab-pill";
}
createDOM(_config: EditorConfig): HTMLElement {
const span = document.createElement("span");
span.className = "web-tab-pill-wrapper";
return span;
}
static importDOM(): DOMConversionMap | null {
return {
span: (node: HTMLElement) => {
if (node.hasAttribute("data-lexical-web-tab-pill")) {
return {
conversion: convertWebTabPillElement,
priority: 1,
};
}
return null;
},
};
}
static importJSON(serializedNode: SerializedWebTabPillNode): WebTabPillNode {
const { url, title, faviconUrl } = serializedNode;
return $createWebTabPillNode(url, title, faviconUrl);
}
exportJSON(): SerializedWebTabPillNode {
return {
...super.exportJSON(),
url: this.__url,
title: this.__title,
faviconUrl: this.__faviconUrl,
type: "web-tab-pill",
version: 1,
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement("span");
element.setAttribute("data-lexical-web-tab-pill", "true");
element.setAttribute("data-url", this.__url);
if (this.__title) {
element.setAttribute("data-title", this.__title);
}
if (this.__faviconUrl) {
element.setAttribute("data-favicon-url", this.__faviconUrl);
}
element.textContent = formatWebTabPillTextContent(this.__url, this.__title);
return { element };
}
getTextContent(): string {
return formatWebTabPillTextContent(this.__url, this.__title);
}
getURL(): string {
return this.__url;
}
getTitle(): string | undefined {
return this.__title;
}
getFaviconUrl(): string | undefined {
return this.__faviconUrl;
}
/**
* Set the title metadata for this web tab pill.
* Must be called within a Lexical update context.
*/
setTitle(title?: string): void {
const writable = this.getWritable();
writable.__title = title;
}
/**
* Set the favicon URL metadata for this web tab pill.
* Must be called within a Lexical update context.
*/
setFaviconUrl(faviconUrl?: string): void {
const writable = this.getWritable();
writable.__faviconUrl = faviconUrl;
}
decorate(): JSX.Element {
const displayText = this.__title || this.__url;
return (
<PillBadge className="tw-whitespace-nowrap">
<div className="tw-flex tw-items-center tw-gap-1">
<Globe className="tw-size-3" />
<span className="tw-max-w-40 tw-truncate">{displayText}</span>
</div>
</PillBadge>
);
}
}
function convertWebTabPillElement(domNode: HTMLElement): DOMConversionOutput | null {
const url = domNode.getAttribute("data-url");
const title = domNode.getAttribute("data-title");
const faviconUrl = domNode.getAttribute("data-favicon-url");
if (url !== null) {
const node = $createWebTabPillNode(url, title || undefined, faviconUrl || undefined);
return { node };
}
return null;
}
/** Create a WebTabPillNode. */
export function $createWebTabPillNode(
url: string,
title?: string,
faviconUrl?: string
): WebTabPillNode {
return new WebTabPillNode(url, title, faviconUrl);
}
/** Check if a node is a WebTabPillNode. */
export function $isWebTabPillNode(node: LexicalNode | null | undefined): node is WebTabPillNode {
return node instanceof WebTabPillNode;
}
/** Find all WebTabPillNodes in the editor. */
export function $findWebTabPills(): WebTabPillNode[] {
const root = $getRoot();
const pills: WebTabPillNode[] = [];
function traverse(node: LexicalNode) {
if (node instanceof WebTabPillNode) {
pills.push(node);
}
if ("getChildren" in node && typeof node.getChildren === "function") {
const children = (node as { getChildren: () => LexicalNode[] }).getChildren();
for (const child of children) {
traverse(child);
}
}
}
traverse(root);
return pills;
}
/**
* Check if a WebTabPillNode with the given URL exists in the editor.
* Must be called within a Lexical read/update context.
* Uses early-exit traversal for better performance.
*/
export function $hasWebTabPillWithUrl(url: string): boolean {
const root = $getRoot();
function traverse(node: LexicalNode): boolean {
if (node instanceof WebTabPillNode && node.getURL() === url) {
return true; // Early exit on match
}
if ("getChildren" in node && typeof node.getChildren === "function") {
const children = (node as { getChildren: () => LexicalNode[] }).getChildren();
for (const child of children) {
if (traverse(child)) {
return true; // Propagate early exit
}
}
}
return false;
}
return traverse(root);
}
/** Remove WebTabPillNodes by URL. */
export function $removeWebTabPillsByUrl(url: string): void {
const pills = $findWebTabPills();
for (const pill of pills) {
if (pill.getURL() === url) {
pill.remove();
}
}
}

View file

@ -10,8 +10,14 @@ export interface PillSyncConfig<T> {
isPillNode: (node: any) => boolean;
/** Function to extract data from the pill node */
extractData: (node: any) => T;
/** Function to create a unique key for comparison (optional, defaults to value as-is) */
/** Function to create a unique identity key for deduplication and removal detection */
getKey?: (item: T) => string;
/**
* Function to create a stable key representing the full pill state for change detection.
* Use this when pill metadata (e.g., title, favicon) can change without identity changing.
* Defaults to `getKey(item)` if not provided.
*/
getChangeKey?: (item: T) => string;
}
/**
@ -43,7 +49,12 @@ export function GenericPillSyncPlugin<T>({
const prevItemsRef = React.useRef<T[]>([]);
// Default configuration values
const { isPillNode, extractData, getKey = (item: T) => String(item) } = config;
const { isPillNode, extractData, getKey = (item: T) => String(item), getChangeKey } = config;
// Use getChangeKey for state comparison, fallback to getKey if not provided
const getComparisonKey = React.useMemo(
() => getChangeKey ?? getKey,
[getChangeKey, getKey]
);
React.useEffect(() => {
if (!onChange && !onRemoved) return;
@ -87,19 +98,29 @@ export function GenericPillSyncPlugin<T>({
// Sort items by their key
const processedItems = deduplicatedItems.sort((a, b) => getKey(a).localeCompare(getKey(b)));
// Check for changes by comparing keys
// Check for changes using both identity keys (for add/remove) and change keys (for metadata updates)
const prevItems = prevItemsRef.current;
const currentKeys = processedItems.map(getKey);
const prevKeys = prevItems.map(getKey);
const currentIdentityKeys = processedItems.map(getKey);
const prevIdentityKeys = prevItems.map(getKey);
const currentChangeKeys = processedItems.map(getComparisonKey);
const prevChangeKeys = prevItems.map(getComparisonKey);
const hasChanges =
currentKeys.length !== prevKeys.length ||
currentKeys.some((key, index) => key !== prevKeys[index]);
// Identity changes: items added or removed
const hasIdentityChanges =
currentIdentityKeys.length !== prevIdentityKeys.length ||
currentIdentityKeys.some((key, index) => key !== prevIdentityKeys[index]);
// State changes: metadata updated (e.g., title/favicon changed while URL stayed same)
const hasStateChanges =
currentChangeKeys.length !== prevChangeKeys.length ||
currentChangeKeys.some((key, index) => key !== prevChangeKeys[index]);
const hasChanges = hasIdentityChanges || hasStateChanges;
if (hasChanges) {
// Detect removed items
// Detect removed items (identity-based)
if (onRemoved) {
const currentKeySet = new Set(currentKeys);
const currentKeySet = new Set(currentIdentityKeys);
const removedItems = prevItems.filter((item) => !currentKeySet.has(getKey(item)));
if (removedItems.length > 0) {
@ -115,7 +136,7 @@ export function GenericPillSyncPlugin<T>({
}
});
});
}, [editor, onChange, onRemoved, isPillNode, extractData, getKey]);
}, [editor, onChange, onRemoved, isPillNode, extractData, getKey, getComparisonKey]);
return null;
}

View file

@ -0,0 +1,106 @@
import React from "react";
import { $isWebTabPillNode, WebTabPillNode } from "../pills/WebTabPillNode";
import { $isActiveWebTabPillNode } from "../pills/ActiveWebTabPillNode";
import { GenericPillSyncPlugin, PillSyncConfig } from "./GenericPillSyncPlugin";
import type { WebTabContext } from "@/types/message";
/**
* Props for the WebTabPillSyncPlugin component
*/
interface WebTabPillSyncPluginProps {
/** Callback triggered when the list of web tab pills changes */
onWebTabsChange?: (webTabs: WebTabContext[]) => void;
/** Callback triggered when web tab pills are removed from the editor */
onWebTabsRemoved?: (removedWebTabs: WebTabContext[]) => void;
/** Callback triggered when an active web tab pill is added */
onActiveWebTabAdded?: () => void;
/** Callback triggered when an active web tab pill is removed */
onActiveWebTabRemoved?: () => void;
}
/**
* Configuration for web tab pill synchronization
*/
const webTabPillConfig: PillSyncConfig<WebTabContext> = {
isPillNode: $isWebTabPillNode,
extractData: (node: WebTabPillNode): WebTabContext => ({
url: node.getURL(),
title: node.getTitle(),
faviconUrl: node.getFaviconUrl(),
}),
// Identity key: URL uniquely identifies a web tab
getKey: (item: WebTabContext) => item.url,
// Change key: includes all metadata for detecting title/favicon updates
getChangeKey: (item: WebTabContext) =>
[item.url, item.title ?? "", item.faviconUrl ?? ""].join("\n"),
};
/**
* Lexical plugin that monitors web tab pill nodes in the editor and syncs
* their state with parent components. Tracks additions, removals, and
* changes to web tab pills to keep external state in sync with editor content.
*
* Also monitors ActiveWebTabPillNode separately since it needs different handling.
*/
export function WebTabPillSyncPlugin({
onWebTabsChange,
onWebTabsRemoved,
onActiveWebTabAdded,
onActiveWebTabRemoved,
}: WebTabPillSyncPluginProps) {
return (
<>
<GenericPillSyncPlugin
config={webTabPillConfig}
onChange={onWebTabsChange}
onRemoved={onWebTabsRemoved}
/>
{(onActiveWebTabAdded || onActiveWebTabRemoved) && (
<ActiveWebTabPillSyncPlugin
onActiveWebTabAdded={onActiveWebTabAdded}
onActiveWebTabRemoved={onActiveWebTabRemoved}
/>
)}
</>
);
}
/**
* Internal plugin to track ActiveWebTabPillNode presence
*/
function ActiveWebTabPillSyncPlugin({
onActiveWebTabAdded,
onActiveWebTabRemoved,
}: {
onActiveWebTabAdded?: () => void;
onActiveWebTabRemoved?: () => void;
}) {
// Use GenericPillSyncPlugin with a simple boolean-like config
const config: PillSyncConfig<boolean> = {
isPillNode: $isActiveWebTabPillNode,
extractData: () => true,
getKey: () => "active-web-tab",
};
const handleChange = React.useCallback(
(items: boolean[]) => {
if (items.length > 0 && onActiveWebTabAdded) {
onActiveWebTabAdded();
}
},
[onActiveWebTabAdded]
);
const handleRemoved = React.useCallback(
(removedItems: boolean[]) => {
if (removedItems.length > 0 && onActiveWebTabRemoved) {
onActiveWebTabRemoved();
}
},
[onActiveWebTabRemoved]
);
return (
<GenericPillSyncPlugin config={config} onChange={handleChange} onRemoved={handleRemoved} />
);
}

View file

@ -10,17 +10,20 @@ import {
LexicalCommand,
} from "lexical";
import { TFile, TFolder, App } from "obsidian";
import type { WebTabContext } from "@/types/message";
import { $createNotePillNode } from "../pills/NotePillNode";
import { $createActiveNotePillNode } from "../pills/ActiveNotePillNode";
import { $createURLPillNode } from "../pills/URLPillNode";
import { $createToolPillNode } from "../pills/ToolPillNode";
import { $createFolderPillNode } from "../pills/FolderPillNode";
import { $createWebTabPillNode } from "../pills/WebTabPillNode";
import { $createActiveWebTabPillNode } from "../pills/ActiveWebTabPillNode";
import { logInfo } from "@/logger";
import { AVAILABLE_TOOLS } from "../constants/tools";
declare const app: App;
export type PillType = "notes" | "tools" | "folders" | "active-note";
export type PillType = "notes" | "tools" | "folders" | "active-note" | "webTabs" | "activeWebTab";
// Type representing different kinds of parsed content segments
export type ParsedContentType =
@ -35,7 +38,7 @@ export type ParsedContentType =
export type PatternType = "notes" | "urls" | "tools" | "customTemplates";
// Type representing the data associated with a pill
export type PillDataValue = TFile | TFolder | string;
export type PillDataValue = TFile | TFolder | string | WebTabContext;
export interface PillData {
type: PillType;
@ -68,6 +71,15 @@ export function $createPillNode(pillData: PillData) {
return $createFolderPillNode(data.path);
}
break;
case "webTabs":
// WebTabContext has url, title, faviconUrl
if (data && typeof data === "object" && "url" in data) {
const webTab = data as WebTabContext;
return $createWebTabPillNode(webTab.url, webTab.title, webTab.faviconUrl);
}
break;
case "activeWebTab":
return $createActiveWebTabPillNode();
}
throw new Error(`Invalid pill data: ${JSON.stringify(pillData)}`);

View file

@ -101,6 +101,7 @@ export const COMPOSER_OUTPUT_INSTRUCTIONS = `Return the new note content or canv
export const NOTE_CONTEXT_PROMPT_TAG = "note_context";
export const SELECTED_TEXT_TAG = "selected_text";
export const WEB_SELECTED_TEXT_TAG = "web_selected_text";
export const VARIABLE_TAG = "variable";
export const VARIABLE_NOTE_TAG = "variable_note";
export const EMBEDDED_PDF_TAG = "embedded_pdf";
@ -108,6 +109,11 @@ export const EMBEDDED_NOTE_TAG = "embedded_note";
export const DATAVIEW_BLOCK_TAG = "dataview_block";
export const VAULT_NOTE_TAG = "vault_note";
export const RETRIEVED_DOCUMENT_TAG = "retrieved_document";
export const WEB_TAB_CONTEXT_TAG = "web_tab_context";
export const ACTIVE_WEB_TAB_CONTEXT_TAG = "active_web_tab";
export const YOUTUBE_VIDEO_CONTEXT_TAG = "youtube_video_context";
/** Marker text used as placeholder for active web tab in serialized content */
export const ACTIVE_WEB_TAB_MARKER = "{activeWebTab}";
export const EMPTY_INDEX_ERROR_MESSAGE =
"Copilot index does not exist. Please index your vault first!\n\n1. Set a working embedding model in QA settings. If it's not a local model, don't forget to set the API key. \n\n2. Click 'Refresh Index for Vault' and wait for indexing to complete. If you encounter the rate limiting error, please turn your request per second down in QA setting.";
export const CHUNK_SIZE = 6000;
@ -682,6 +688,7 @@ export const COMMAND_IDS = {
SEARCH_ORAMA_DB: "copilot-search-orama-db",
TOGGLE_COPILOT_CHAT_WINDOW: "chat-toggle-window",
ADD_SELECTION_TO_CHAT_CONTEXT: "add-selection-to-chat-context",
ADD_WEB_SELECTION_TO_CHAT_CONTEXT: "add-web-selection-to-chat-context",
ADD_CUSTOM_COMMAND: "add-custom-command",
APPLY_CUSTOM_COMMAND: "apply-custom-command",
OPEN_LOG_FILE: "open-log-file",
@ -709,6 +716,7 @@ export const COMMAND_NAMES: Record<CommandId, string> = {
[COMMAND_IDS.SEARCH_ORAMA_DB]: "Search semantic index (debug)",
[COMMAND_IDS.TOGGLE_COPILOT_CHAT_WINDOW]: "Toggle Copilot Chat Window",
[COMMAND_IDS.ADD_SELECTION_TO_CHAT_CONTEXT]: "Add selection to chat context",
[COMMAND_IDS.ADD_WEB_SELECTION_TO_CHAT_CONTEXT]: "Add web selection to chat context",
[COMMAND_IDS.ADD_CUSTOM_COMMAND]: "Add new custom command",
[COMMAND_IDS.APPLY_CUSTOM_COMMAND]: "Apply custom command",
[COMMAND_IDS.OPEN_LOG_FILE]: "Create log file",
@ -763,7 +771,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
defaultConversationTag: "copilot-conversation",
autosaveChat: true,
generateAIChatTitleOnSave: true,
includeActiveNoteAsContext: true,
autoAddActiveContentToContext: true,
defaultOpenArea: DEFAULT_OPEN_AREA.VIEW,
defaultSendShortcut: SEND_SHORTCUT.ENTER,
customPromptsFolder: DEFAULT_CUSTOM_PROMPTS_FOLDER,
@ -819,7 +827,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
enableSavedMemory: true,
quickCommandModelKey: undefined,
quickCommandIncludeNoteContext: true,
autoIncludeTextSelection: false,
autoAddSelectionToContext: false,
};
export const EVENT_NAMES = {

View file

@ -0,0 +1,174 @@
/**
* Tests for processSelectedTextContexts in ContextProcessor
*
* Verifies that note and web selected text contexts are correctly
* formatted with appropriate XML tags.
*/
import { SELECTED_TEXT_TAG, WEB_SELECTED_TEXT_TAG } from "@/constants";
import { NoteSelectedTextContext, WebSelectedTextContext } from "@/types/message";
// Mock the aiParams module
const mockSelectedTextContexts: (NoteSelectedTextContext | WebSelectedTextContext)[] = [];
jest.mock("@/aiParams", () => ({
getSelectedTextContexts: () => mockSelectedTextContexts,
}));
// Import after mocking
import { ContextProcessor } from "@/contextProcessor";
describe("ContextProcessor.processSelectedTextContexts", () => {
let processor: ContextProcessor;
beforeEach(() => {
// Clear mock data
mockSelectedTextContexts.length = 0;
// Get singleton instance
processor = ContextProcessor.getInstance();
});
it("should return empty string when no selected text contexts", () => {
const result = processor.processSelectedTextContexts();
expect(result).toBe("");
});
it("should format note selected text with proper XML tags", () => {
const noteContext: NoteSelectedTextContext = {
id: "note-1",
sourceType: "note",
content: "function fibonacci(n) {\n return n <= 1 ? n : fibonacci(n-1) + fibonacci(n-2);\n}",
noteTitle: "Algorithms",
notePath: "dev/algorithms.md",
startLine: 10,
endLine: 12,
};
mockSelectedTextContexts.push(noteContext);
const result = processor.processSelectedTextContexts();
expect(result).toContain(`<${SELECTED_TEXT_TAG}>`);
expect(result).toContain(`</${SELECTED_TEXT_TAG}>`);
expect(result).toContain("<title>Algorithms</title>");
expect(result).toContain("<path>dev/algorithms.md</path>");
expect(result).toContain("<start_line>10</start_line>");
expect(result).toContain("<end_line>12</end_line>");
expect(result).toContain("<content>");
expect(result).toContain("function fibonacci");
expect(result).toContain("</content>");
// Should NOT contain web-specific tags
expect(result).not.toContain(`<${WEB_SELECTED_TEXT_TAG}>`);
expect(result).not.toContain("<url>");
});
it("should format web selected text with proper XML tags", () => {
const webContext: WebSelectedTextContext = {
id: "web-1",
sourceType: "web",
content: "# React Documentation\n\nReact is a JavaScript library for building user interfaces.",
title: "React Docs",
url: "https://react.dev/learn",
};
mockSelectedTextContexts.push(webContext);
const result = processor.processSelectedTextContexts();
expect(result).toContain(`<${WEB_SELECTED_TEXT_TAG}>`);
expect(result).toContain(`</${WEB_SELECTED_TEXT_TAG}>`);
expect(result).toContain("<title>React Docs</title>");
expect(result).toContain("<url>https://react.dev/learn</url>");
expect(result).toContain("<content>");
expect(result).toContain("React Documentation");
expect(result).toContain("</content>");
// Should NOT contain note-specific tags
expect(result).not.toContain(`<${SELECTED_TEXT_TAG}>`);
expect(result).not.toContain("<path>");
expect(result).not.toContain("<start_line>");
expect(result).not.toContain("<end_line>");
});
it("should handle mixed note and web selected text contexts", () => {
const noteContext: NoteSelectedTextContext = {
id: "note-1",
sourceType: "note",
content: "Local note content about React patterns",
noteTitle: "React Patterns",
notePath: "dev/react-patterns.md",
startLine: 15,
endLine: 20,
};
const webContext: WebSelectedTextContext = {
id: "web-1",
sourceType: "web",
content: "Web content about React best practices",
title: "React Best Practices",
url: "https://react.dev/best-practices",
};
mockSelectedTextContexts.push(noteContext, webContext);
const result = processor.processSelectedTextContexts();
// Verify both context types are present
expect(result).toContain(`<${SELECTED_TEXT_TAG}>`);
expect(result).toContain(`</${SELECTED_TEXT_TAG}>`);
expect(result).toContain(`<${WEB_SELECTED_TEXT_TAG}>`);
expect(result).toContain(`</${WEB_SELECTED_TEXT_TAG}>`);
// Verify note selection has path and line numbers
expect(result).toContain("<path>dev/react-patterns.md</path>");
expect(result).toContain("<start_line>15</start_line>");
expect(result).toContain("<end_line>20</end_line>");
// Verify web selection has url
expect(result).toContain("<url>https://react.dev/best-practices</url>");
// Verify content from both
expect(result).toContain("Local note content about React patterns");
expect(result).toContain("Web content about React best practices");
});
it("should escape XML special characters in content", () => {
const noteContext: NoteSelectedTextContext = {
id: "note-1",
sourceType: "note",
content: "Code with <tags> & special \"chars\"",
noteTitle: "Test <Note>",
notePath: "test/path&file.md",
startLine: 1,
endLine: 1,
};
mockSelectedTextContexts.push(noteContext);
const result = processor.processSelectedTextContexts();
// Title and path should be escaped
expect(result).toContain("<title>Test &lt;Note&gt;</title>");
expect(result).toContain("<path>test/path&amp;file.md</path>");
});
it("should escape XML special characters in web context", () => {
const webContext: WebSelectedTextContext = {
id: "web-1",
sourceType: "web",
content: "Content with <html> tags",
title: "Page <Title>",
url: "https://example.com/page?a=1&b=2",
};
mockSelectedTextContexts.push(webContext);
const result = processor.processSelectedTextContexts();
// Title and URL should be escaped
expect(result).toContain("<title>Page &lt;Title&gt;</title>");
expect(result).toContain("<url>https://example.com/page?a=1&amp;b=2</url>");
});
});

View file

@ -2,15 +2,23 @@ import { getSelectedTextContexts } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import { RESTRICTION_MESSAGES } from "@/constants";
import { logWarn, logInfo, logError } from "@/logger";
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import { WebViewerTimeoutError } from "@/services/webViewerService/webViewerServiceTypes";
import { FileParserManager } from "@/tools/FileParserManager";
import { isPlusChain } from "@/utils";
import { normalizeUrlString } from "@/utils/urlNormalization";
import { TFile, Vault, Notice } from "obsidian";
import {
NOTE_CONTEXT_PROMPT_TAG,
EMBEDDED_PDF_TAG,
EMBEDDED_NOTE_TAG,
SELECTED_TEXT_TAG,
WEB_SELECTED_TEXT_TAG,
DATAVIEW_BLOCK_TAG,
WEB_TAB_CONTEXT_TAG,
ACTIVE_WEB_TAB_CONTEXT_TAG,
YOUTUBE_VIDEO_CONTEXT_TAG,
} from "./constants";
interface EmbeddedLinkTarget {
@ -643,9 +651,422 @@ export class ContextProcessor {
let additionalContext = "";
for (const selectedText of selectedTextContexts) {
additionalContext += `\n\n<${SELECTED_TEXT_TAG}>\n<title>${selectedText.noteTitle}</title>\n<path>${selectedText.notePath}</path>\n<start_line>${selectedText.startLine.toString()}</start_line>\n<end_line>${selectedText.endLine.toString()}</end_line>\n<content>\n${selectedText.content}\n</content>\n</${SELECTED_TEXT_TAG}>`;
if (selectedText.sourceType === "web") {
// Web selected text context
additionalContext += `\n\n<${WEB_SELECTED_TEXT_TAG}>\n<title>${escapeXml(selectedText.title)}</title>\n<url>${escapeXml(selectedText.url)}</url>\n<content>\n${escapeXml(selectedText.content)}\n</content>\n</${WEB_SELECTED_TEXT_TAG}>`;
} else {
// Note selected text context (default for backward compatibility)
additionalContext += `\n\n<${SELECTED_TEXT_TAG}>\n<title>${escapeXml(selectedText.noteTitle)}</title>\n<path>${escapeXml(selectedText.notePath)}</path>\n<start_line>${selectedText.startLine.toString()}</start_line>\n<end_line>${selectedText.endLine.toString()}</end_line>\n<content>\n${selectedText.content}\n</content>\n</${SELECTED_TEXT_TAG}>`;
}
}
return additionalContext;
}
/**
* Process web tab contexts and return formatted content for LLM.
* Uses WebViewerService to fetch reader mode markdown from each tab.
* Handles cases where webview content is not yet loaded (e.g., after Obsidian restart).
*
* Performance optimizations:
* - Deduplicates tabs by URL
* - Uses bounded concurrency to avoid UI blocking and resource exhaustion
*
* Design notes (potential future enhancements):
* - Tab count limit: Currently no limit on number of tabs. Could add MAX_WEB_TABS similar to
* how activeNote is handled (single note vs multiple). For now, users self-limit by only
* adding tabs they need.
* - Content length limit: Currently no truncation of long pages. Could add MAX_CHARS_PER_TAB
* similar to how large notes are handled. For now, reader mode already strips most bloat.
* - Total context budget: Could implement a shared budget across all tabs. For now, the LLM's
* context window and token costs naturally discourage excessive context.
*/
async processContextWebTabs(
webTabs: Array<{ url: string; title?: string; faviconUrl?: string; isActive?: boolean }>
): Promise<string> {
if (!webTabs || webTabs.length === 0) {
return "";
}
const WEBVIEW_READY_TIMEOUT_MS = 2_500;
// Timeout for reader mode content extraction to avoid hanging the message send
const READER_MODE_CONTENT_TIMEOUT_MS = 8_000;
// Timeout for YouTube transcript extraction (longer due to DOM manipulation)
const YOUTUBE_TRANSCRIPT_TIMEOUT_MS = 15_000;
// Limit concurrent webview operations to avoid UI blocking and IPC congestion
const MAX_CONCURRENCY = 2;
/**
* Build a web tab context XML block.
* Centralizes XML generation to avoid duplication and ensure consistent escaping.
* @param tagName - The XML tag name to use (active_web_tab or web_tab_context)
*/
const buildWebTabBlock = (
tagName: string,
options: {
title: string;
url: string;
mode?: string;
content?: string;
error?: string;
}
): string => {
const parts = [
`\n\n<${tagName}>`,
`\n<title>${escapeXml(options.title)}</title>`,
`\n<url>${escapeXml(options.url)}</url>`,
];
if (options.mode) {
parts.push(`\n<mode>${escapeXml(options.mode)}</mode>`);
}
if (options.error) {
parts.push(`\n<error>${escapeXml(options.error)}</error>`);
} else if (options.content !== undefined) {
// Content is markdown; escape to prevent XML/prompt injection
parts.push(`\n<content>\n${escapeXml(options.content)}\n</content>`);
}
parts.push(`\n</${tagName}>`);
return parts.join("");
};
/**
* Build a YouTube video context XML block.
* All content is properly escaped to prevent XML/prompt injection.
*/
const buildYouTubeBlock = (options: {
title: string;
url: string;
videoId: string;
channel?: string;
description?: string;
uploadDate?: string;
duration?: string;
genre?: string;
transcript?: string;
error?: string;
isActive?: boolean;
}): string => {
const parts = [
`\n\n<${YOUTUBE_VIDEO_CONTEXT_TAG}>`,
`\n<title>${escapeXml(options.title)}</title>`,
`\n<url>${escapeXml(options.url)}</url>`,
`\n<video_id>${escapeXml(options.videoId)}</video_id>`,
];
if (options.isActive) {
parts.push(`\n<is_active>true</is_active>`);
}
if (options.channel) {
parts.push(`\n<channel>${escapeXml(options.channel)}</channel>`);
}
if (options.uploadDate) {
parts.push(`\n<upload_date>${escapeXml(options.uploadDate)}</upload_date>`);
}
if (options.duration) {
parts.push(`\n<duration>${escapeXml(options.duration)}</duration>`);
}
if (options.genre) {
parts.push(`\n<genre>${escapeXml(options.genre)}</genre>`);
}
if (options.description) {
parts.push(`\n<description>${escapeXml(options.description)}</description>`);
}
// Error for real failures (tab closed, extraction failed, etc.)
if (options.error) {
parts.push(`\n<error>${escapeXml(options.error)}</error>`);
}
// Content: transcript if available, otherwise a message
const content = options.transcript || "No transcript available for this video";
parts.push(`\n<content>\n${escapeXml(content)}\n</content>`);
parts.push(`\n</${YOUTUBE_VIDEO_CONTEXT_TAG}>`);
return parts.join("");
};
// Separate active tab from normal tabs and deduplicate by URL (and videoId for YouTube)
let activeTab: { url: string; title?: string; faviconUrl?: string } | null = null;
const normalTabs: Array<{ url: string; title?: string; faviconUrl?: string }> = [];
const seenUrls = new Set<string>();
const seenVideoIds = new Set<string>(); // Deduplicate YouTube videos by videoId
const service = getWebViewerService(app);
/**
* Check if a tab should be skipped due to deduplication.
* For YouTube videos, deduplicate by videoId (handles youtu.be vs youtube.com/watch).
* For other URLs, deduplicate by normalized URL string.
*/
const isDuplicate = (url: string): boolean => {
const videoId = service.getYouTubeVideoId(url);
if (videoId) {
if (seenVideoIds.has(videoId)) return true;
seenVideoIds.add(videoId);
return false;
}
if (seenUrls.has(url)) return true;
seenUrls.add(url);
return false;
};
// First pass: find active tab
for (const tab of webTabs) {
const url = normalizeUrlString(tab.url);
if (!url) continue;
if (tab.isActive && !activeTab) {
activeTab = { ...tab, url };
isDuplicate(url); // Mark as seen
}
}
// Second pass: collect normal tabs (excluding duplicates)
for (const tab of webTabs) {
const url = normalizeUrlString(tab.url);
if (!url || isDuplicate(url)) continue;
normalTabs.push({ ...tab, url });
}
// Check if we have any tabs to process
if (!activeTab && normalTabs.length === 0) {
return "";
}
// Check Web Viewer availability first
const availability = service.getAvailability();
if (!availability.supported || !availability.available) {
const reason =
availability.reason ??
(availability.supported ? "Web Viewer is not available." : "Web Viewer is not supported on this platform.");
const blocks: string[] = [];
if (activeTab) {
blocks.push(
buildWebTabBlock(ACTIVE_WEB_TAB_CONTEXT_TAG, {
title: activeTab.title || "Unknown",
url: activeTab.url,
error: reason,
})
);
}
for (const tab of normalTabs) {
blocks.push(
buildWebTabBlock(WEB_TAB_CONTEXT_TAG, {
title: tab.title || "Unknown",
url: tab.url,
error: reason,
})
);
}
return blocks.join("");
}
/**
* Process a single tab and return its XML block.
* YouTube videos are handled specially to extract transcript.
*/
const processTab = async (
tab: { url: string; title?: string; faviconUrl?: string },
tagName: string
): Promise<string> => {
try {
const url = tab.url;
// Check if this is a YouTube video - handle specially
const videoId = service.getYouTubeVideoId(url);
if (videoId) {
const isActive = tagName === ACTIVE_WEB_TAB_CONTEXT_TAG;
return await processYouTubeTab(tab, videoId, isActive);
}
const leaf = service.findLeafByUrl(url, { title: tab.title });
if (!leaf) {
return buildWebTabBlock(tagName, {
title: tab.title || "Unknown",
url,
error: "Web tab not found or closed",
});
}
// Get initial page info (available even if webview not ready)
let pageInfo = service.getPageInfo(leaf);
// Check if webview is ready (content loaded)
// Note: webviewMounted/webviewFirstLoadFinished are internal Obsidian fields
// If they don't exist (undefined), assume ready (fallback for older versions)
const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean };
const webviewReady =
view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined
? true
: Boolean(view.webviewMounted && view.webviewFirstLoadFinished);
if (!webviewReady) {
try {
await service.waitForWebviewReady(leaf, WEBVIEW_READY_TIMEOUT_MS);
pageInfo = service.getPageInfo(leaf);
} catch (err) {
logWarn(`Web tab content not loaded yet for ${url}:`, err);
return buildWebTabBlock(tagName, {
title: pageInfo.title || tab.title || "Untitled",
url: pageInfo.url || url,
mode: pageInfo.mode,
error: "Web tab content not loaded yet",
});
}
}
// Use AbortSignal for cancellable timeout
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort();
}, READER_MODE_CONTENT_TIMEOUT_MS);
try {
const content = await service.getReaderModeMarkdown(leaf, {
signal: abortController.signal,
});
pageInfo = service.getPageInfo(leaf);
return buildWebTabBlock(tagName, {
title: pageInfo.title || tab.title || "Untitled",
url: pageInfo.url || url,
mode: pageInfo.mode,
content,
});
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
logError(`Error processing web tab ${tab.url}:`, error);
return buildWebTabBlock(tagName, {
title: tab.title || "Unknown",
url: tab.url,
error:
error instanceof WebViewerTimeoutError
? "Web tab content extraction timed out"
: "Could not process web tab",
});
}
};
/**
* Process a YouTube video tab and extract transcript.
* Automatically clicks the transcript button, extracts content, and closes the panel.
*/
const processYouTubeTab = async (
tab: { url: string; title?: string; faviconUrl?: string },
videoId: string,
isActive: boolean
): Promise<string> => {
try {
// Find leaf by videoId (handles all URL formats and redirects)
// This is more reliable than URL string matching because:
// - youtu.be/xxx redirects to youtube.com/watch?v=xxx
// - URL may have different query params (t=, list=, etc.)
// - www vs non-www differences
let leaf = null;
let actualUrl = tab.url;
for (const l of service.getLeaves()) {
const leafUrl = service.getPageInfo(l).url;
if (service.getYouTubeVideoId(leafUrl) === videoId) {
leaf = l;
actualUrl = leafUrl; // Use the actual URL from the leaf
break;
}
}
if (!leaf) {
return buildYouTubeBlock({
title: tab.title || "YouTube Video",
url: tab.url,
videoId,
isActive,
error: "Web tab not found or closed",
});
}
// Wait for webview to be ready
const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean };
const webviewReady =
view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined
? true
: Boolean(view.webviewMounted && view.webviewFirstLoadFinished);
if (!webviewReady) {
try {
await service.waitForWebviewReady(leaf, WEBVIEW_READY_TIMEOUT_MS);
} catch (err) {
logWarn(`YouTube tab content not loaded yet for ${actualUrl}:`, err);
return buildYouTubeBlock({
title: tab.title || "YouTube Video",
url: actualUrl,
videoId,
isActive,
error: "Web tab content not loaded yet",
});
}
}
// Extract video metadata and transcript
const result = await service.getYouTubeTranscript(leaf, {
timeoutMs: YOUTUBE_TRANSCRIPT_TIMEOUT_MS,
});
// Format transcript (may be empty if no transcript available)
const transcriptText =
result.transcript.length > 0
? result.transcript.map((seg) => `${seg.timestamp}: ${seg.text}`).join("\n")
: undefined;
return buildYouTubeBlock({
title: result.title || tab.title || "YouTube Video",
url: actualUrl,
videoId: result.videoId,
channel: result.channel,
description: result.description,
uploadDate: result.uploadDate,
duration: result.duration,
genre: result.genre,
transcript: transcriptText,
isActive,
});
} catch (err) {
logWarn(`YouTube transcript extraction failed for ${tab.url}:`, err);
return buildYouTubeBlock({
title: tab.title || "YouTube Video",
url: tab.url,
videoId,
isActive,
error: err instanceof Error ? err.message : "Failed to extract video info",
});
}
};
// Process active tab first (if exists)
const blocks: string[] = [];
if (activeTab) {
const activeBlock = await processTab(activeTab, ACTIVE_WEB_TAB_CONTEXT_TAG);
blocks.push(activeBlock);
}
// Process normal tabs with bounded concurrency
for (let i = 0; i < normalTabs.length; i += MAX_CONCURRENCY) {
const chunk = normalTabs.slice(i, i + MAX_CONCURRENCY);
const chunkResults = await Promise.all(
chunk.map((tab) => processTab(tab, WEB_TAB_CONTEXT_TAG))
);
blocks.push(...chunkResults);
}
return blocks.join("");
}
}

View file

@ -3,6 +3,7 @@ jest.mock("./MessageRepository");
jest.mock("./ContextManager");
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
}));
jest.mock("@/chatUtils", () => ({
@ -39,10 +40,15 @@ jest.mock("@/settings/model", () => ({
getSystemPromptWithMemory: jest.fn().mockResolvedValue("Test system prompt"),
getSettings: jest.fn().mockReturnValue({}),
}));
jest.mock("@/services/webViewerService/webViewerServiceSingleton", () => ({
getWebViewerService: jest.fn(),
}));
import { ChatManager } from "./ChatManager";
import { MessageRepository } from "./MessageRepository";
import { ContextManager } from "./ContextManager";
import { ChainType } from "@/chainFactory";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import { ChatMessage, MessageContext } from "@/types/message";
import { TFile } from "obsidian";
@ -151,7 +157,10 @@ describe("ChatManager", () => {
"Hello",
"Hello",
USER_SENDER,
context,
{
...context,
webTabs: [],
},
undefined
);
expect(mockContextManager.processMessageContext).toHaveBeenCalledWith(
@ -197,6 +206,7 @@ describe("ChatManager", () => {
notes: [mockActiveFile],
urls: [],
selectedTextContexts: [],
webTabs: [],
},
undefined
);
@ -224,7 +234,10 @@ describe("ChatManager", () => {
"Hello",
"Hello",
USER_SENDER,
context,
{
...context,
webTabs: [],
},
undefined
);
});
@ -529,6 +542,7 @@ describe("ChatManager", () => {
notes: [mockActiveFile],
urls: [],
selectedTextContexts: [],
webTabs: [],
},
undefined
);
@ -606,4 +620,527 @@ describe("ChatManager", () => {
});
});
});
describe("buildWebTabsWithActiveSnapshot (via sendMessage)", () => {
const mockGetWebViewerService = getWebViewerService as jest.Mock;
beforeEach(() => {
mockGetWebViewerService.mockReset();
});
it("should include active web tab when includeActiveWebTab is true", async () => {
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://active.example.com",
title: "Active Page",
faviconUrl: "https://active.example.com/favicon.ico",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage(
"Hello",
context,
ChainType.LLM_CHAIN,
false, // includeActiveNote
true // includeActiveWebTab
);
// The webTabs should include the active tab with isActive: true
// Note: Raw URL is preserved (no normalization applied to stored URL)
expect(mockMessageRepo.addMessage).toHaveBeenCalledWith(
"Hello",
"Hello",
USER_SENDER,
expect.objectContaining({
webTabs: expect.arrayContaining([
expect.objectContaining({
url: "https://active.example.com",
isActive: true,
}),
]),
}),
undefined
);
});
it("should include active web tab when ACTIVE_WEB_TAB_MARKER is in text", async () => {
const mockMessage = createMockMessage("msg-1", "Check {activeWebTab}", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://marker.example.com",
title: "Marker Page",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
// includeActiveWebTab=false but marker in text should still trigger inclusion
// Note: Raw URL is preserved (no normalization applied to stored URL)
await chatManager.sendMessage("Check {activeWebTab}", context, ChainType.LLM_CHAIN);
expect(mockMessageRepo.addMessage).toHaveBeenCalledWith(
"Check {activeWebTab}",
"Check {activeWebTab}",
USER_SENDER,
expect.objectContaining({
webTabs: expect.arrayContaining([
expect.objectContaining({
url: "https://marker.example.com",
isActive: true,
}),
]),
}),
undefined
);
});
it("should merge active tab with existing same-URL tab", async () => {
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [
{
url: "https://same.example.com",
title: "Old Title",
},
],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://same.example.com",
title: "New Title",
faviconUrl: "https://same.example.com/new-favicon.ico",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage("Hello {activeWebTab}", context, ChainType.LLM_CHAIN);
// Should merge and not duplicate
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
expect(webTabs).toHaveLength(1);
expect(webTabs[0]).toEqual(
expect.objectContaining({
url: "https://same.example.com",
title: "New Title",
faviconUrl: "https://same.example.com/new-favicon.ico",
isActive: true,
})
);
});
it("should merge active tab when only hash fragment differs (regression test for duplicate entries)", async () => {
// Regression test: same page with different hash should NOT create duplicate entry
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [
{
url: "https://docs.example.com/guide#section1",
title: "Guide - Section 1",
},
],
};
// User navigated to a different section on the same page
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://docs.example.com/guide#section2",
title: "Guide - Section 2",
faviconUrl: "https://docs.example.com/favicon.ico",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage("Hello {activeWebTab}", context, ChainType.LLM_CHAIN);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
// Should merge (same page, different hash) - NOT create duplicate entry
expect(webTabs).toHaveLength(1);
// Active tab's URL (with its hash) is now preserved to support SPA routing
expect(webTabs[0]).toEqual(
expect.objectContaining({
url: "https://docs.example.com/guide#section2", // Active tab's URL preserved (with hash)
title: "Guide - Section 2", // Title updated from active tab
faviconUrl: "https://docs.example.com/favicon.ico",
isActive: true,
})
);
});
it("should clear multiple isActive flags and keep only active tab as active", async () => {
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [
{ url: "https://first.com", isActive: true },
{ url: "https://second.com", isActive: true },
],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://third.com",
title: "Third",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage("Hello {activeWebTab}", context, ChainType.LLM_CHAIN);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
// Only the new active tab should have isActive: true
// Note: Raw URL is preserved (no normalization applied to stored URL)
const activeTabs = webTabs.filter((t: { isActive?: boolean }) => t.isActive);
expect(activeTabs).toHaveLength(1);
expect(activeTabs[0].url).toBe("https://third.com");
});
it("should handle Web Viewer unavailable gracefully (mobile/error)", async () => {
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [{ url: "https://existing.com" }],
};
// Simulate Web Viewer service throwing (mobile scenario)
mockGetWebViewerService.mockImplementation(() => {
throw new Error("Web Viewer not available on mobile");
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
// Should not throw, should return sanitized tabs unchanged
await chatManager.sendMessage("Hello {activeWebTab}", context, ChainType.LLM_CHAIN);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
// Should still have the existing tab, just sanitized
expect(webTabs).toHaveLength(1);
expect(webTabs[0]?.url).toBe("https://existing.com");
});
it("should return sanitized tabs unchanged when no active web tab available", async () => {
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [{ url: "https://existing.com", title: "Existing" }],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: null, // No active tab
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage("Hello {activeWebTab}", context, ChainType.LLM_CHAIN);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
expect(webTabs).toHaveLength(1);
expect(webTabs[0]?.url).toBe("https://existing.com");
});
it("should not include active web tab when includeActiveWebTab is false and no marker", async () => {
const mockMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [],
webTabs: [],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://should-not-appear.com",
title: "Should Not Appear",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
// No marker in text, includeActiveWebTab defaults to false
await chatManager.sendMessage("Hello", context, ChainType.LLM_CHAIN);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
expect(webTabs).toHaveLength(0);
});
it("should suppress active web tab when web selection exists (even with marker)", async () => {
const mockMessage = createMockMessage("msg-1", "Check {activeWebTab}", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [
{
id: "web-selection-1",
sourceType: "web",
title: "Selected Page",
url: "https://selected.example.com",
content: "Some selected text from web",
},
],
webTabs: [],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://active.example.com",
title: "Active Page",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
// Even with marker in text and includeActiveWebTab=true, web selection should suppress
await chatManager.sendMessage(
"Check {activeWebTab}",
context,
ChainType.LLM_CHAIN,
false,
true // includeActiveWebTab=true
);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
// Should NOT include active web tab because web selection exists
expect(webTabs).toHaveLength(0);
expect(webTabs.find((t: { isActive?: boolean }) => t.isActive)).toBeUndefined();
});
it("should suppress active web tab when note selection exists (any selection suppresses)", async () => {
const mockMessage = createMockMessage("msg-1", "Check {activeWebTab}", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [
{
id: "note-selection-1",
sourceType: "note",
noteTitle: "Test Note",
notePath: "test.md",
startLine: 1,
endLine: 5,
content: "Some selected text from note",
},
],
webTabs: [],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://active.example.com",
title: "Active Page",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
// Any selection (including note selection) should suppress active web tab
await chatManager.sendMessage("Check {activeWebTab}", context, ChainType.LLM_CHAIN);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
// Should NOT include active web tab because note selection exists
expect(webTabs).toHaveLength(0);
expect(webTabs.find((t: { isActive?: boolean }) => t.isActive)).toBeUndefined();
});
it("should preserve existing webTabs but not inject active when web selection exists", async () => {
const mockMessage = createMockMessage("msg-1", "Check {activeWebTab}", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [
{
id: "web-selection-1",
sourceType: "web",
title: "Selected Page",
url: "https://selected.example.com",
content: "Some selected text from web",
},
],
webTabs: [
{ url: "https://existing.example.com", title: "Existing Tab" },
],
};
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://active.example.com",
title: "Active Page",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage(
"Check {activeWebTab}",
context,
ChainType.LLM_CHAIN,
false,
true
);
const addMessageCall = mockMessageRepo.addMessage.mock.calls[0];
const webTabs = addMessageCall[3]?.webTabs ?? [];
// Should preserve existing webTabs but NOT inject active tab
expect(webTabs).toHaveLength(1);
expect(webTabs[0]?.url).toBe("https://existing.example.com");
expect(webTabs.find((t: { isActive?: boolean }) => t.isActive)).toBeUndefined();
});
it("should not call getWebViewerService when web selection exists", async () => {
const mockMessage = createMockMessage("msg-1", "Check {activeWebTab}", USER_SENDER);
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [
{
id: "web-selection-1",
sourceType: "web",
title: "Selected Page",
url: "https://selected.example.com",
content: "Some selected text from web",
},
],
webTabs: [],
};
// Reset mock to track calls
mockGetWebViewerService.mockClear();
mockGetWebViewerService.mockReturnValue({
getActiveWebTabState: () => ({
activeWebTabForMentions: {
url: "https://active.example.com",
title: "Active Page",
},
}),
});
mockPlugin.app.workspace.getActiveFile.mockReturnValue(null);
mockMessageRepo.addMessage.mockReturnValue("msg-1");
mockMessageRepo.getMessage.mockReturnValue(mockMessage);
mockContextManager.processMessageContext.mockResolvedValue(createContextResult());
mockMessageRepo.updateProcessedText.mockReturnValue(true);
await chatManager.sendMessage(
"Check {activeWebTab}",
context,
ChainType.LLM_CHAIN,
false,
true
);
// getWebViewerService should NOT be called because web selection suppresses active tab
expect(mockGetWebViewerService).not.toHaveBeenCalled();
});
});
});

View file

@ -1,8 +1,8 @@
import { getSettings, getSystemPromptWithMemory } from "@/settings/model";
import { ChainType } from "@/chainFactory";
import { getCurrentProject } from "@/aiParams";
import { logInfo } from "@/logger";
import { ChatMessage, MessageContext } from "@/types/message";
import { logInfo, logWarn } from "@/logger";
import { ChatMessage, MessageContext, WebTabContext } from "@/types/message";
import { FileParserManager } from "@/tools/FileParserManager";
import ChainManager from "@/LLMProviders/chainManager";
import ProjectManager from "@/LLMProviders/projectManager";
@ -11,8 +11,14 @@ import CopilotPlugin from "@/main";
import { ContextManager } from "./ContextManager";
import { MessageRepository } from "./MessageRepository";
import { ChatPersistenceManager } from "./ChatPersistenceManager";
import { USER_SENDER } from "@/constants";
import { ACTIVE_WEB_TAB_MARKER, USER_SENDER } from "@/constants";
import { TFile } from "obsidian";
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
import {
normalizeUrlForMatching,
normalizeUrlString,
sanitizeWebTabContexts,
} from "@/utils/urlNormalization";
/**
* ChatManager - Central business logic coordinator
@ -112,6 +118,94 @@ export class ChatManager {
return basePrompt;
}
/**
* Build webTabs array with Active Web Tab snapshot injected.
*
* This implements snapshot semantics:
* - Active Web Tab URL is resolved at message creation time
* - The URL is stored in message.context.webTabs with isActive: true
* - Edit/reprocess will use the stored URL, not the current active tab
*
* @param existingWebTabs - Existing webTabs from context
* @param shouldIncludeActiveWebTab - Pre-computed flag for whether to include active web tab
* @returns Updated webTabs array with active tab snapshot
*/
private buildWebTabsWithActiveSnapshot(
existingWebTabs: WebTabContext[],
shouldIncludeActiveWebTab: boolean
): WebTabContext[] {
// Always sanitize existing webTabs (normalize URLs, dedupe, ensure single isActive)
const sanitizedTabs = sanitizeWebTabContexts(existingWebTabs);
if (!shouldIncludeActiveWebTab) {
return sanitizedTabs;
}
try {
// Get active web tab from WebViewerService
// Use activeWebTabForMentions to match UI behavior:
// - Preserved only when switching directly to chat panel
// - Cleared when switching to other views (e.g., note tab)
const service = getWebViewerService(this.plugin.app);
const state = service.getActiveWebTabState();
const activeTab = state.activeWebTabForMentions;
const activeUrl = normalizeUrlForMatching(activeTab?.url);
if (!activeUrl) {
// No active web tab available, return sanitized tabs unchanged
return sanitizedTabs;
}
// Clear any existing isActive flags to ensure only one active tab
const clearedTabs: WebTabContext[] = sanitizedTabs.map((tab) => {
if (tab.isActive) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isActive: _unused, ...rest } = tab;
return rest;
}
return tab;
});
// Check if active URL already exists in the list (using normalized matching)
const existingIndex = clearedTabs.findIndex(
(tab) => normalizeUrlForMatching(tab.url) === activeUrl
);
if (existingIndex >= 0) {
// Merge metadata and mark as active
// Prefer activeTab.url to preserve hash fragments for SPA routing
// Use normalizeUrlString to trim whitespace while keeping hash/query intact
const existing = clearedTabs[existingIndex];
clearedTabs[existingIndex] = {
...existing,
url: normalizeUrlString(activeTab?.url) ?? existing.url,
title: activeTab?.title ?? existing.title,
faviconUrl: activeTab?.faviconUrl ?? existing.faviconUrl,
isActive: true,
};
return clearedTabs;
}
// Add new active tab entry
// Store the raw URL to preserve hash fragments and query params for SPA routing
// Use normalizeUrlString to trim whitespace while keeping hash/query intact
// (activeUrl is only used for comparison/deduplication above)
return [
...clearedTabs,
{
url: normalizeUrlString(activeTab?.url) ?? activeUrl,
title: activeTab?.title,
faviconUrl: activeTab?.faviconUrl,
isActive: true,
},
];
} catch (error) {
// Web Viewer not available (e.g., mobile platform) - don't fail the message
logWarn("[ChatManager] Failed to resolve active web tab:", error);
return sanitizedTabs;
}
}
/**
* Send a new message with context processing
*/
@ -120,6 +214,7 @@ export class ChatManager {
context: MessageContext,
chainType: ChainType,
includeActiveNote: boolean = false,
includeActiveWebTab: boolean = false,
content?: any[]
): Promise<string> {
try {
@ -137,6 +232,19 @@ export class ChatManager {
updatedContext.notes = hasActiveNote ? existingNotes : [...existingNotes, activeNote];
}
// Inject Active Web Tab snapshot if requested (快照语义)
// This resolves the active web tab URL at message creation time
// Compute shouldIncludeActiveWebTab: either explicitly requested or via marker in text
// BUT: any selection takes priority and suppresses active tab to avoid redundant context
const hasAnySelection = (updatedContext.selectedTextContexts || []).length > 0;
const shouldIncludeActiveWebTab =
!hasAnySelection &&
(includeActiveWebTab || displayText.includes(ACTIVE_WEB_TAB_MARKER));
updatedContext.webTabs = this.buildWebTabsWithActiveSnapshot(
updatedContext.webTabs || [],
shouldIncludeActiveWebTab
);
// Create the message with initial content
const currentRepo = this.getCurrentMessageRepo();
const messageId = currentRepo.addMessage(
@ -186,6 +294,13 @@ export class ChatManager {
/**
* Edit an existing message
*
* Design note: This method only updates the message text, not the context (notes/urls/webTabs/etc).
* The original context is preserved because:
* 1. Context represents the state at message creation time (which files/URLs were referenced)
* 2. Allowing context modification would require complex UI for editing pills/references
* 3. The reprocessMessageContext() call below re-fetches content using the ORIGINAL context,
* ensuring the LLM sees fresh content from the same sources
*/
async editMessage(
messageId: string,
@ -196,7 +311,7 @@ export class ChatManager {
try {
logInfo(`[ChatManager] Editing message ${messageId}: "${newText}"`);
// Edit the message (this marks it for context reprocessing)
// Edit the message text only - context remains unchanged (see design note above)
const currentRepo = this.getCurrentMessageRepo();
const editSuccess = currentRepo.editMessage(messageId, newText);
if (!editSuccess) {
@ -464,12 +579,22 @@ export class ChatManager {
/**
* Load chat history from a file
*
* Design note: This method does NOT reprocess context (URLs/webTabs content fetching) for loaded messages.
* This is intentional because:
* 1. Historical messages represent a past conversation state - refetching would alter the original context
* 2. URL content may have changed since the original conversation
* 3. WebTabs are ephemeral - the tabs referenced in history are likely closed
* 4. Performance - loading history should be fast, not trigger network requests
*
* The persisted chat only stores references (file paths, URLs) not the fetched content.
* If the user wants fresh content, they should start a new conversation or regenerate specific messages.
*/
async loadChatHistory(file: TFile): Promise<void> {
// Clear current messages first
this.clearMessages();
// Load messages using ChatPersistenceManager
// Load messages from file - only restores message text and context references (not fetched content)
const messages = await this.persistenceManager.loadChat(file);
// Add messages to the current repository

View file

@ -212,6 +212,10 @@ export class ChatPersistenceManager {
contextParts.push(`URLs: ${message.context.urls.join(", ")}`);
}
if (message.context.webTabs?.length) {
contextParts.push(`Web Tabs: ${message.context.webTabs.map((tab) => tab.url).join(", ")}`);
}
if (message.context.tags?.length) {
contextParts.push(`Tags: ${message.context.tags.join(", ")}`);
}
@ -321,6 +325,7 @@ export class ChatPersistenceManager {
urls: [],
tags: [],
folders: [],
webTabs: [],
};
// Split by | to get different context types
@ -375,6 +380,17 @@ export class ChatPersistenceManager {
if (urlsStr) {
context.urls = urlsStr.split(", ").map((url) => url.trim());
}
} else if (trimmed.startsWith("Web Tabs: ") || trimmed.startsWith("WebTabs: ")) {
const webTabsStr = trimmed.startsWith("Web Tabs: ")
? trimmed.substring(10) // Remove "Web Tabs: "
: trimmed.substring(9); // Remove "WebTabs: "
if (webTabsStr) {
context.webTabs = webTabsStr
.split(", ")
.map((url) => url.trim())
.filter((url) => url.length > 0)
.map((url) => ({ url }));
}
} else if (trimmed.startsWith("Tags: ")) {
const tagsStr = trimmed.substring(6); // Remove "Tags: "
if (tagsStr) {
@ -393,7 +409,8 @@ export class ChatPersistenceManager {
context.notes.length > 0 ||
context.urls.length > 0 ||
context.tags.length > 0 ||
context.folders.length > 0
context.folders.length > 0 ||
context.webTabs.length > 0
) {
return context;
}

View file

@ -182,7 +182,11 @@ export class ContextManager {
// 7. Process selected text contexts
const selectedTextContextAddition = this.contextProcessor.processSelectedTextContexts();
// 8. Combine everything (L2 previous context, then L3 current turn context)
// 8. Process web tab contexts (L3 - current turn only)
const webTabs = message.context?.webTabs || [];
const webTabContextAddition = await this.contextProcessor.processContextWebTabs(webTabs);
// 9. Combine everything (L2 previous context, then L3 current turn context)
const finalProcessedMessage =
processedUserMessage +
l2Context +
@ -190,7 +194,8 @@ export class ContextManager {
tagContextAddition +
folderContextAddition +
urlContextAddition.urlContext +
selectedTextContextAddition;
selectedTextContextAddition +
webTabContextAddition;
logInfo(`[ContextManager] Successfully processed context for message ${message.id}`);
const contextEnvelope = this.buildPromptContextEnvelope({
@ -204,6 +209,7 @@ export class ContextManager {
folderContextAddition,
urlContext: urlContextAddition.urlContext,
selectedText: selectedTextContextAddition,
webTabContext: webTabContextAddition,
});
return {
@ -418,6 +424,9 @@ export class ContextManager {
this.appendTurnContextSegment(turnSegments, "selected_text", params.selectedText, {
source: "selected_text",
});
this.appendTurnContextSegment(turnSegments, "web_tabs", params.webTabContext, {
source: "web_tabs",
});
if (turnSegments.length > 0) {
layerSegments.L3_TURN = turnSegments;
@ -570,4 +579,5 @@ interface BuildPromptContextEnvelopeParams {
folderContextAddition: string;
urlContext: string;
selectedText: string;
webTabContext: string;
}

View file

@ -1,5 +1,5 @@
import { MessageRepository } from "./MessageRepository";
import { USER_SENDER, SELECTED_TEXT_TAG } from "@/constants";
import { USER_SENDER, SELECTED_TEXT_TAG, WEB_SELECTED_TEXT_TAG } from "@/constants";
import { MessageContext } from "@/types/message";
import { TFile } from "obsidian";
@ -127,6 +127,7 @@ The landscape of artificial intelligence continues to evolve rapidly. Here are t
selectedTextContexts: [
{
id: "sel-1",
sourceType: "note",
content: `function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
@ -189,6 +190,7 @@ function fibonacci(n) {
selectedTextContexts: [
{
id: "sel-2",
sourceType: "note",
content:
"The Single Responsibility Principle states that a class should have only one reason to change.",
noteTitle: "SOLID Principles",
@ -295,4 +297,136 @@ The Single Responsibility Principle states that a class should have only one rea
expect(fullMessage?.message).toContain("<error>[Error: Could not process file]</error>");
expect(fullMessage?.message).toContain("</note_context_error>");
});
it("should format web selected text with proper XML tags", () => {
const userMessage = "Summarize this web content";
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [
{
id: "web-sel-1",
sourceType: "web",
content: `# Getting Started with React
React is a JavaScript library for building user interfaces.
## Key Concepts
- Components
- Props
- State`,
title: "React Documentation",
url: "https://react.dev/learn",
},
],
};
const processedText = `Summarize this web content
<web_selected_text>
<title>React Documentation</title>
<url>https://react.dev/learn</url>
<content>
# Getting Started with React
React is a JavaScript library for building user interfaces.
## Key Concepts
- Components
- Props
- State
</content>
</web_selected_text>`;
const messageId = messageRepository.addMessage(
userMessage,
processedText,
USER_SENDER,
context
);
// Get the full message with processed context
const fullMessage = messageRepository.getLLMMessage(messageId);
expect(fullMessage?.message).toContain(`<${WEB_SELECTED_TEXT_TAG}>`);
expect(fullMessage?.message).toContain("<title>React Documentation</title>");
expect(fullMessage?.message).toContain("<url>https://react.dev/learn</url>");
expect(fullMessage?.message).toContain("<content>");
expect(fullMessage?.message).toContain("Getting Started with React");
expect(fullMessage?.message).toContain("</content>");
expect(fullMessage?.message).toContain(`</${WEB_SELECTED_TEXT_TAG}>`);
// Should NOT contain note-specific tags
expect(fullMessage?.message).not.toContain("<path>");
expect(fullMessage?.message).not.toContain("<start_line>");
expect(fullMessage?.message).not.toContain("<end_line>");
});
it("should handle mixed note and web selected text contexts", () => {
const userMessage = "Compare these selections";
const context: MessageContext = {
notes: [],
urls: [],
selectedTextContexts: [
{
id: "note-sel-1",
sourceType: "note",
content: "Local note content about React patterns",
noteTitle: "React Patterns",
notePath: "dev/react-patterns.md",
startLine: 15,
endLine: 20,
},
{
id: "web-sel-1",
sourceType: "web",
content: "Web content about React best practices",
title: "React Best Practices",
url: "https://react.dev/best-practices",
},
],
};
const processedText = `Compare these selections
<selected_text>
<title>React Patterns</title>
<path>dev/react-patterns.md</path>
<start_line>15</start_line>
<end_line>20</end_line>
<content>
Local note content about React patterns
</content>
</selected_text>
<web_selected_text>
<title>React Best Practices</title>
<url>https://react.dev/best-practices</url>
<content>
Web content about React best practices
</content>
</web_selected_text>`;
const messageId = messageRepository.addMessage(
userMessage,
processedText,
USER_SENDER,
context
);
// Get the full message with processed context
const fullMessage = messageRepository.getLLMMessage(messageId);
const message = fullMessage!.message;
// Verify both context types are present with proper tags
expect(message).toContain(`<${SELECTED_TEXT_TAG}>`);
expect(message).toContain(`</${SELECTED_TEXT_TAG}>`);
expect(message).toContain(`<${WEB_SELECTED_TEXT_TAG}>`);
expect(message).toContain(`</${WEB_SELECTED_TEXT_TAG}>`);
// Verify note selection has path and line numbers
expect(message).toContain("<path>dev/react-patterns.md</path>");
expect(message).toContain("<start_line>15</start_line>");
// Verify web selection has url but no path/line numbers
expect(message).toContain("<url>https://react.dev/best-practices</url>");
});
});

View file

@ -37,9 +37,16 @@ export function useChatManager(chatUIState: ChatUIState) {
displayText: string,
context: MessageContext,
chainType: ChainType,
includeActiveNote: boolean = false
includeActiveNote: boolean = false,
includeActiveWebTab: boolean = false
): Promise<string> => {
return await chatUIState.sendMessage(displayText, context, chainType, includeActiveNote);
return await chatUIState.sendMessage(
displayText,
context,
chainType,
includeActiveNote,
includeActiveWebTab
);
},
[chatUIState]
);

View file

@ -1,7 +1,10 @@
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ProjectManager from "@/LLMProviders/projectManager";
import { CustomModel, getCurrentProject, setSelectedTextContexts } from "@/aiParams";
import { SelectedTextContext } from "@/types/message";
import { CustomModel, getCurrentProject, setSelectedTextContexts, getSelectedTextContexts } from "@/aiParams";
import {
NoteSelectedTextContext,
SelectedTextContext,
} from "@/types/message";
import { registerCommands } from "@/commands";
import CopilotView from "@/components/CopilotView";
import { APPLY_VIEW_TYPE, ApplyView } from "@/components/composer/ApplyView";
@ -21,6 +24,11 @@ import { logFileManager } from "@/logFileManager";
import { UserMemoryManager } from "@/memory/UserMemoryManager";
import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
import { checkIsPlusUser } from "@/plusUtils";
import {
getWebViewerService,
startActiveWebTabTracking,
} from "@/services/webViewerService/webViewerServiceSingleton";
import { WebSelectionTracker } from "@/services/webViewerService/webViewerServiceSelection";
import VectorStoreManager from "@/search/vectorStoreManager";
import { CopilotSettingTab } from "@/settings/SettingsPage";
import {
@ -39,6 +47,7 @@ import {
MarkdownView,
Menu,
Notice,
Platform,
Plugin,
TFile,
TFolder,
@ -63,6 +72,7 @@ export default class CopilotPlugin extends Plugin {
userMemoryManager: UserMemoryManager;
private selectionDebounceTimer?: number;
private selectionChangeHandler?: () => void;
private webSelectionTracker?: WebSelectionTracker;
async onload(): Promise<void> {
await this.loadSettings();
@ -109,6 +119,17 @@ export default class CopilotPlugin extends Plugin {
// Initialize UserMemoryManager
this.userMemoryManager = new UserMemoryManager(this.app);
// Single source of truth for Active Web Tab ({activeWebTab}) state
// Preserves activeWebTab when switching to Chat view
// Only run on desktop - Web Viewer is not available on mobile
if (Platform.isDesktopApp) {
const { activeLeafRef, layoutRef } = startActiveWebTabTracking(this.app, {
preserveOnViewTypes: [CHAT_VIEWTYPE],
});
this.registerEvent(activeLeafRef);
this.registerEvent(layoutRef);
}
this.registerView(CHAT_VIEWTYPE, (leaf: WorkspaceLeaf) => new CopilotView(leaf, this));
this.registerView(APPLY_VIEW_TYPE, (leaf: WorkspaceLeaf) => new ApplyView(leaf));
@ -167,6 +188,9 @@ export default class CopilotPlugin extends Plugin {
// Initialize automatic selection handler
this.initSelectionHandler();
// Initialize web selection watcher (Desktop only)
this.initWebSelectionWatcher();
}
async onunload() {
@ -183,8 +207,17 @@ export default class CopilotPlugin extends Plugin {
// Cleanup selection handler
this.cleanupSelectionHandler();
this.cleanupWebSelectionWatcher();
this.clearSelectionContext();
// Cleanup Web Viewer state tracking (webview event listeners)
try {
const webViewerService = getWebViewerService(this.app);
webViewerService.stopActiveWebTabTracking();
} catch {
// Ignore errors if service not available
}
// Best-effort flush of log file
await logFileManager.flush();
logInfo("Copilot plugin unloaded");
@ -298,7 +331,21 @@ export default class CopilotPlugin extends Plugin {
}
/**
* Stores the provided selection as the active selected text context
* Clears the auto-selected web text context for a specific URL.
* Preserves contexts from other sourceTypes and other URLs.
*/
private clearWebSelectionContextForUrl(url: string): void {
const current = getSelectedTextContexts();
const next = current.filter((c) => c.sourceType !== "web" || c.url !== url);
if (next.length === current.length) {
return;
}
setSelectedTextContexts(next);
}
/**
* Stores the provided selection as the active selected text context.
* Only keeps the latest selection - note and web selections are mutually exclusive.
*/
private setSelectionContext(context: SelectedTextContext) {
setSelectedTextContexts([context]);
@ -311,7 +358,7 @@ export default class CopilotPlugin extends Plugin {
handleSelectionChange() {
// Check if auto-inclusion is enabled
const settings = getSettings();
if (!settings.autoIncludeTextSelection) {
if (!settings.autoAddSelectionToContext) {
return;
}
@ -345,9 +392,10 @@ export default class CopilotPlugin extends Plugin {
const endLine = Math.max(anchorLine, headLine);
// Create selected text context
const selectedTextContext: SelectedTextContext = {
const selectedTextContext: NoteSelectedTextContext = {
id: uuidv4(),
content: selectedText,
sourceType: "note",
noteTitle: activeFile.basename,
notePath: activeFile.path,
startLine,
@ -357,6 +405,58 @@ export default class CopilotPlugin extends Plugin {
this.setSelectionContext(selectedTextContext);
}
/**
* Initialize web selection watcher for auto-adding web tab selections.
* Desktop only - uses WebSelectionTracker with self-scheduling pattern.
*/
initWebSelectionWatcher() {
// Only run on desktop
if (!Platform.isDesktopApp) {
return;
}
const webViewerService = getWebViewerService(this.app);
this.webSelectionTracker = new WebSelectionTracker({
intervalMs: 500,
emptySelectionDebounceCount: 2,
isEnabled: () => getSettings().autoAddSelectionToContext,
getLeaf: () => webViewerService.getActiveLeaf() ?? webViewerService.getLastActiveLeaf(),
getActiveLeaf: () => webViewerService.getActiveLeaf(),
onSelectionChange: (context) => {
// Use symmetric update strategy via setSelectionContext
this.setSelectionContext(context);
},
onSelectionClear: ({ url }) => {
this.clearWebSelectionContextForUrl(url);
},
});
this.webSelectionTracker.start();
}
/**
* Clean up web selection watcher
*/
cleanupWebSelectionWatcher() {
this.webSelectionTracker?.stop();
this.webSelectionTracker = undefined;
}
/**
* Suppress the current web selection so it won't be auto-captured again until it changes or is cleared.
* Called by UI when user removes web selection or starts a new chat.
* @param url - Optional URL to suppress (prevents leaf-binding issues when lastActiveLeaf has changed)
*/
suppressCurrentWebSelection(url?: string): void {
if (url && url.trim()) {
this.webSelectionTracker?.suppressSelectionForUrl(url);
return;
}
this.webSelectionTracker?.suppressCurrentSelection();
}
private getCurrentEditorOrDummy(): Editor {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
return {

View file

@ -0,0 +1,383 @@
/**
* Web Viewer Service - Obsidian Web Viewer API wrapper
*
* Thin orchestration layer delegating leaf/webview operations to webViewerServiceActions.ts.
* State management is delegated to WebViewerStateManager.
* Note: Web Viewer is an internal API surface that may change without notice.
* Desktop-only (depends on Electron webview).
*/
import type { App } from "obsidian";
import { Platform } from "obsidian";
import { logError, logWarn } from "@/logger";
import * as actions from "@/services/webViewerService/webViewerServiceActions";
import {
getCommandManager,
getInternalWebViewerPluginApi,
isCommandRegistered,
toErrorMessage,
waitFor,
} from "@/services/webViewerService/webViewerServiceHelpers";
import { WebViewerStateManager } from "@/services/webViewerService/webViewerServiceState";
import {
type ActiveWebTabStateListener,
type ActiveWebTabStateSnapshot,
type ActiveWebTabTrackingRefs,
isWebViewerLeaf,
type ResolveLeafOptions,
type SaveToVaultResult,
type StartActiveWebTabTrackingOptions,
WEB_VIEWER_COMMANDS,
WEB_VIEWER_VIEW_TYPE,
type WebViewerAvailability,
type WebViewerCommandId,
WebViewerError,
type WebViewerLeaf,
WebViewerLeafNotFoundError,
type WebViewerPageInfo,
type WebViewerPluginApi,
WebViewerUnsupportedError,
} from "@/services/webViewerService/webViewerServiceTypes";
// ============================================================================
// WebViewerService Class
// ============================================================================
/**
* Service that encapsulates all Web Viewer API operations.
*/
export class WebViewerService {
private readonly app: App;
private internalPluginApi: WebViewerPluginApi | null = null;
// State manager handles Active Web Tab state and leaf tracking
private readonly stateManager: WebViewerStateManager;
constructor(app: App) {
this.app = app;
// Initialize state manager with dependency injection
this.stateManager = new WebViewerStateManager({
app,
isSupportedPlatform: () => this.isSupportedPlatform(),
getActiveLeaf: () => this.getActiveLeaf(),
getLeaves: () => this.getLeaves(),
getPageInfo: (leaf) => actions.getPageInfo(leaf),
});
}
// --------------------------------------------------------------------------
// Platform & Availability
// --------------------------------------------------------------------------
/** Check if the current platform supports Web Viewer (desktop only). */
isSupportedPlatform(): boolean {
return Platform.isDesktopApp === true;
}
/** Get detailed Web Viewer availability information. */
getAvailability(): WebViewerAvailability {
const platform: WebViewerAvailability["platform"] = this.isSupportedPlatform()
? "desktop"
: "mobile";
if (!this.isSupportedPlatform()) {
return {
supported: false,
available: false,
platform,
reason: "Web Viewer is not supported on mobile platforms.",
};
}
const leaves = this.getLeaves();
if (leaves.length > 0) {
return { supported: true, available: true, platform };
}
const api = this.getInternalPluginApi();
if (api) {
return { supported: true, available: true, platform };
}
const hasWebViewerCommands = isCommandRegistered(this.app, WEB_VIEWER_COMMANDS.OPEN);
if (hasWebViewerCommands) {
return {
supported: true,
available: true,
platform,
reason: "No Web Viewer leaves open, but Web Viewer commands are registered.",
};
}
return {
supported: true,
available: false,
platform,
reason: "Web Viewer does not appear available.",
};
}
/** Throw if Web Viewer is not available. */
assertAvailable(): void {
const availability = this.getAvailability();
if (!availability.supported) {
throw new WebViewerUnsupportedError(availability.reason ?? "Web Viewer unsupported.");
}
if (!availability.available) {
throw new WebViewerError(availability.reason ?? "Web Viewer is not available.");
}
}
// --------------------------------------------------------------------------
// Leaf Management
// --------------------------------------------------------------------------
/** Get all currently open Web Viewer leaves. */
getLeaves(): WebViewerLeaf[] {
if (!this.isSupportedPlatform()) return [];
return this.app.workspace.getLeavesOfType(WEB_VIEWER_VIEW_TYPE) as WebViewerLeaf[];
}
/** Get the currently active Web Viewer leaf (if any). */
getActiveLeaf(): WebViewerLeaf | null {
const leaf = this.app.workspace.activeLeaf;
return isWebViewerLeaf(leaf) ? leaf : null;
}
/** Get the most recently active Web Viewer leaf tracked by this service. */
getLastActiveLeaf(): WebViewerLeaf | null {
return this.stateManager.getLastActiveLeaf();
}
/** Resolve a Web Viewer leaf according to strategy. */
async resolveLeaf(options: ResolveLeafOptions = {}): Promise<WebViewerLeaf> {
this.assertAvailable();
const {
strategy = "active-or-last",
focus = false,
requireWebviewReady = false,
timeoutMs = 15_000,
} = options;
const active = this.getActiveLeaf();
if (active) {
if (focus) this.app.workspace.setActiveLeaf(active, { focus: true });
if (requireWebviewReady) await this.waitForWebviewReady(active, timeoutMs);
return active;
}
if (strategy === "active-or-last" || strategy === "active-or-last-or-any") {
const last = this.getLastActiveLeaf();
if (last) {
if (focus) this.app.workspace.setActiveLeaf(last, { focus: true });
if (requireWebviewReady) await this.waitForWebviewReady(last, timeoutMs);
return last;
}
}
if (strategy === "active-or-last-or-any") {
const anyLeaf = this.getLeaves()[0];
if (anyLeaf) {
if (focus) this.app.workspace.setActiveLeaf(anyLeaf, { focus: true });
if (requireWebviewReady) await this.waitForWebviewReady(anyLeaf, timeoutMs);
return anyLeaf;
}
}
throw new WebViewerLeafNotFoundError("No Web Viewer leaf found.");
}
/**
* Wait until the webview is mounted and first load finished.
* If the fields don't exist (undefined), assume ready (fallback for older Obsidian versions).
*/
async waitForWebviewReady(leaf: WebViewerLeaf, timeoutMs: number): Promise<void> {
const view = leaf.view as { webviewMounted?: boolean; webviewFirstLoadFinished?: boolean };
// Fallback: if fields don't exist, assume ready (older Obsidian versions)
if (view.webviewMounted === undefined || view.webviewFirstLoadFinished === undefined) {
return;
}
if (view.webviewMounted && view.webviewFirstLoadFinished) return;
await waitFor(
() => Boolean(view.webviewMounted && view.webviewFirstLoadFinished),
timeoutMs,
100,
"Waiting for Web Viewer webview ready"
);
}
// --------------------------------------------------------------------------
// State Manager Delegation (Active Web Tab)
// --------------------------------------------------------------------------
/**
* Find a Web Viewer leaf by URL (best-effort normalized match).
* @param url - The URL to search for
* @param options - Optional disambiguation hints
* @param options.title - Page title hint to help disambiguate multiple URL matches
*/
findLeafByUrl(url: string, options: { title?: string } = {}): WebViewerLeaf | null {
return this.stateManager.findLeafByUrl(url, options);
}
/** Get the current Active Web Tab state snapshot. */
getActiveWebTabState(): ActiveWebTabStateSnapshot {
return this.stateManager.getActiveWebTabState();
}
/** Subscribe to Active Web Tab state updates. */
subscribeActiveWebTabState(listener: ActiveWebTabStateListener): () => void {
return this.stateManager.subscribeActiveWebTabState(listener);
}
/**
* Subscribe to webview load events.
* Called when any Web Viewer tab finishes loading (did-finish-load event).
* Useful for refreshing tab metadata (title, favicon, etc.) after page load.
* @returns Unsubscribe function
*/
subscribeToWebviewLoad(callback: () => void): () => void {
return this.stateManager.subscribeToWebviewLoad(callback);
}
/**
* Start tracking Active Web Tab state using workspace events.
* Call this in plugin onload() and register the returned EventRefs.
*/
startActiveWebTabTracking(
options: StartActiveWebTabTrackingOptions = {}
): ActiveWebTabTrackingRefs {
return this.stateManager.startActiveWebTabTracking(options);
}
/** Stop tracking Active Web Tab state and clean up event refs. */
stopActiveWebTabTracking(): void {
this.stateManager.stopActiveWebTabTracking();
}
// --------------------------------------------------------------------------
// Commands
// --------------------------------------------------------------------------
/** Execute a Web Viewer command by ID. */
async executeCommand(
id: WebViewerCommandId,
options: { leaf?: WebViewerLeaf; focusLeaf?: boolean } = {}
): Promise<void> {
const cm = getCommandManager(this.app);
if (!cm) throw new WebViewerError("Command manager unavailable.");
const { leaf, focusLeaf = false } = options;
if (leaf && focusLeaf) this.app.workspace.setActiveLeaf(leaf, { focus: true });
try {
const result = cm.executeCommandById(id);
if (result === false) throw new WebViewerError(`Command returned false: ${id}`);
} catch (err) {
logError(`Failed to execute command ${id}:`, err);
throw new WebViewerError(`Failed to execute command ${id}: ${toErrorMessage(err)}`);
}
}
// --------------------------------------------------------------------------
// Internal Plugin API
// --------------------------------------------------------------------------
/** Flag to avoid repeated warnings about internal API structure issues */
private internalApiWarned = false;
/**
* Get the internal Web Viewer plugin API if available.
* Only caches successful results - allows retry if API was not found.
*/
getInternalPluginApi(): WebViewerPluginApi | null {
// Return cached API if already found
if (this.internalPluginApi) return this.internalPluginApi;
const api = getInternalWebViewerPluginApi(this.app, () => {
if (!this.internalApiWarned) {
this.internalApiWarned = true;
logWarn(
"[WebViewerService] internalPlugins.plugins has unexpected structure. " +
"Web Viewer integration may not work correctly."
);
}
});
if (api) {
this.internalPluginApi = api;
}
return api;
}
// --------------------------------------------------------------------------
// Content Extraction, Navigation, View Controls (delegated to actions)
// --------------------------------------------------------------------------
getPageInfo(leaf: WebViewerLeaf): WebViewerPageInfo {
return actions.getPageInfo(leaf);
}
async getReaderModeMarkdown(
leaf: WebViewerLeaf,
options?: { signal?: AbortSignal }
): Promise<string> {
return actions.getReaderModeMarkdown(leaf, options);
}
async getSelectedText(leaf: WebViewerLeaf, trim = true): Promise<string> {
return actions.getSelectedText(leaf, trim);
}
async getSelectedMarkdown(leaf: WebViewerLeaf): Promise<string> {
return actions.getSelectedMarkdown(leaf);
}
async getPageMarkdown(leaf: WebViewerLeaf): Promise<string> {
return actions.getPageMarkdown(leaf);
}
async getHtml(leaf: WebViewerLeaf, includeDocumentElement = true): Promise<string> {
return actions.getHtml(leaf, includeDocumentElement);
}
// --------------------------------------------------------------------------
// YouTube Transcript Extraction (delegated to actions)
// --------------------------------------------------------------------------
/**
* Extract YouTube video ID from various URL formats.
* @returns video ID or null if not a valid YouTube video URL
*/
getYouTubeVideoId(url: string): string | null {
return actions.getYouTubeVideoId(url);
}
/** Check if URL is a YouTube video page */
isYouTubeVideoUrl(url: string): boolean {
return actions.isYouTubeVideoUrl(url);
}
/**
* Extract YouTube video transcript via DOM manipulation.
* Automatically clicks the transcript button if needed and closes the panel after extraction.
*/
async getYouTubeTranscript(
leaf: WebViewerLeaf,
options?: { timeoutMs?: number }
): Promise<actions.YouTubeTranscriptResult> {
return actions.getYouTubeTranscript(leaf, options);
}
// --------------------------------------------------------------------------
// Save (delegated to actions)
// --------------------------------------------------------------------------
async saveToVault(
leaf: WebViewerLeaf,
options: { preferCommand?: boolean; focusLeafBeforeCommand?: boolean } = {}
): Promise<SaveToVaultResult> {
return actions.saveToVault(leaf, (id, opts) => this.executeCommand(id, opts), options);
}
}

View file

@ -0,0 +1,510 @@
/**
* Web Viewer Service Actions
*
* Stateless (function-based) implementations for operating on a Web Viewer leaf/webview.
* These functions do not hold state and receive all dependencies as parameters.
*/
import { logError, logInfo, logWarn } from "@/logger";
import {
requireWebview,
type SaveToVaultResult,
WEB_VIEWER_COMMANDS,
type WebViewerCommandId,
type WebViewerLeaf,
type WebViewerPageInfo,
WebViewerTimeoutError,
} from "@/services/webViewerService/webViewerServiceTypes";
import { htmlToMarkdown, toStringSafe } from "@/services/webViewerService/webViewerServiceHelpers";
// ============================================================================
// Action Function Types
// ============================================================================
/** Function type for executing Web Viewer commands. */
export type ExecuteWebViewerCommand = (
id: WebViewerCommandId,
options?: { leaf?: WebViewerLeaf; focusLeaf?: boolean }
) => Promise<void>;
// ============================================================================
// Content Extraction
// ============================================================================
/** Get basic page info from a Web Viewer leaf. */
export function getPageInfo(leaf: WebViewerLeaf): WebViewerPageInfo {
return {
url: typeof leaf.view?.url === "string" ? leaf.view.url : "",
title: typeof leaf.view?.title === "string" ? leaf.view.title : "",
faviconUrl: typeof leaf.view?.faviconUrl === "string" ? leaf.view.faviconUrl : "",
mode: leaf.view?.mode === "reader" ? "reader" : "webview",
};
}
/**
* Get Reader-mode Markdown content for the current page.
* @param leaf - The Web Viewer leaf to extract content from
* @param options - Optional configuration
* @param options.signal - AbortSignal to cancel the operation
*/
export async function getReaderModeMarkdown(
leaf: WebViewerLeaf,
options: { signal?: AbortSignal } = {}
): Promise<string> {
const { signal } = options;
// Check if already aborted before starting
if (signal?.aborted) {
throw new WebViewerTimeoutError("Operation was aborted");
}
try {
const contentPromise = Promise.resolve(leaf.view.getReaderModeContent());
// If no signal provided, just await the promise directly
if (!signal) {
const content = await contentPromise;
return typeof content?.md === "string" ? content.md : "";
}
// Race between content fetch and abort signal
const content = await new Promise<{ md?: string } | undefined>((resolve, reject) => {
const abortHandler = () => {
reject(new WebViewerTimeoutError("Operation was aborted"));
};
// Listen for abort
signal.addEventListener("abort", abortHandler, { once: true });
contentPromise
.then((result) => {
signal.removeEventListener("abort", abortHandler);
resolve(result);
})
.catch((err) => {
signal.removeEventListener("abort", abortHandler);
reject(err);
});
});
return typeof content?.md === "string" ? content.md : "";
} catch (err) {
// Re-throw timeout errors as-is
if (err instanceof WebViewerTimeoutError) {
throw err;
}
logError("Failed to get reader mode content:", err);
throw err;
}
}
/** Get selected text inside the embedded page (webview). */
export async function getSelectedText(leaf: WebViewerLeaf, trim = true): Promise<string> {
const webview = requireWebview(leaf);
const code = `(() => { try { return window.getSelection?.()?.toString?.() ?? ""; } catch { return ""; } })()`;
try {
const raw = await webview.executeJavaScript(code);
const text = toStringSafe(raw);
return trim ? text.trim() : text;
} catch (err) {
logError("Failed to get selected text:", err);
throw err;
}
}
/**
* Get selected content as Markdown (with images/links preserved).
* Uses Turndown to convert HTML to Markdown.
*/
export async function getSelectedMarkdown(leaf: WebViewerLeaf): Promise<string> {
const webview = requireWebview(leaf);
// Get base URL for resolving relative paths
let baseUrl = "";
try {
baseUrl = typeof webview.getURL === "function" ? webview.getURL() : "";
} catch {
baseUrl = leaf.view?.url ?? "";
}
// Get selection HTML from webview
const code = `(() => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return "";
const div = document.createElement("div");
for (let i = 0; i < sel.rangeCount; i++) div.appendChild(sel.getRangeAt(i).cloneContents());
return div.innerHTML;
})()`;
let html = "";
try {
const raw = await webview.executeJavaScript(code);
html = toStringSafe(raw);
} catch (err) {
logError("Failed to get selection HTML:", err);
throw err;
}
try {
return htmlToMarkdown(html, baseUrl);
} catch (err) {
logError("Failed to convert HTML to Markdown:", err);
throw err;
}
}
/**
* Get the entire page content as Markdown.
* Uses Turndown to convert the full page HTML to Markdown.
* Unlike getReaderModeMarkdown which uses Obsidian's reader mode extraction,
* this method converts the raw DOM content directly.
*/
export async function getPageMarkdown(leaf: WebViewerLeaf): Promise<string> {
const webview = requireWebview(leaf);
// Get base URL for resolving relative paths
let baseUrl = "";
try {
baseUrl = typeof webview.getURL === "function" ? webview.getURL() : "";
} catch {
baseUrl = leaf.view?.url ?? "";
}
// Get body HTML, excluding script/style/noscript tags
const code = `(() => {
const clone = document.body.cloneNode(true);
clone.querySelectorAll('script, style, noscript').forEach(el => el.remove());
return clone.innerHTML;
})()`;
let html = "";
try {
const raw = await webview.executeJavaScript(code);
html = toStringSafe(raw);
} catch (err) {
logError("Failed to get page HTML:", err);
throw err;
}
try {
return htmlToMarkdown(html, baseUrl);
} catch (err) {
logError("Failed to convert page HTML to Markdown:", err);
throw err;
}
}
/** Get full HTML from the embedded page. */
export async function getHtml(leaf: WebViewerLeaf, includeDocumentElement = true): Promise<string> {
const webview = requireWebview(leaf);
const code = includeDocumentElement
? `(() => { try { return document.documentElement?.outerHTML ?? ""; } catch { return ""; } })()`
: `(() => { try { return document.body?.outerHTML ?? ""; } catch { return ""; } })()`;
try {
const raw = await webview.executeJavaScript(code);
return toStringSafe(raw);
} catch (err) {
logError("Failed to get HTML:", err);
throw err;
}
}
// ============================================================================
// YouTube Transcript Extraction
// ============================================================================
/**
* Extract YouTube video ID from various URL formats.
* Supports: youtube.com/watch, youtu.be, youtube.com/shorts, m.youtube.com, embed
* @returns video ID or null if not a valid YouTube video URL
*/
export function getYouTubeVideoId(url: string): string | null {
try {
const u = new URL(url);
const hostname = u.hostname.replace(/^www\./, "").replace(/^m\./, "");
// youtube.com/watch?v=xxx or youtube.com/watch?v=xxx&t=120
if (hostname === "youtube.com" && u.pathname === "/watch") {
return u.searchParams.get("v");
}
// youtu.be/xxx or youtu.be/xxx?t=120
if (hostname === "youtu.be" && u.pathname.length > 1) {
return u.pathname.slice(1).split("/")[0];
}
// youtube.com/shorts/xxx
if (hostname === "youtube.com" && u.pathname.startsWith("/shorts/")) {
return u.pathname.split("/")[2] || null;
}
// youtube.com/embed/xxx
if (hostname === "youtube.com" && u.pathname.startsWith("/embed/")) {
return u.pathname.split("/")[2] || null;
}
return null;
} catch {
return null;
}
}
/** Check if URL is a YouTube video page */
export function isYouTubeVideoUrl(url: string): boolean {
return getYouTubeVideoId(url) !== null;
}
/** YouTube transcript segment */
export interface YouTubeTranscriptSegment {
timestamp: string;
text: string;
}
/** YouTube video metadata and transcript extraction result */
export interface YouTubeTranscriptResult {
videoId: string;
title: string;
channel: string;
description?: string;
uploadDate?: string;
duration?: string;
genre?: string;
transcript: YouTubeTranscriptSegment[];
}
/** Runtime validation for transcript result */
function isValidTranscriptResult(data: unknown): data is YouTubeTranscriptResult {
if (typeof data !== "object" || data === null) return false;
const d = data as Record<string, unknown>;
return (
typeof d.videoId === "string" &&
typeof d.title === "string" &&
typeof d.channel === "string" &&
(d.description === undefined || typeof d.description === "string") &&
(d.uploadDate === undefined || typeof d.uploadDate === "string") &&
(d.duration === undefined || typeof d.duration === "string") &&
(d.genre === undefined || typeof d.genre === "string") &&
Array.isArray(d.transcript) &&
d.transcript.every(
(seg: unknown) =>
typeof seg === "object" &&
seg !== null &&
typeof (seg as Record<string, unknown>).timestamp === "string" &&
typeof (seg as Record<string, unknown>).text === "string"
)
);
}
/**
* Extract YouTube video transcript via DOM manipulation.
* Automatically clicks the transcript button if needed and closes the panel after extraction.
* @param leaf - The Web Viewer leaf containing the YouTube page
* @param options.timeoutMs - Maximum time to wait for transcript to load (default: 10000ms)
*/
export async function getYouTubeTranscript(
leaf: WebViewerLeaf,
options: { timeoutMs?: number } = {}
): Promise<YouTubeTranscriptResult> {
const webview = requireWebview(leaf);
const { timeoutMs = 10000 } = options;
const maxAttempts = Math.ceil(timeoutMs / 500);
// Get the actual page URL and extract videoId (handles redirects and all URL formats)
let pageUrl = "";
try {
pageUrl = typeof webview.getURL === "function" ? webview.getURL() : "";
} catch {
pageUrl = "";
}
if (!pageUrl) pageUrl = leaf.view?.url ?? "";
const videoId = getYouTubeVideoId(pageUrl);
if (!videoId) {
throw new Error("Not a YouTube video page");
}
// Pass videoId into the script to avoid re-parsing URL (fixes /shorts/, /embed/, youtu.be support)
const code = `(async () => {
const videoId = ${JSON.stringify(videoId)};
// =========================================================================
// Step 1: Extract video metadata from JSON-LD (structured data)
// JSON-LD is more reliable than DOM selectors as it's machine-readable
// =========================================================================
let title = '';
let channel = '';
let description = '';
let uploadDate = '';
let duration = '';
let genre = '';
// Helper to extract fields from a VideoObject schema
const extractVideoObject = (data) => {
if (!data || data['@type'] !== 'VideoObject') return;
if (!title && data.name && typeof data.name === 'string') {
title = data.name;
}
if (!description && data.description && typeof data.description === 'string') {
description = data.description;
}
if (!uploadDate && data.uploadDate && typeof data.uploadDate === 'string') {
uploadDate = data.uploadDate;
}
// JSON-LD duration is ISO 8601 format (e.g., "PT12M34S"), convert to readable format
if (!duration && data.duration && typeof data.duration === 'string') {
const match = data.duration.match(/PT(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)S)?/);
if (match) {
const h = match[1] ? match[1] + ':' : '';
const m = match[2] || '0';
const s = match[3] || '0';
duration = h + (h ? m.padStart(2, '0') : m) + ':' + s.padStart(2, '0');
}
}
if (!genre && data.genre) {
genre = Array.isArray(data.genre) ? data.genre.join(', ') : String(data.genre);
}
// author can be string, object {name}, or array [{name}]
if (!channel && data.author) {
const author = data.author;
if (typeof author === 'string') {
channel = author;
} else if (Array.isArray(author) && author[0]?.name) {
channel = String(author[0].name);
} else if (author?.name) {
channel = String(author.name);
}
}
};
// Parse all JSON-LD scripts, handling various formats (@graph, array, single object)
const ldJsonEls = document.querySelectorAll('script[type="application/ld+json"]');
for (const el of ldJsonEls) {
try {
const data = JSON.parse(el.textContent || '');
if (Array.isArray(data)) {
for (const item of data) {
extractVideoObject(item);
}
} else if (data['@graph'] && Array.isArray(data['@graph'])) {
for (const item of data['@graph']) {
extractVideoObject(item);
}
} else {
extractVideoObject(data);
}
// Stop if we have all essential fields
if (title && channel && description) break;
} catch {}
}
// =========================================================================
// Step 2: Fallback to DOM selectors for missing metadata
// =========================================================================
if (!title) {
title = document.querySelector('h1.ytd-watch-metadata yt-formatted-string')?.textContent?.trim()
|| document.querySelector('#title h1')?.textContent?.trim() || '';
}
if (!channel) {
channel = document.querySelector('#owner #channel-name a')?.textContent?.trim()
|| document.querySelector('#channel-name a')?.textContent?.trim()
|| document.querySelector('#channel-name')?.textContent?.trim() || '';
}
if (!duration) {
duration = document.querySelector('#ytd-player .ytp-time-duration')?.textContent?.trim() || '';
}
// =========================================================================
// Step 3: Try to extract transcript
// Check if transcript panel is already open, otherwise click the button
// =========================================================================
let segments = document.querySelectorAll('ytd-transcript-segment-renderer');
let needClose = false;
if (segments.length === 0) {
const btn = document.querySelector('ytd-video-description-transcript-section-renderer button');
if (btn) {
btn.click();
needClose = true;
// Wait for transcript to load (poll every 500ms)
for (let i = 0; i < ${maxAttempts}; i++) {
await new Promise(r => setTimeout(r, 500));
segments = document.querySelectorAll('ytd-transcript-segment-renderer');
if (segments.length > 0) break;
}
}
}
// Helper to close the transcript panel (called in finally block)
const closePanel = () => {
if (needClose) {
const closeBtn = document.querySelector(
'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] #visibility-button button'
);
if (closeBtn) closeBtn.click();
}
};
// =========================================================================
// Step 4: Extract transcript segments and return result
// =========================================================================
try {
const transcript = [...segments].map(seg => ({
timestamp: seg.querySelector('.segment-timestamp')?.textContent?.trim() || '',
text: seg.querySelector('yt-formatted-string.segment-text, .segment-text')?.textContent?.trim() || ''
})).filter(t => t.timestamp && t.text);
return { videoId, title, channel, description, uploadDate, duration, genre, transcript };
} finally {
// Step 5: Always close the transcript panel if we opened it
closePanel();
}
})()`;
const result = await webview.executeJavaScript(code);
// Validate result structure
if (!isValidTranscriptResult(result)) {
throw new Error("Invalid transcript data structure");
}
return result;
}
// ============================================================================
// Save & Export
// ============================================================================
/** Save the current Web Viewer page to the vault. */
export async function saveToVault(
leaf: WebViewerLeaf,
executeCommand: ExecuteWebViewerCommand,
options: { preferCommand?: boolean; focusLeafBeforeCommand?: boolean } = {}
): Promise<SaveToVaultResult> {
const { preferCommand = true, focusLeafBeforeCommand = true } = options;
if (preferCommand) {
try {
await executeCommand(WEB_VIEWER_COMMANDS.SAVE_TO_VAULT, {
leaf,
focusLeaf: focusLeafBeforeCommand,
});
logInfo("Saved via webviewer:save-to-vault command");
return { method: "command" };
} catch (err) {
logWarn("save-to-vault command failed, falling back:", err);
}
}
try {
await Promise.resolve(leaf.view.saveAsMarkdown());
logInfo("Saved via view.saveAsMarkdown()");
return { method: "view.saveAsMarkdown" };
} catch (err) {
logError("Failed to save Web Viewer page:", err);
throw err;
}
}

View file

@ -0,0 +1,307 @@
/**
* Web Viewer Service Helpers
*
* Shared utilities for Web Viewer integration:
* - Runtime shape checks
* - Safe string/error helpers
* - Turndown HTML to Markdown helpers
* - Command manager adapters
* - Workspace leaf helpers
*/
import type { App, WorkspaceLeaf } from "obsidian";
import TurndownService from "turndown";
import {
type CommandManager,
WEB_VIEWER_VIEW_TYPE,
WebViewerError,
type WebViewerPluginApi,
} from "@/services/webViewerService/webViewerServiceTypes";
// ============================================================================
// General Utilities
// ============================================================================
/**
* Check if a value is a non-null object record.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* Convert an unknown value to a safe string without throwing.
*/
export function toStringSafe(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return String(value);
} catch {
return "";
}
}
/**
* Convert an unknown error to a human-readable message.
*/
export function toErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return toStringSafe(err);
}
/**
* Delay for the provided number of milliseconds.
*/
export async function delay(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
/**
* Wait until a predicate returns true, or throw after timeout.
* @param predicate - Function that returns true when condition is met
* @param timeoutMs - Maximum time to wait in milliseconds
* @param intervalMs - Polling interval in milliseconds
* @param label - Description for error message if timeout occurs
*/
export async function waitFor(
predicate: () => boolean,
timeoutMs: number,
intervalMs: number,
label: string
): Promise<void> {
const start = Date.now();
for (;;) {
if (predicate()) return;
if (Date.now() - start >= timeoutMs) {
throw new WebViewerError(`${label} timed out after ${timeoutMs}ms`);
}
await delay(intervalMs);
}
}
// ============================================================================
// Turndown Helpers (HTML to Markdown)
// ============================================================================
/**
* Resolve a potentially-relative URL against a base URL.
*/
export function resolveUrl(rawUrl: string, baseUrl: string): string {
const input = (rawUrl ?? "").trim();
if (!input) return "";
if (!baseUrl) return input;
try {
return new URL(input, baseUrl).href;
} catch {
return input;
}
}
/**
* Format a Markdown link destination safely.
* Wraps URLs containing spaces or parentheses in angle brackets.
*/
export function formatMarkdownDestination(url: string): string {
const u = (url ?? "").trim();
if (!u) return "";
return /[\s)]/.test(u) ? `<${u}>` : u;
}
/**
* Create a TurndownService configured for Obsidian-friendly Markdown output.
* @param baseUrl - Base URL for resolving relative links and images
*/
export function createTurndown(baseUrl: string): TurndownService {
const td = new TurndownService({
headingStyle: "atx",
hr: "---",
bulletListMarker: "-",
codeBlockStyle: "fenced",
fence: "```",
emDelimiter: "*",
strongDelimiter: "**",
linkStyle: "inlined",
});
td.remove(["script", "style", "noscript"]);
// Custom rule for links: resolve relative URLs
td.addRule("webviewer-link", {
filter: "a",
replacement: (content, node) => {
const el = node as HTMLAnchorElement;
const rawHref = el.getAttribute("href") ?? "";
const href = formatMarkdownDestination(resolveUrl(rawHref, baseUrl));
const label = content?.trim() || el.textContent || href;
if (!href) return label;
return `[${label}](${href})`;
},
});
// Custom rule for images: resolve relative URLs
td.addRule("webviewer-image", {
filter: "img",
replacement: (_content, node) => {
const el = node as HTMLImageElement;
const alt = el.getAttribute("alt") ?? "";
const rawSrc = el.getAttribute("src") ?? "";
const src = formatMarkdownDestination(resolveUrl(rawSrc, baseUrl));
if (!src) return "";
return `![${alt}](${src})`;
},
});
return td;
}
/**
* Convert HTML string to Markdown using Turndown.
* Uses DOMParser to avoid resource preloading (which causes ERR_FILE_NOT_FOUND for relative URLs).
* @param html - The HTML string to convert
* @param baseUrl - Base URL for resolving relative links and images
* @returns The converted Markdown string, or empty string if input is empty/unparseable.
* Note: Turndown conversion errors will propagate to the caller.
*/
export function htmlToMarkdown(html: string, baseUrl: string): string {
if (!html.trim()) return "";
const td = createTurndown(baseUrl);
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${html}</div>`, "text/html");
const wrapper = doc.body.firstElementChild;
if (!wrapper) return "";
return td
.turndown(wrapper as HTMLElement)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
// ============================================================================
// Command Manager
// ============================================================================
/**
* Get the (undocumented) Obsidian command manager API.
*/
export function getCommandManager(app: App): CommandManager | null {
const commands = (app as unknown as { commands?: unknown }).commands;
if (!commands || !isRecord(commands)) return null;
if (typeof (commands as unknown as CommandManager).executeCommandById !== "function") return null;
return commands as unknown as CommandManager;
}
/**
* Check if a specific command is registered in Obsidian.
*/
export function isCommandRegistered(app: App, commandId: string): boolean {
const cm = getCommandManager(app);
if (!cm) return false;
// Try commands map/object
if (cm.commands) {
if (cm.commands instanceof Map) {
return cm.commands.has(commandId);
}
if (typeof cm.commands === "object") {
return commandId in cm.commands;
}
}
// Fallback: try listCommands
if (typeof cm.listCommands === "function") {
const list = cm.listCommands();
return list.some((c) => c.id === commandId);
}
return false;
}
// ============================================================================
// Workspace Leaf Utilities
// ============================================================================
/**
* Check if a leaf is still open in the workspace.
*/
export function isLeafStillOpen(app: App, leaf: WorkspaceLeaf): boolean {
const leaves = app.workspace.getLeavesOfType(WEB_VIEWER_VIEW_TYPE);
return leaves.includes(leaf);
}
// ============================================================================
// Internal Plugin API
// ============================================================================
/**
* Try to extract a usable WebViewerPluginApi from a plugin entry.
* Capability-based detection: requires openUrl OR handleOpenUrl.
*/
function tryExtractPluginApi(entry: unknown): WebViewerPluginApi | null {
if (!isRecord(entry)) return null;
if ((entry as { enabled?: unknown }).enabled !== true) return null;
const instance = (entry as { instance?: unknown }).instance;
if (!instance || !isRecord(instance)) return null;
const maybe = instance as Partial<WebViewerPluginApi>;
const hasOpenCapability =
typeof maybe.openUrl === "function" || typeof maybe.handleOpenUrl === "function";
if (hasOpenCapability) {
return maybe as WebViewerPluginApi;
}
return null;
}
/**
* Get the internal Web Viewer plugin API from Obsidian's internal plugins.
* @param app - The Obsidian App instance
* @param warnOnUnexpectedStructure - Callback to warn once about unexpected structure
* @returns The WebViewerPluginApi if found, null otherwise
*/
export function getInternalWebViewerPluginApi(
app: App,
warnOnUnexpectedStructure?: () => void
): WebViewerPluginApi | null {
const internalPlugins = (app as unknown as { internalPlugins?: unknown }).internalPlugins;
if (!internalPlugins || !isRecord(internalPlugins)) return null;
const plugins = (internalPlugins as { plugins?: unknown }).plugins;
if (!plugins) return null;
// Strategy 1: Direct key lookup using WEB_VIEWER_VIEW_TYPE
const directEntry =
plugins instanceof Map
? plugins.get(WEB_VIEWER_VIEW_TYPE)
: isRecord(plugins)
? (plugins as Record<string, unknown>)[WEB_VIEWER_VIEW_TYPE]
: null;
if (directEntry) {
const api = tryExtractPluginApi(directEntry);
if (api) return api;
}
// Strategy 2: Fallback to scanning all entries
let entries: unknown[];
if (plugins instanceof Map) {
entries = Array.from(plugins.values());
} else if (isRecord(plugins)) {
entries = Object.values(plugins);
} else {
// Unexpected structure - warn once
if (warnOnUnexpectedStructure) {
warnOnUnexpectedStructure();
}
return null;
}
for (const entry of entries) {
const api = tryExtractPluginApi(entry);
if (api) return api;
}
return null;
}

View file

@ -0,0 +1,403 @@
/**
* Web Viewer Service Selection Tracker
*
* Manages automatic web selection tracking for Web Viewer tabs.
* Uses self-scheduling setTimeout pattern to avoid concurrent execution.
*/
import * as actions from "@/services/webViewerService/webViewerServiceActions";
import type { WebViewerLeaf } from "@/services/webViewerService/webViewerServiceTypes";
import type { WebSelectedTextContext } from "@/types/message";
import { v4 as uuidv4 } from "uuid";
// ============================================================================
// Types
// ============================================================================
/**
* Options for starting web selection tracking
*/
export interface WebSelectionTrackingOptions {
/** Polling interval in milliseconds (default: 500) */
intervalMs?: number;
/** Consecutive empty selection checks required before clearing (default: 2) */
emptySelectionDebounceCount?: number;
/** Callback to check if tracking should be enabled */
isEnabled: () => boolean;
/** Callback to get the active or last active Web Viewer leaf */
getLeaf: () => WebViewerLeaf | null;
/** Callback to get the currently active Web Viewer leaf (if any) */
getActiveLeaf: () => WebViewerLeaf | null;
/** Callback when a new web selection is detected */
onSelectionChange: (context: WebSelectedTextContext) => void;
/** Callback when the current web selection should be cleared (badge removed) */
onSelectionClear: (event: WebSelectionClearEvent) => void;
}
/**
* Event payload for when a web selection should be cleared.
*/
export interface WebSelectionClearEvent {
/** The URL whose selection badge should be cleared. */
url: string;
/** Why the selection is being cleared. */
reason: WebSelectionClearReason;
}
/**
* Reasons for requesting a web selection clear.
*/
export type WebSelectionClearReason = "selection-cleared" | "invalid-url";
/**
* State for deduplication
*/
interface SelectionState {
url: string;
text: string;
}
/**
* Per-leaf tracking state.
*/
interface LeafSelectionTrackingState {
lastSelection: SelectionState | null;
consecutiveEmptyChecks: number;
consecutiveInvalidUrlChecks: number;
}
// ============================================================================
// WebSelectionTracker Class
// ============================================================================
/**
* Tracks web selection changes in Web Viewer tabs.
*
* Features:
* - Self-scheduling setTimeout pattern (no concurrent execution)
* - Deduplication by url + text
* - Lightweight text check before expensive markdown conversion
* - Clears badge when selection is cleared (debounced, active-leaf gated)
* - Clears badge on invalid/empty URL (best-effort, active-leaf gated)
*/
export class WebSelectionTracker {
private readonly intervalMs: number;
private readonly emptySelectionDebounceCount: number;
private readonly isEnabled: () => boolean;
private readonly getLeaf: () => WebViewerLeaf | null;
private readonly getActiveLeaf: () => WebViewerLeaf | null;
private readonly onSelectionChange: (context: WebSelectedTextContext) => void;
private readonly onSelectionClear: (event: WebSelectionClearEvent) => void;
private timeoutId: ReturnType<typeof setTimeout> | null = null;
private isRunning = false;
private leafState = new WeakMap<WebViewerLeaf, LeafSelectionTrackingState>();
/** URL-based suppression map: URL -> pinned selection text (null means "pin next observed") */
private suppressedSelectionsByUrl = new Map<string, string | null>();
constructor(options: WebSelectionTrackingOptions) {
this.intervalMs = options.intervalMs ?? 500;
const configuredEmptyCount = options.emptySelectionDebounceCount ?? 2;
this.emptySelectionDebounceCount =
Number.isFinite(configuredEmptyCount) && configuredEmptyCount > 0
? Math.floor(configuredEmptyCount)
: 2;
this.isEnabled = options.isEnabled;
this.getLeaf = options.getLeaf;
this.getActiveLeaf = options.getActiveLeaf;
this.onSelectionChange = options.onSelectionChange;
this.onSelectionClear = options.onSelectionClear;
}
/**
* Start tracking web selection changes.
* Idempotent - calling multiple times is safe.
*/
start(): void {
if (this.isRunning) return;
this.isRunning = true;
this.scheduleNext();
}
/**
* Stop tracking web selection changes.
* Clears any pending timeout and resets state.
*/
stop(): void {
this.isRunning = false;
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.leafState = new WeakMap<WebViewerLeaf, LeafSelectionTrackingState>();
this.suppressedSelectionsByUrl = new Map<string, string | null>();
}
/**
* Schedule the next check using setTimeout.
* Uses self-scheduling pattern to ensure no concurrent execution.
*/
private scheduleNext(): void {
if (!this.isRunning) return;
this.timeoutId = setTimeout(async () => {
await this.checkSelection();
// Schedule next only after current check completes
this.scheduleNext();
}, this.intervalMs);
}
/**
* Check for selection changes in the active Web Viewer tab.
*/
private async checkSelection(): Promise<void> {
// Skip if tracking is disabled
if (!this.isEnabled()) {
return;
}
try {
const leaf = this.getLeaf();
if (!leaf) return;
const state = this.getOrCreateLeafState(leaf);
// Get page URL for deduplication
const pageInfo = actions.getPageInfo(leaf);
const url = pageInfo.url;
if (this.isValidUrl(url)) {
state.consecutiveInvalidUrlChecks = 0;
} else {
// URL is empty/invalid page: debounce and clear (best-effort)
state.consecutiveEmptyChecks = 0;
this.maybeClearSelectionForInvalidUrl(leaf, state);
return;
}
// Use lightweight getSelectedText first to check for changes
const selectedText = await actions.getSelectedText(leaf);
// Empty selection: debounce and clear (only when leaf is active)
if (!selectedText.trim()) {
this.handleEmptySelection(leaf, state);
return;
}
// Non-empty selection observed -> reset empty debounce for this leaf
state.consecutiveEmptyChecks = 0;
// URL-based suppression: after user removes web selection (or starts a new chat),
// don't auto-capture the same selection until it changes or is cleared.
if (this.shouldSuppressSelectionForUrl(url, selectedText)) {
return;
}
// Deduplication: check if url + text combo has changed
// This handles the case of same text on different pages
if (state.lastSelection && state.lastSelection.url === url && state.lastSelection.text === selectedText) {
return;
}
// Get full markdown content (more expensive)
const selectedMarkdown = await actions.getSelectedMarkdown(leaf);
if (!selectedMarkdown.trim()) {
return;
}
// Race guard: user may click X / start a new chat while getSelectedMarkdown() is in progress.
// If suppression became active mid-flight, do not emit.
if (this.shouldSuppressSelectionForUrl(url, selectedText)) {
return;
}
// Update deduplication state (only after markdown is confirmed non-empty)
state.lastSelection = { url, text: selectedText };
// Create web selection context
const context: WebSelectedTextContext = {
id: uuidv4(),
content: selectedMarkdown,
sourceType: "web",
title: pageInfo.title || "Untitled",
url,
faviconUrl: pageInfo.faviconUrl || undefined,
};
// Notify callback
this.onSelectionChange(context);
} catch {
// Ignore errors - web viewer may not be available
}
}
/**
* Get or initialize tracking state for a leaf.
*/
private getOrCreateLeafState(leaf: WebViewerLeaf): LeafSelectionTrackingState {
const existing = this.leafState.get(leaf);
if (existing) {
return existing;
}
const initial: LeafSelectionTrackingState = {
lastSelection: null,
consecutiveEmptyChecks: 0,
consecutiveInvalidUrlChecks: 0,
};
this.leafState.set(leaf, initial);
return initial;
}
/**
* Returns true when `url` is a non-empty absolute URL string.
*/
private isValidUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return false;
try {
new URL(trimmed);
return true;
} catch {
return false;
}
}
/**
* Returns true when the given leaf is the currently active Web Viewer leaf.
*/
private isActiveWebViewerLeaf(leaf: WebViewerLeaf): boolean {
const activeLeaf = this.getActiveLeaf();
return Boolean(activeLeaf && activeLeaf === leaf);
}
/**
* Handle the "non-empty -> empty" transition with debounce and active-leaf gating.
*/
private handleEmptySelection(leaf: WebViewerLeaf, state: LeafSelectionTrackingState): void {
// Only clear when we previously observed a non-empty selection for this leaf
if (!state.lastSelection) {
return;
}
// Only clear when the Web Viewer leaf is currently active (user is on that page)
if (!this.isActiveWebViewerLeaf(leaf)) {
return;
}
state.consecutiveEmptyChecks += 1;
if (state.consecutiveEmptyChecks < this.emptySelectionDebounceCount) {
return;
}
const urlToClear = state.lastSelection.url;
// Reset state when selection is truly cleared
state.lastSelection = null;
state.consecutiveEmptyChecks = 0;
// Clear suppression for the URL that was actually selected (not current page URL)
this.clearSuppressionForUrl(urlToClear);
this.onSelectionClear({ url: urlToClear, reason: "selection-cleared" });
}
/**
* Clear selection badge when current page URL is empty/invalid (best-effort, debounced).
*/
private maybeClearSelectionForInvalidUrl(leaf: WebViewerLeaf, state: LeafSelectionTrackingState): void {
// Only clear when we previously observed a valid selection for this leaf
if (!state.lastSelection) {
return;
}
// Only clear when the Web Viewer leaf is currently active (user is on that page)
if (!this.isActiveWebViewerLeaf(leaf)) {
return;
}
// Debounce: require consecutive invalid URL checks before clearing
state.consecutiveInvalidUrlChecks += 1;
if (state.consecutiveInvalidUrlChecks < this.emptySelectionDebounceCount) {
return;
}
const urlToClear = state.lastSelection.url;
// Reset state since page is invalid and selection is no longer meaningful
state.lastSelection = null;
state.consecutiveEmptyChecks = 0;
state.consecutiveInvalidUrlChecks = 0;
this.clearSuppressionForUrl(urlToClear);
this.onSelectionClear({ url: urlToClear, reason: "invalid-url" });
}
/**
* Clear suppression state for the given URL.
* @param url - The URL whose suppression state should be cleared
*/
private clearSuppressionForUrl(url: string): void {
this.suppressedSelectionsByUrl.delete(url.trim());
}
/**
* Returns true if the current selection should be suppressed for the given URL.
* When suppression is first activated for a URL, the next observed non-empty selection
* is pinned and suppressed.
* @param url - Current page URL
* @param selectedText - Current non-empty selection text
* @returns True if the selection should be suppressed
*/
private shouldSuppressSelectionForUrl(url: string, selectedText: string): boolean {
const urlKey = url.trim();
const suppressedText = this.suppressedSelectionsByUrl.get(urlKey);
if (suppressedText === undefined) {
return false;
}
if (suppressedText === null) {
// Pin the current selection as the one to suppress
this.suppressedSelectionsByUrl.set(urlKey, selectedText);
return true;
}
if (suppressedText === selectedText) {
return true;
}
// Selection changed -> lift suppression and proceed with normal capture
this.suppressedSelectionsByUrl.delete(urlKey);
return false;
}
/**
* Suppress auto-capture for the given URL so the selection won't be re-added until it changes or is cleared.
* The selection text is pinned on the next observed non-empty selection for that URL.
* @param url - The URL whose selection should be suppressed
*/
suppressSelectionForUrl(url: string): void {
const trimmed = url.trim();
if (!this.isValidUrl(trimmed)) {
return;
}
this.suppressedSelectionsByUrl.set(trimmed, null);
}
/**
* Suppress the current web selection from being auto-captured again until it changes or is cleared.
* Use this for:
* - User removes web selection context (badge X)
* - New chat (prevent previous web selection from reappearing)
*/
suppressCurrentSelection(): void {
const leaf = this.getLeaf();
if (!leaf) return;
const pageInfo = actions.getPageInfo(leaf);
this.suppressSelectionForUrl(pageInfo.url);
}
}

View file

@ -0,0 +1,109 @@
/**
* Web Viewer Service Singleton & Convenience Functions
*
* Provides cached WebViewerService instances and convenience functions
* for common Web Viewer operations.
*/
import type { App } from "obsidian";
import { WebViewerService } from "@/services/webViewerService/webViewerService";
import type {
ActiveWebTabTrackingRefs,
ResolveLeafOptions,
SaveToVaultResult,
StartActiveWebTabTrackingOptions,
WebViewerLeaf,
WebViewerPageInfo,
} from "@/services/webViewerService/webViewerServiceTypes";
// ============================================================================
// Singleton Cache
// ============================================================================
const serviceCache = new WeakMap<App, WebViewerService>();
/**
* Get a cached WebViewerService instance for the provided App.
* Creates a new instance if one doesn't exist.
*/
export function getWebViewerService(app: App): WebViewerService {
const cached = serviceCache.get(app);
if (cached) return cached;
const service = new WebViewerService(app);
serviceCache.set(app, service);
return service;
}
// ============================================================================
// Convenience Functions
// ============================================================================
/**
* Start tracking the Active Web Tab state (SSoT for UI).
*/
export function startActiveWebTabTracking(
app: App,
options?: StartActiveWebTabTrackingOptions
): ActiveWebTabTrackingRefs {
return getWebViewerService(app).startActiveWebTabTracking(options);
}
/**
* Resolve the current Web Viewer leaf.
*/
export async function resolveWebViewerLeaf(app: App, options?: ResolveLeafOptions): Promise<WebViewerLeaf> {
return getWebViewerService(app).resolveLeaf(options);
}
/**
* Get reader-mode Markdown from the current Web Viewer.
*/
export async function getWebViewerMarkdown(app: App, options?: ResolveLeafOptions): Promise<string> {
const leaf = await resolveWebViewerLeaf(app, options);
return getWebViewerService(app).getReaderModeMarkdown(leaf);
}
/**
* Get selected text from the current Web Viewer.
*/
export async function getWebViewerSelectedText(
app: App,
trim = true,
options?: ResolveLeafOptions
): Promise<string> {
const leaf = await resolveWebViewerLeaf(app, options);
return getWebViewerService(app).getSelectedText(leaf, trim);
}
/**
* Get selected content as Markdown from the current Web Viewer.
*/
export async function getWebViewerSelectedMarkdown(app: App, options?: ResolveLeafOptions): Promise<string> {
const leaf = await resolveWebViewerLeaf(app, options);
return getWebViewerService(app).getSelectedMarkdown(leaf);
}
/**
* Get page info from the current Web Viewer.
*/
export async function getWebViewerPageInfo(
app: App,
options?: ResolveLeafOptions
): Promise<WebViewerPageInfo> {
const leaf = await resolveWebViewerLeaf(app, options);
return getWebViewerService(app).getPageInfo(leaf);
}
/**
* Save the current Web Viewer page to vault.
*/
export async function saveWebViewerToVault(
app: App,
saveOptions?: { preferCommand?: boolean; focusLeafBeforeCommand?: boolean },
resolveOptions?: ResolveLeafOptions
): Promise<SaveToVaultResult> {
const leaf = await resolveWebViewerLeaf(app, { ...resolveOptions, focus: true });
return getWebViewerService(app).saveToVault(leaf, saveOptions);
}

View file

@ -0,0 +1,652 @@
/**
* Web Viewer Service State Manager
*
* Manages Active Web Tab state (single source of truth) and leaf tracking.
* Extracted from WebViewerService to reduce file size.
*/
import type { App, WorkspaceLeaf } from "obsidian";
import { logWarn } from "@/logger";
import { isLeafStillOpen } from "@/services/webViewerService/webViewerServiceHelpers";
import {
isWebViewerLeaf,
type ActiveWebTabStateListener,
type ActiveWebTabStateSnapshot,
type ActiveWebTabTrackingRefs,
type StartActiveWebTabTrackingOptions,
type WebViewerLeaf,
type WebViewerPageInfo,
} from "@/services/webViewerService/webViewerServiceTypes";
import { normalizeUrlForMatching } from "@/utils/urlNormalization";
import type { WebTabContext } from "@/types/message";
// ============================================================================
// Webview Event Types
// ============================================================================
/** Webview events that can trigger Web Viewer tab metadata refresh. */
const WEBVIEW_METADATA_EVENTS = [
"did-finish-load",
"page-favicon-updated",
"page-title-updated",
] as const;
/** Union type of supported webview metadata event names. */
type WebviewMetadataEventName = (typeof WEBVIEW_METADATA_EVENTS)[number];
/** Entry for tracking webview event listeners. */
interface WebviewLoadListenerEntry {
leaf: WebViewerLeaf;
handler: () => void;
events: ReadonlyArray<WebviewMetadataEventName>;
}
// ============================================================================
// Recompute Active Web Tab State Parameters
// ============================================================================
/**
* Parameters for the unified recomputeActiveWebTabState method.
* Discriminated union based on trigger type.
*/
type RecomputeActiveWebTabStateParams =
| { trigger: "active-leaf-change"; activeLeaf: WorkspaceLeaf | null }
| { trigger: "layout-change" }
| { trigger: "webview-metadata"; loadedLeaf: WebViewerLeaf };
// ============================================================================
// Dependencies Interface
// ============================================================================
/**
* Dependencies injected into WebViewerStateManager.
* Avoids circular dependency with WebViewerService.
*/
export interface WebViewerStateManagerDeps {
app: App;
isSupportedPlatform: () => boolean;
getActiveLeaf: () => WebViewerLeaf | null;
getLeaves: () => WebViewerLeaf[];
getPageInfo: (leaf: WebViewerLeaf) => WebViewerPageInfo;
}
// ============================================================================
// WebViewerStateManager Class
// ============================================================================
/**
* Manages Active Web Tab state and leaf tracking for WebViewerService.
*/
export class WebViewerStateManager {
private readonly app: App;
private readonly isSupportedPlatform: () => boolean;
private readonly getActiveLeaf: () => WebViewerLeaf | null;
private readonly getLeaves: () => WebViewerLeaf[];
private readonly getPageInfo: (leaf: WebViewerLeaf) => WebViewerPageInfo;
// Leaf tracking
private lastActiveLeaf: WebViewerLeaf | null = null;
// Active Web Tab state (Single Source of Truth)
private activeWebTabState: ActiveWebTabStateSnapshot = {
activeWebTabForMentions: null,
activeOrLastWebTab: null,
};
// Track the leaf corresponding to activeWebTabForMentions for identity matching
private activeWebTabLeaf: WebViewerLeaf | null = null;
private activeWebTabListeners: Set<ActiveWebTabStateListener> = new Set();
private activeWebTabTrackingRefs: ActiveWebTabTrackingRefs | null = null;
private activeWebTabTrackingPreserveViewTypes: string[] = [];
// Track webview event listeners for cleanup (Map for incremental updates)
private webviewLoadListeners: Map<HTMLElement, WebviewLoadListenerEntry> = new Map();
// Callbacks to notify when any webview finishes loading
private webviewLoadCallbacks: Set<() => void> = new Set();
// Cancel handle for a scheduled webview load callback notification tick
private cancelScheduledWebviewLoadNotify: (() => void) | null = null;
constructor(deps: WebViewerStateManagerDeps) {
this.app = deps.app;
this.isSupportedPlatform = deps.isSupportedPlatform;
this.getActiveLeaf = deps.getActiveLeaf;
this.getLeaves = deps.getLeaves;
this.getPageInfo = deps.getPageInfo;
}
// --------------------------------------------------------------------------
// Leaf Tracking
// --------------------------------------------------------------------------
/**
* Get the most recently active Web Viewer leaf tracked by this manager.
*/
getLastActiveLeaf(): WebViewerLeaf | null {
const leaf = this.lastActiveLeaf;
if (!leaf || !isWebViewerLeaf(leaf)) {
this.lastActiveLeaf = null;
return null;
}
if (!isLeafStillOpen(this.app, leaf)) {
this.lastActiveLeaf = null;
return null;
}
return leaf;
}
/**
* Find a Web Viewer leaf by URL (best-effort normalized match).
* @param url - The URL to search for
* @param options - Optional disambiguation hints
* @param options.title - Page title hint to help disambiguate multiple URL matches
*/
findLeafByUrl(url: string, options: { title?: string } = {}): WebViewerLeaf | null {
const targetRaw = url.trim();
if (!targetRaw) return null;
const leaves = this.getLeaves();
// Fast path: exact match
for (const leaf of leaves) {
if (leaf?.view?.url === targetRaw) return leaf;
}
// Slow path: normalized match
const targetNormalized = normalizeUrlForMatching(targetRaw);
if (!targetNormalized) return null;
// Collect all leaves that match after normalization
const matchedLeaves: WebViewerLeaf[] = [];
for (const leaf of leaves) {
const leafUrl = leaf?.view?.url;
if (!leafUrl) continue;
const leafNormalized = normalizeUrlForMatching(leafUrl);
if (leafNormalized === targetNormalized) {
matchedLeaves.push(leaf);
}
}
// No matches
if (matchedLeaves.length === 0) {
return null;
}
// Single match - safe to return
if (matchedLeaves.length === 1) {
return matchedLeaves[0];
}
// Multiple matches - try to disambiguate using title hint (if provided)
const titleHint = (options.title ?? "").trim();
if (titleHint) {
const titleHintLower = titleHint.toLowerCase();
const titleMatchedLeaves: WebViewerLeaf[] = [];
for (const leaf of matchedLeaves) {
try {
const info = this.getPageInfo(leaf);
const leafTitle = (info.title || "").trim();
if (leafTitle && leafTitle.toLowerCase() === titleHintLower) {
titleMatchedLeaves.push(leaf);
}
} catch {
// Ignore and continue scanning other leaves
}
}
if (titleMatchedLeaves.length === 1) {
return titleMatchedLeaves[0];
}
// If title narrowed it down but still multiple, prefer active/lastActive among title matches
if (titleMatchedLeaves.length > 1) {
const activeLeaf = this.getActiveLeaf();
if (activeLeaf && titleMatchedLeaves.includes(activeLeaf)) {
return activeLeaf;
}
const lastActiveLeaf = this.getLastActiveLeaf();
if (lastActiveLeaf && titleMatchedLeaves.includes(lastActiveLeaf)) {
return lastActiveLeaf;
}
// Return first title match as fallback
logWarn("[WebViewerStateManager] Multiple leaves matched URL + title; returning first match.", {
url: targetRaw,
title: titleHint,
matches: titleMatchedLeaves.length,
});
return titleMatchedLeaves[0];
}
}
// Multiple matches - try to disambiguate using active/lastActive
// This handles cases like two tabs differing only by hash
const activeLeaf = this.getActiveLeaf();
if (activeLeaf && matchedLeaves.includes(activeLeaf)) {
return activeLeaf;
}
const lastActiveLeaf = this.getLastActiveLeaf();
if (lastActiveLeaf && matchedLeaves.includes(lastActiveLeaf)) {
return lastActiveLeaf;
}
// Cannot disambiguate further - return first match as deterministic fallback
logWarn("[WebViewerStateManager] Multiple leaves matched URL; returning first match as fallback.", {
url: targetRaw,
matches: matchedLeaves.length,
});
return matchedLeaves[0];
}
// --------------------------------------------------------------------------
// Active Web Tab State (Single Source of Truth)
// --------------------------------------------------------------------------
/**
* Get the current Active Web Tab state snapshot.
*/
getActiveWebTabState(): ActiveWebTabStateSnapshot {
return this.activeWebTabState;
}
/**
* Subscribe to Active Web Tab state updates.
* @returns Unsubscribe function
*/
subscribeActiveWebTabState(listener: ActiveWebTabStateListener): () => void {
this.activeWebTabListeners.add(listener);
return () => {
this.activeWebTabListeners.delete(listener);
};
}
/**
* Subscribe to webview load events.
* Called when any Web Viewer tab finishes loading (did-finish-load event).
* Useful for refreshing tab metadata (title, favicon, etc.) after page load.
* @returns Unsubscribe function
*/
subscribeToWebviewLoad(callback: () => void): () => void {
this.webviewLoadCallbacks.add(callback);
return () => {
this.webviewLoadCallbacks.delete(callback);
};
}
/**
* Start tracking Active Web Tab state using workspace events.
* Call this in plugin onload() and register the returned EventRefs.
*/
startActiveWebTabTracking(options: StartActiveWebTabTrackingOptions = {}): ActiveWebTabTrackingRefs {
// If already tracking, stop first to allow re-initialization with new options
if (this.activeWebTabTrackingRefs) {
this.stopActiveWebTabTracking();
}
this.activeWebTabTrackingPreserveViewTypes = [...(options.preserveOnViewTypes ?? [])];
const activeLeafRef = this.app.workspace.on("active-leaf-change", (leaf: WorkspaceLeaf | null) => {
// Also update lastActiveLeaf for backward compatibility
try {
if (isWebViewerLeaf(leaf)) this.lastActiveLeaf = leaf;
} catch (err) {
logWarn("WebViewerStateManager failed to track active leaf:", err);
}
this.recomputeActiveWebTabState({ trigger: "active-leaf-change", activeLeaf: leaf });
// Re-subscribe to webview events when active leaf changes
this.subscribeToWebviewLoadEvents();
});
const layoutRef = this.app.workspace.on("layout-change", () => {
this.recomputeActiveWebTabState({ trigger: "layout-change" });
// Re-subscribe to webview events when layout changes (new tabs may have been added)
this.subscribeToWebviewLoadEvents();
});
this.activeWebTabTrackingRefs = { activeLeafRef, layoutRef };
// Initialize snapshot eagerly
this.recomputeActiveWebTabState({ trigger: "active-leaf-change", activeLeaf: this.app.workspace.activeLeaf ?? null });
// Initial subscription to webview events
this.subscribeToWebviewLoadEvents();
return this.activeWebTabTrackingRefs;
}
/**
* Sync webview event listeners for all Web Viewer leaves.
* Listens to:
* - did-finish-load: page finished loading (title available)
* - page-favicon-updated: favicon has been resolved (may come after did-finish-load)
*
* Design:
* - Incremental add/remove to avoid full unbind/rebind on frequent workspace events.
* - Coalesces notifications to subscribers to avoid event storms.
*/
private subscribeToWebviewLoadEvents(): void {
if (!this.isSupportedPlatform()) {
this.cleanupWebviewLoadListeners();
return;
}
try {
const leaves = this.getLeaves();
const nextWebviews = new Set<HTMLElement>();
for (const leaf of leaves) {
const webview = leaf.view?.webview as HTMLElement | undefined;
if (
webview &&
typeof webview.addEventListener === "function" &&
typeof webview.removeEventListener === "function"
) {
nextWebviews.add(webview);
const existing = this.webviewLoadListeners.get(webview);
if (existing && existing.leaf === leaf) {
// Already listening for this webview/leaf pair
continue;
}
// If we already have a listener for this webview, remove it (leaf/view may have changed)
if (existing) {
for (const event of existing.events) {
webview.removeEventListener(event, existing.handler);
}
this.webviewLoadListeners.delete(webview);
}
// Capture the leaf in closure so we know which leaf triggered the event
const handler = () => {
// When webview finishes loading or favicon updates, refresh state
this.recomputeActiveWebTabState({ trigger: "webview-metadata", loadedLeaf: leaf });
// Coalesce subscriber notifications to avoid event storms
this.scheduleWebviewLoadCallbackNotification();
};
const entry: WebviewLoadListenerEntry = {
leaf,
handler,
events: WEBVIEW_METADATA_EVENTS,
};
for (const event of entry.events) {
webview.addEventListener(event, handler);
}
this.webviewLoadListeners.set(webview, entry);
}
}
// Remove listeners for webviews that no longer exist
const staleWebviews: HTMLElement[] = [];
for (const webview of this.webviewLoadListeners.keys()) {
if (!nextWebviews.has(webview)) {
staleWebviews.push(webview);
}
}
for (const webview of staleWebviews) {
const entry = this.webviewLoadListeners.get(webview);
if (!entry) continue;
try {
for (const event of entry.events) {
webview.removeEventListener(event, entry.handler);
}
} catch {
// Ignore cleanup errors
}
this.webviewLoadListeners.delete(webview);
}
} catch {
// Ignore errors
}
}
/**
* Clean up all webview event listeners safely.
*/
private cleanupWebviewLoadListeners(): void {
for (const [webview, { handler, events }] of this.webviewLoadListeners) {
try {
if (typeof webview.removeEventListener === "function") {
for (const event of events) {
webview.removeEventListener(event, handler);
}
}
} catch {
// Ignore cleanup errors
}
}
this.webviewLoadListeners.clear();
}
/**
* Schedule a single notification for webview load callbacks.
* Coalesces rapid-fire webview events (e.g. repeated favicon updates) into one tick.
*/
private scheduleWebviewLoadCallbackNotification(): void {
if (this.cancelScheduledWebviewLoadNotify) {
// Already scheduled, skip
return;
}
const schedule = (fn: () => void): (() => void) => {
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
const id = window.requestAnimationFrame(() => fn());
return () => window.cancelAnimationFrame(id);
}
const id = window.setTimeout(fn, 0);
return () => window.clearTimeout(id);
};
this.cancelScheduledWebviewLoadNotify = schedule(() => {
this.cancelScheduledWebviewLoadNotify = null;
this.notifyWebviewLoadCallbacks();
});
}
/**
* Notify all webview load callbacks.
*/
private notifyWebviewLoadCallbacks(): void {
for (const callback of this.webviewLoadCallbacks) {
try {
callback();
} catch {
// Ignore callback errors
}
}
}
/**
* Stop tracking Active Web Tab state and clean up internal resources.
* Call this in plugin onunload() if needed.
*
* Note: This method does NOT call offref() on workspace event refs because
* they are registered via plugin.registerEvent() which handles cleanup automatically.
* This method only cleans up webview DOM listeners and resets internal state.
*/
stopActiveWebTabTracking(): void {
// Clear refs reference (but don't offref - handled by plugin.registerEvent)
this.activeWebTabTrackingRefs = null;
// Clean up webview event listeners
this.cleanupWebviewLoadListeners();
// Cancel any scheduled notification
this.cancelScheduledWebviewLoadNotify?.();
this.cancelScheduledWebviewLoadNotify = null;
// Reset state and leaf references
this.activeWebTabState = {
activeWebTabForMentions: null,
activeOrLastWebTab: null,
};
this.activeWebTabLeaf = null;
this.lastActiveLeaf = null;
this.activeWebTabTrackingPreserveViewTypes = [];
}
/**
* Unified entry point for recomputing Active Web Tab state.
*
* Rules:
* - If a Web Viewer leaf is active, it always becomes `activeWebTabLeaf` and drives `activeWebTabForMentions`.
* - Sticky semantics: only on `active-leaf-change` do we apply `preserveOnViewTypes` ("preserve" = do not clear; never restore).
* - Layout changes validate the tracked leaf is still open and refresh its snapshot (URL/title/favicon may change).
* - Webview metadata events refresh title/favicon via leaf identity matching (`activeWebTabLeaf`).
*/
private recomputeActiveWebTabState(params: RecomputeActiveWebTabStateParams): void {
if (!this.isSupportedPlatform()) {
this.setActiveWebTabState({ activeWebTabForMentions: null, activeOrLastWebTab: null });
this.activeWebTabLeaf = null;
return;
}
const activeWebLeaf = this.getActiveLeaf();
// Fast-path for webview-metadata: only process if the loaded leaf is relevant.
// This matches the old refreshActiveWebTabStateForLeaf behavior and avoids
// unnecessary recomputation when background tabs trigger favicon/load events.
// We check both activeWebTabLeaf (tracked) and activeWebLeaf (current) because:
// - activeWebTabLeaf: the leaf we're tracking for mentions
// - activeWebLeaf: handles the case where user just switched to a WebViewer tab
// but activeWebTabLeaf hasn't been updated yet (e.g., was previously cleared)
if (params.trigger === "webview-metadata") {
const loadedLeaf = params.loadedLeaf;
const isRelevant = loadedLeaf === this.activeWebTabLeaf || loadedLeaf === activeWebLeaf;
if (!isRelevant) {
return;
}
}
let nextActiveWebTabLeaf = this.activeWebTabLeaf;
let nextActiveWebTabForMentions = this.activeWebTabState.activeWebTabForMentions;
// 1) Active Web Viewer leaf always wins.
if (activeWebLeaf) {
nextActiveWebTabLeaf = activeWebLeaf;
nextActiveWebTabForMentions = this.toWebTabContext(activeWebLeaf);
} else if (params.trigger === "active-leaf-change") {
// 2) Sticky semantics only applies to active-leaf-change.
const viewType = params.activeLeaf?.view?.getViewType();
const preserve = Boolean(viewType && this.activeWebTabTrackingPreserveViewTypes.includes(viewType));
if (!preserve) {
nextActiveWebTabLeaf = null;
nextActiveWebTabForMentions = null;
}
}
// 3) Validate tracked leaf is still open (identity-based).
// Only for active-leaf-change and layout-change triggers.
// For webview-metadata, we already validated relevance above.
if (params.trigger !== "webview-metadata" && nextActiveWebTabLeaf) {
const leafStillOpen = this.getLeaves().includes(nextActiveWebTabLeaf);
if (!leafStillOpen) {
nextActiveWebTabLeaf = null;
nextActiveWebTabForMentions = null;
}
}
// 4) Metadata refresh hooks.
if (params.trigger === "layout-change") {
if (nextActiveWebTabLeaf) {
nextActiveWebTabForMentions = this.toWebTabContext(nextActiveWebTabLeaf);
}
} else if (params.trigger === "webview-metadata") {
const loadedLeaf = params.loadedLeaf;
// At this point, loadedLeaf is guaranteed to be relevant (checked in fast-path above).
// Refresh the snapshot with fresh metadata.
nextActiveWebTabLeaf = loadedLeaf;
nextActiveWebTabForMentions = this.toWebTabContext(loadedLeaf);
}
this.activeWebTabLeaf = nextActiveWebTabLeaf;
const nextActiveOrLastWebTab = this.computeActiveOrLastWebTabContext();
this.setActiveWebTabState({
activeWebTabForMentions: nextActiveWebTabForMentions,
activeOrLastWebTab: nextActiveOrLastWebTab,
});
}
/**
* Compute the best-effort active-or-last Web Tab context for UI display.
*/
private computeActiveOrLastWebTabContext(): WebTabContext | null {
try {
const leaf = this.getActiveLeaf() ?? this.getLastActiveLeaf();
if (!leaf) return null;
return this.toWebTabContext(leaf);
} catch {
return null;
}
}
/**
* Convert a Web Viewer leaf into a serializable WebTabContext snapshot.
*/
private toWebTabContext(leaf: WebViewerLeaf): WebTabContext | null {
try {
const info = this.getPageInfo(leaf);
const url = (info.url || "").trim();
if (!url) return null;
const title = (info.title || "").trim();
const faviconUrl = (info.faviconUrl || "").trim();
return {
url,
title: title ? title : undefined,
faviconUrl: faviconUrl ? faviconUrl : undefined,
};
} catch {
return null;
}
}
/**
* Update internal snapshot and notify listeners when it changes.
*/
private setActiveWebTabState(next: ActiveWebTabStateSnapshot): void {
const prev = this.activeWebTabState;
const unchanged =
WebViewerStateManager.areWebTabContextsEqual(prev.activeWebTabForMentions, next.activeWebTabForMentions) &&
WebViewerStateManager.areWebTabContextsEqual(prev.activeOrLastWebTab, next.activeOrLastWebTab);
if (unchanged) {
return;
}
this.activeWebTabState = next;
this.notifyActiveWebTabListeners();
}
/**
* Notify Active Web Tab subscribers of a state update.
*/
private notifyActiveWebTabListeners(): void {
for (const listener of this.activeWebTabListeners) {
try {
listener(this.activeWebTabState);
} catch (err) {
logWarn("[WebViewerStateManager] Error in Active Web Tab listener:", err);
}
}
}
/**
* Compare two WebTabContext objects for equality.
*/
private static areWebTabContextsEqual(a: WebTabContext | null, b: WebTabContext | null): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.url === b.url && a.title === b.title && a.faviconUrl === b.faviconUrl;
}
}

View file

@ -0,0 +1,242 @@
/**
* Web Viewer Service Types
*
* Constants, types, interfaces, error classes, and type guards for Web Viewer integration.
* Note: Web Viewer is an internal Obsidian API surface that may change without notice.
* Desktop-only (depends on Electron webview).
*/
import type { Command, EventRef, View, WorkspaceLeaf } from "obsidian";
import type { WebTabContext } from "@/types/message";
// ============================================================================
// Constants
// ============================================================================
/** Obsidian Web Viewer view type string (undocumented/internal). */
export const WEB_VIEWER_VIEW_TYPE = "webviewer";
/** Official Web Viewer command IDs (core plugin). Only includes commands actually used. */
export const WEB_VIEWER_COMMANDS = {
OPEN: "webviewer:open",
SAVE_TO_VAULT: "webviewer:save-to-vault",
} as const;
export type WebViewerCommandId = (typeof WEB_VIEWER_COMMANDS)[keyof typeof WEB_VIEWER_COMMANDS];
// ============================================================================
// Types & Interfaces
// ============================================================================
/** Supported Web Viewer render modes. */
export type WebViewerMode = "webview" | "reader";
/** Reader-mode content returned by getReaderModeContent(). */
export interface WebViewerReaderContent {
md: string;
}
/**
* Minimal shape of Electron's webview element as observed in Obsidian Web Viewer.
* Note: This is not part of the official Obsidian plugin API.
* Some methods are marked optional as they may not exist in all Electron versions.
*/
export interface WebviewElement extends HTMLElement {
// Content extraction (core - always available)
executeJavaScript(code: string, userGesture?: boolean): Promise<unknown>;
getURL(): string;
getTitle(): string;
}
/**
* Web Viewer view instance (runtime-observed, undocumented).
* These fields/methods may change without notice.
*/
export interface WebViewerView extends View {
// Properties
url: string;
title: string;
faviconUrl: string;
mode: WebViewerMode;
webview: WebviewElement;
webviewMounted: boolean;
webviewFirstLoadFinished: boolean;
// Methods (only those actually used)
getReaderModeContent(): WebViewerReaderContent | Promise<WebViewerReaderContent>;
saveAsMarkdown(): Promise<void> | void;
}
/** Workspace leaf that hosts a Web Viewer view. */
export type WebViewerLeaf = WorkspaceLeaf & { view: WebViewerView };
/** Internal plugin API surfaced by the core Web Viewer plugin. */
export interface WebViewerPluginApi {
/** Primary method to open a URL (may open new tab). */
openUrl?(url: string): void;
/** Fallback method to handle opening a URL. */
handleOpenUrl?(url: string): void;
/** Get the configured search engine URL. Optional capability. */
getSearchEngineUrl?(query?: string): string;
}
/** Command manager API (undocumented on App typings). */
export interface CommandManager {
executeCommandById(id: string): unknown;
listCommands?: () => Command[];
commands?: Map<string, Command> | Record<string, Command>;
}
/** Strategy for resolving a target Web Viewer leaf. */
export type ResolveStrategy = "active-only" | "active-or-last" | "active-or-last-or-any";
/** Options for resolving a target Web Viewer leaf. */
export interface ResolveLeafOptions {
strategy?: ResolveStrategy;
focus?: boolean;
requireWebviewReady?: boolean;
timeoutMs?: number;
}
/** Web Viewer availability details. */
export interface WebViewerAvailability {
supported: boolean;
available: boolean;
platform: "desktop" | "mobile";
reason?: string;
}
/** Page info from a Web Viewer leaf. */
export interface WebViewerPageInfo {
url: string;
title: string;
faviconUrl: string;
mode: WebViewerMode;
}
/** Result of save-to-vault operation. */
export interface SaveToVaultResult {
method: "command" | "view.saveAsMarkdown";
}
// ============================================================================
// Errors
// ============================================================================
/** Base error class for Web Viewer operations. */
export class WebViewerError extends Error {
constructor(message: string) {
super(message);
this.name = "WebViewerError";
Object.setPrototypeOf(this, WebViewerError.prototype);
}
}
/** Error thrown when Web Viewer is not supported on the current platform. */
export class WebViewerUnsupportedError extends WebViewerError {
constructor(message: string) {
super(message);
this.name = "WebViewerUnsupportedError";
Object.setPrototypeOf(this, WebViewerUnsupportedError.prototype);
}
}
/** Error thrown when no Web Viewer leaf is found. */
export class WebViewerLeafNotFoundError extends WebViewerError {
constructor(message: string) {
super(message);
this.name = "WebViewerLeafNotFoundError";
Object.setPrototypeOf(this, WebViewerLeafNotFoundError.prototype);
}
}
/** Error thrown when webview element is not available or not ready. */
export class WebviewUnavailableError extends WebViewerError {
constructor(message: string) {
super(message);
this.name = "WebviewUnavailableError";
Object.setPrototypeOf(this, WebviewUnavailableError.prototype);
}
}
/** Error thrown when an operation times out. */
export class WebViewerTimeoutError extends WebViewerError {
constructor(message: string) {
super(message);
this.name = "WebViewerTimeoutError";
Object.setPrototypeOf(this, WebViewerTimeoutError.prototype);
}
}
// ============================================================================
// Type Guards
// ============================================================================
/**
* Check if a leaf is a Web Viewer leaf.
*/
export function isWebViewerLeaf(leaf: WorkspaceLeaf | null): leaf is WebViewerLeaf {
if (!leaf) return false;
const view = leaf.view as View | undefined;
if (!view || typeof view !== "object") return false;
const getViewType = view.getViewType;
return typeof getViewType === "function" && leaf.view.getViewType() === WEB_VIEWER_VIEW_TYPE;
}
/**
* Ensure the leaf has a usable webview element, throwing if not available.
*/
export function requireWebview(leaf: WebViewerLeaf): WebviewElement {
const webview = leaf.view?.webview as unknown;
if (
!webview ||
typeof webview !== "object" ||
typeof (webview as unknown as WebviewElement).executeJavaScript !== "function"
) {
throw new WebviewUnavailableError(
"Web Viewer webview is unavailable. The view may not be fully initialized."
);
}
return webview as unknown as WebviewElement;
}
// ============================================================================
// Active Web Tab State Types
// ============================================================================
/**
* Snapshot for the Active Web Tab single-source-of-truth state.
*
* - `activeWebTabForMentions`: Drives whether the "Active Web Tab" @mention option is available.
* - `activeOrLastWebTab`: Best-effort tab metadata for pill display (active Web Viewer tab or last active).
*/
export interface ActiveWebTabStateSnapshot {
activeWebTabForMentions: WebTabContext | null;
activeOrLastWebTab: WebTabContext | null;
}
/**
* Options for Active Web Tab tracking.
*/
export interface StartActiveWebTabTrackingOptions {
/**
* View types that should preserve `activeWebTabForMentions` when they become active.
* Example: preserve the last active Web Viewer tab while the Copilot chat view is focused.
*/
preserveOnViewTypes?: string[];
}
/**
* Event refs returned by `startActiveWebTabTracking()`.
*/
export interface ActiveWebTabTrackingRefs {
activeLeafRef: EventRef;
layoutRef: EventRef;
}
/** Listener type for Active Web Tab state changes. */
export type ActiveWebTabStateListener = (state: ActiveWebTabStateSnapshot) => void;

View file

@ -79,3 +79,77 @@ describe("sanitizeSettings - defaultSendShortcut migration", () => {
expect(sanitized.defaultSendShortcut).toBe(SEND_SHORTCUT.SHIFT_ENTER);
});
});
describe("sanitizeSettings - autoAddActiveContentToContext migration", () => {
it("should migrate from old includeActiveNoteAsContext=true", () => {
const oldSettings = {
...DEFAULT_SETTINGS,
autoAddActiveContentToContext: undefined as any,
includeActiveNoteAsContext: true,
};
const sanitized = sanitizeSettings(oldSettings);
expect(sanitized.autoAddActiveContentToContext).toBe(true);
});
it("should migrate from old includeActiveNoteAsContext=false", () => {
const oldSettings = {
...DEFAULT_SETTINGS,
autoAddActiveContentToContext: undefined as any,
includeActiveNoteAsContext: false,
};
const sanitized = sanitizeSettings(oldSettings);
expect(sanitized.autoAddActiveContentToContext).toBe(false);
});
it("should use default when no old setting exists", () => {
const newSettings = {
...DEFAULT_SETTINGS,
autoAddActiveContentToContext: undefined as any,
};
const sanitized = sanitizeSettings(newSettings);
expect(sanitized.autoAddActiveContentToContext).toBe(DEFAULT_SETTINGS.autoAddActiveContentToContext);
});
});
describe("sanitizeSettings - autoAddSelectionToContext migration", () => {
it("should migrate from old autoIncludeTextSelection=true", () => {
const oldSettings = {
...DEFAULT_SETTINGS,
autoAddSelectionToContext: undefined as any,
autoIncludeTextSelection: true,
};
const sanitized = sanitizeSettings(oldSettings);
expect(sanitized.autoAddSelectionToContext).toBe(true);
});
it("should migrate from old autoIncludeTextSelection=false", () => {
const oldSettings = {
...DEFAULT_SETTINGS,
autoAddSelectionToContext: undefined as any,
autoIncludeTextSelection: false,
};
const sanitized = sanitizeSettings(oldSettings);
expect(sanitized.autoAddSelectionToContext).toBe(false);
});
it("should use default when no old setting exists", () => {
const newSettings = {
...DEFAULT_SETTINGS,
autoAddSelectionToContext: undefined as any,
};
const sanitized = sanitizeSettings(newSettings);
expect(sanitized.autoAddSelectionToContext).toBe(DEFAULT_SETTINGS.autoAddSelectionToContext);
});
});

View file

@ -87,7 +87,7 @@ export interface CopilotSettings {
* When disabled (default), use the first 10 words of the first user message.
*/
generateAIChatTitleOnSave: boolean;
includeActiveNoteAsContext: boolean;
autoAddActiveContentToContext: boolean;
customPromptsFolder: string;
indexVaultToVectorStore: string;
chatNoteContextPath: string;
@ -152,7 +152,7 @@ export interface CopilotSettings {
/** Last checkbox state for including note context in quick command */
quickCommandIncludeNoteContext: boolean;
/** Automatically add text selections to chat context */
autoIncludeTextSelection: boolean;
autoAddSelectionToContext: boolean;
}
export const settingsStore = createStore();
@ -315,9 +315,15 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
sanitizedSettings.lexicalSearchRamLimit = Math.min(1000, Math.max(20, lexicalSearchRamLimit));
}
// Ensure includeActiveNoteAsContext has a default value
if (typeof sanitizedSettings.includeActiveNoteAsContext !== "boolean") {
sanitizedSettings.includeActiveNoteAsContext = DEFAULT_SETTINGS.includeActiveNoteAsContext;
// Ensure autoAddActiveContentToContext has a default value (migrate from old settings)
if (typeof sanitizedSettings.autoAddActiveContentToContext !== "boolean") {
// Migration: check old setting first (includeActiveNoteAsContext)
const oldNoteContext = (settingsToSanitize as unknown as Record<string, unknown>).includeActiveNoteAsContext;
if (typeof oldNoteContext === "boolean") {
sanitizedSettings.autoAddActiveContentToContext = oldNoteContext;
} else {
sanitizedSettings.autoAddActiveContentToContext = DEFAULT_SETTINGS.autoAddActiveContentToContext;
}
}
// Ensure generateAIChatTitleOnSave has a default value
@ -403,9 +409,15 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
sanitizedSettings.quickCommandModelKey = DEFAULT_SETTINGS.quickCommandModelKey;
}
// Ensure autoIncludeTextSelection has a default value
if (typeof sanitizedSettings.autoIncludeTextSelection !== "boolean") {
sanitizedSettings.autoIncludeTextSelection = DEFAULT_SETTINGS.autoIncludeTextSelection;
// Ensure autoAddSelectionToContext has a default value (migrate from old settings)
if (typeof sanitizedSettings.autoAddSelectionToContext !== "boolean") {
// Migration: check old setting first (autoIncludeTextSelection)
const oldTextSelection = (settingsToSanitize as unknown as Record<string, unknown>).autoIncludeTextSelection;
if (typeof oldTextSelection === "boolean") {
sanitizedSettings.autoAddSelectionToContext = oldTextSelection;
} else {
sanitizedSettings.autoAddSelectionToContext = DEFAULT_SETTINGS.autoAddSelectionToContext;
}
}
// Ensure defaultSendShortcut has a valid value

View file

@ -266,21 +266,21 @@ export const BasicSettings: React.FC = () => {
<SettingItem
type="switch"
title="Include Current Note in Context Menu"
description="Automatically include the current note in the chat context menu by default when sending messages to the AI."
checked={settings.includeActiveNoteAsContext}
title="Auto-Add Active Content to Context"
description="Automatically add the active note or Web Viewer tab (Desktop only) to chat context when sending messages."
checked={settings.autoAddActiveContentToContext}
onCheckedChange={(checked) => {
updateSetting("includeActiveNoteAsContext", checked);
updateSetting("autoAddActiveContentToContext", checked);
}}
/>
<SettingItem
type="switch"
title="Auto-Add Text Selection to Context"
description="Automatically add selected text to chat context when you make a text selection in markdown notes. Disable to use manual command instead."
checked={settings.autoIncludeTextSelection}
title="Auto-Add Selection to Context"
description="Automatically add selected text from notes or Web Viewer (Desktop only) to chat context. Disable to use manual command instead."
checked={settings.autoAddSelectionToContext}
onCheckedChange={(checked) => {
updateSetting("autoIncludeTextSelection", checked);
updateSetting("autoAddSelectionToContext", checked);
}}
/>

View file

@ -62,6 +62,7 @@ export class ChatUIState {
context: MessageContext,
chainType: ChainType,
includeActiveNote: boolean = false,
includeActiveWebTab: boolean = false,
content?: any[]
): Promise<string> {
const messageId = await this.chatManager.sendMessage(
@ -69,6 +70,7 @@ export class ChatUIState {
context,
chainType,
includeActiveNote,
includeActiveWebTab,
content
);
this.notifyListeners();

View file

@ -10,16 +10,69 @@ export interface FormattedDateTime {
fileName: string;
}
/**
* Base interface for selected text context
*/
interface BaseSelectedTextContext {
id: string;
content: string;
}
/**
* Context for selected text from notes
*/
export interface SelectedTextContext {
content: string;
export interface NoteSelectedTextContext extends BaseSelectedTextContext {
sourceType: "note";
noteTitle: string;
notePath: string;
startLine: number;
endLine: number;
id: string;
}
/**
* Context for selected text from web tabs
*/
export interface WebSelectedTextContext extends BaseSelectedTextContext {
sourceType: "web";
title: string;
url: string;
faviconUrl?: string;
}
/**
* Union type for selected text context (note or web)
*/
export type SelectedTextContext = NoteSelectedTextContext | WebSelectedTextContext;
/**
* Type guard for note selected text context
*/
export function isNoteSelectedTextContext(
ctx: SelectedTextContext
): ctx is NoteSelectedTextContext {
return ctx.sourceType === "note";
}
/**
* Type guard for web selected text context
*/
export function isWebSelectedTextContext(
ctx: SelectedTextContext
): ctx is WebSelectedTextContext {
return ctx.sourceType === "web";
}
/**
* Context for web tabs from Web Viewer
*/
export interface WebTabContext {
url: string;
title?: string;
faviconUrl?: string;
/** Whether the tab content is loaded (webview mounted and first load finished) */
isLoaded?: boolean;
/** True when this tab should serialize as <active_web_tab> (prompt-only marker) */
isActive?: boolean;
}
/**
@ -31,6 +84,7 @@ export interface MessageContext {
tags?: string[];
folders?: string[];
selectedTextContexts?: SelectedTextContext[];
webTabs?: WebTabContext[];
}
/**

View file

@ -22,6 +22,21 @@ import { MarkdownView, Notice, TFile, Vault, normalizePath, requestUrl } from "o
import { CustomModel } from "./aiParams";
export { err2String } from "@/errorFormat";
/**
* Extract domain from URL, removing 'www.' prefix.
* Returns the original URL string if parsing fails.
* @param url - The URL to extract domain from
* @returns Domain without 'www.' prefix, or original string on parse failure
*/
export function getDomainFromUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// Add custom error type at the top of the file
interface APIError extends Error {
json?: any;

View file

@ -0,0 +1,307 @@
/**
* Tests for URL normalization utilities
*
* Verifies that web tab contexts are correctly normalized, merged, and deduplicated.
*/
import {
normalizeUrlString,
normalizeUrlForMatching,
normalizeOptionalString,
normalizeWebTabContext,
mergeWebTabContexts,
sanitizeWebTabContexts,
} from "@/utils/urlNormalization";
import type { WebTabContext } from "@/types/message";
describe("normalizeUrlString", () => {
it("should return null for null input", () => {
expect(normalizeUrlString(null)).toBeNull();
});
it("should return null for undefined input", () => {
expect(normalizeUrlString(undefined)).toBeNull();
});
it("should return null for empty string", () => {
expect(normalizeUrlString("")).toBeNull();
});
it("should return null for whitespace-only string", () => {
expect(normalizeUrlString(" ")).toBeNull();
});
it("should trim whitespace from URL", () => {
expect(normalizeUrlString(" https://example.com ")).toBe("https://example.com");
});
it("should return valid URL as-is", () => {
expect(normalizeUrlString("https://example.com/path")).toBe("https://example.com/path");
});
});
describe("normalizeUrlForMatching", () => {
it("should return null for null input", () => {
expect(normalizeUrlForMatching(null)).toBeNull();
});
it("should return null for undefined input", () => {
expect(normalizeUrlForMatching(undefined)).toBeNull();
});
it("should return null for empty string", () => {
expect(normalizeUrlForMatching("")).toBeNull();
});
it("should remove hash fragments", () => {
expect(normalizeUrlForMatching("https://example.com/page#section1")).toBe(
"https://example.com/page"
);
expect(normalizeUrlForMatching("https://example.com/page#section2")).toBe(
"https://example.com/page"
);
});
it("should remove default ports", () => {
expect(normalizeUrlForMatching("https://example.com:443/path")).toBe(
"https://example.com/path"
);
expect(normalizeUrlForMatching("http://example.com:80/path")).toBe("http://example.com/path");
});
it("should preserve non-default ports", () => {
expect(normalizeUrlForMatching("https://example.com:8443/path")).toBe(
"https://example.com:8443/path"
);
});
it("should normalize trailing slashes", () => {
expect(normalizeUrlForMatching("https://example.com/path/")).toBe("https://example.com/path");
expect(normalizeUrlForMatching("https://example.com/path//")).toBe("https://example.com/path");
});
it("should preserve root path trailing slash", () => {
expect(normalizeUrlForMatching("https://example.com/")).toBe("https://example.com/");
});
it("should sort query parameters", () => {
expect(normalizeUrlForMatching("https://example.com?b=2&a=1")).toBe(
"https://example.com/?a=1&b=2"
);
expect(normalizeUrlForMatching("https://example.com?a=1&b=2")).toBe(
"https://example.com/?a=1&b=2"
);
});
it("should match same page with different hash fragments", () => {
const url1 = normalizeUrlForMatching("https://example.com/page#section1");
const url2 = normalizeUrlForMatching("https://example.com/page#section2");
expect(url1).toBe(url2);
});
it("should match same page with reordered query params", () => {
const url1 = normalizeUrlForMatching("https://example.com/page?a=1&b=2");
const url2 = normalizeUrlForMatching("https://example.com/page?b=2&a=1");
expect(url1).toBe(url2);
});
it("should return trimmed string for invalid URLs", () => {
expect(normalizeUrlForMatching(" not-a-url ")).toBe("not-a-url");
});
});
describe("normalizeOptionalString", () => {
it("should return undefined for null input", () => {
expect(normalizeOptionalString(null)).toBeUndefined();
});
it("should return undefined for empty string", () => {
expect(normalizeOptionalString("")).toBeUndefined();
});
it("should trim and return valid string", () => {
expect(normalizeOptionalString(" Title ")).toBe("Title");
});
});
describe("normalizeWebTabContext", () => {
it("should return null for empty URL", () => {
const tab: WebTabContext = { url: "", title: "Test" };
expect(normalizeWebTabContext(tab)).toBeNull();
});
it("should return null for whitespace-only URL", () => {
const tab: WebTabContext = { url: " ", title: "Test" };
expect(normalizeWebTabContext(tab)).toBeNull();
});
it("should normalize URL and metadata", () => {
const tab: WebTabContext = {
url: " https://example.com ",
title: " Example ",
faviconUrl: " https://example.com/favicon.ico ",
};
const result = normalizeWebTabContext(tab);
expect(result).toEqual({
url: "https://example.com",
title: "Example",
faviconUrl: "https://example.com/favicon.ico",
isLoaded: undefined,
isActive: undefined,
});
});
it("should preserve isActive flag when true", () => {
const tab: WebTabContext = {
url: "https://example.com",
isActive: true,
};
const result = normalizeWebTabContext(tab);
expect(result?.isActive).toBe(true);
});
it("should convert falsy isActive to undefined", () => {
const tab: WebTabContext = {
url: "https://example.com",
isActive: false,
};
const result = normalizeWebTabContext(tab);
expect(result?.isActive).toBeUndefined();
});
it("should preserve isLoaded flag", () => {
const tab: WebTabContext = {
url: "https://example.com",
isLoaded: false,
};
const result = normalizeWebTabContext(tab);
expect(result?.isLoaded).toBe(false);
});
});
describe("mergeWebTabContexts", () => {
it("should return empty array for empty input", () => {
expect(mergeWebTabContexts([])).toEqual([]);
});
it("should deduplicate tabs by URL", () => {
const tabs: WebTabContext[] = [
{ url: "https://example.com", title: "First" },
{ url: "https://example.com", title: "Second" },
];
const result = mergeWebTabContexts(tabs);
expect(result).toHaveLength(1);
expect(result[0].url).toBe("https://example.com");
});
it("should preserve insertion order", () => {
const tabs: WebTabContext[] = [
{ url: "https://first.com" },
{ url: "https://second.com" },
{ url: "https://third.com" },
];
const result = mergeWebTabContexts(tabs);
expect(result.map((t) => t.url)).toEqual([
"https://first.com",
"https://second.com",
"https://third.com",
]);
});
it("should merge metadata from later entries", () => {
const tabs: WebTabContext[] = [
{ url: "https://example.com", title: "First" },
{ url: "https://example.com", faviconUrl: "https://example.com/favicon.ico" },
];
const result = mergeWebTabContexts(tabs);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
url: "https://example.com",
title: "First",
faviconUrl: "https://example.com/favicon.ico",
isLoaded: undefined,
isActive: undefined,
});
});
it("should set isActive true if any merged entry is active", () => {
const tabs: WebTabContext[] = [
{ url: "https://example.com", isActive: false },
{ url: "https://example.com", isActive: true },
];
const result = mergeWebTabContexts(tabs);
expect(result[0].isActive).toBe(true);
});
it("should filter out tabs with invalid URLs", () => {
const tabs: WebTabContext[] = [
{ url: "https://valid.com" },
{ url: "" },
{ url: " " },
{ url: "https://another-valid.com" },
];
const result = mergeWebTabContexts(tabs);
expect(result).toHaveLength(2);
expect(result.map((t) => t.url)).toEqual(["https://valid.com", "https://another-valid.com"]);
});
});
describe("sanitizeWebTabContexts", () => {
it("should deduplicate and normalize tabs", () => {
const tabs: WebTabContext[] = [
{ url: " https://example.com " },
{ url: "https://example.com" },
];
const result = sanitizeWebTabContexts(tabs);
expect(result).toHaveLength(1);
expect(result[0].url).toBe("https://example.com");
});
it("should ensure only one tab has isActive=true", () => {
const tabs: WebTabContext[] = [
{ url: "https://first.com", isActive: true },
{ url: "https://second.com", isActive: true },
{ url: "https://third.com", isActive: true },
];
const result = sanitizeWebTabContexts(tabs);
const activeTabs = result.filter((t) => t.isActive);
expect(activeTabs).toHaveLength(1);
expect(activeTabs[0].url).toBe("https://first.com");
});
it("should preserve single isActive flag", () => {
const tabs: WebTabContext[] = [
{ url: "https://first.com" },
{ url: "https://second.com", isActive: true },
{ url: "https://third.com" },
];
const result = sanitizeWebTabContexts(tabs);
expect(result.find((t) => t.url === "https://second.com")?.isActive).toBe(true);
expect(result.find((t) => t.url === "https://first.com")?.isActive).toBeUndefined();
expect(result.find((t) => t.url === "https://third.com")?.isActive).toBeUndefined();
});
});

View file

@ -0,0 +1,172 @@
import type { WebTabContext } from "@/types/message";
/**
* URL Normalization Utilities
*
* Shared policy for URL normalization across ChatInput, ChatManager, and ContextProcessor.
* Ensures consistent deduplication and comparison of web tab contexts.
*/
/**
* Normalize a URL string for use in context and deduplication.
* Only trims whitespace - preserves hash, query params, etc.
*
* @param url - URL string to normalize
* @returns Trimmed URL, or null if empty after trimming
*/
export function normalizeUrlString(url: string | null | undefined): string | null {
if (typeof url !== "string") return null;
const trimmed = url.trim();
return trimmed ? trimmed : null;
}
/**
* Normalize a URL for matching/deduplication purposes.
* More aggressive normalization than normalizeUrlString:
* - Removes hash fragments
* - Removes default ports (:80 for http, :443 for https)
* - Normalizes trailing slashes (removes except for root)
* - Sorts query parameters for stable comparison
*
* Use this for URL comparison when determining if two URLs point to the same page.
*
* Note: For invalid URLs that cannot be parsed, returns the trimmed string as fallback.
*
* @param url - URL string to normalize
* @returns Normalized URL for matching, null if empty/null/undefined, or trimmed string if URL parsing fails
*/
export function normalizeUrlForMatching(url: string | null | undefined): string | null {
if (typeof url !== "string") return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
parsed.hash = "";
// Remove default ports
if (
(parsed.protocol === "http:" && parsed.port === "80") ||
(parsed.protocol === "https:" && parsed.port === "443")
) {
parsed.port = "";
}
// Normalize trailing slashes (remove except for root path)
if (parsed.pathname !== "/") {
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
}
// Sort query parameters for stable comparison
const entries = Array.from(parsed.searchParams.entries());
if (entries.length > 0) {
entries.sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey !== bKey) return aKey.localeCompare(bKey);
return aValue.localeCompare(bValue);
});
parsed.search = `?${new URLSearchParams(entries).toString()}`;
} else {
parsed.search = "";
}
return parsed.toString();
} catch {
// If URL parsing fails, return trimmed string as fallback
return trimmed;
}
}
/**
* Normalize an optional metadata string (title/faviconUrl) by trimming and dropping empties.
*
* @param value - Raw string value
* @returns Trimmed string, or undefined if empty after trimming
*/
export function normalizeOptionalString(value: string | null | undefined): string | undefined {
const normalized = normalizeUrlString(value);
return normalized ?? undefined;
}
/**
* Normalize a WebTabContext object for stable storage, comparisons, and deduplication.
*
* @param tab - Web tab context
* @returns Normalized web tab context, or null if URL is empty/invalid
*/
export function normalizeWebTabContext(tab: WebTabContext): WebTabContext | null {
const url = normalizeUrlString(tab.url);
if (!url) return null;
const title = normalizeOptionalString(tab.title);
const faviconUrl = normalizeOptionalString(tab.faviconUrl);
return {
url,
title,
faviconUrl,
isLoaded: tab.isLoaded,
isActive: tab.isActive ? true : undefined,
};
}
/**
* Merge and deduplicate WebTabContext entries by normalized URL.
*
* Merge policy:
* - Preserves insertion order of the first occurrence of each URL
* - Later entries fill/override missing metadata (title/favicon/isLoaded)
* - isActive becomes true if any merged entry is active
*
* @param tabs - Input web tab contexts
* @returns Deduplicated, merged list
*/
export function mergeWebTabContexts(tabs: WebTabContext[]): WebTabContext[] {
const byUrl = new Map<string, WebTabContext>();
for (const tab of tabs) {
const normalized = normalizeWebTabContext(tab);
if (!normalized) continue;
const existing = byUrl.get(normalized.url);
if (!existing) {
byUrl.set(normalized.url, normalized);
continue;
}
byUrl.set(normalized.url, {
...existing,
title: normalized.title ?? existing.title,
faviconUrl: normalized.faviconUrl ?? existing.faviconUrl,
isLoaded: normalized.isLoaded ?? existing.isLoaded,
isActive: existing.isActive || normalized.isActive ? true : undefined,
});
}
return Array.from(byUrl.values());
}
/**
* Sanitize a webTabs array:
* - Normalize and deduplicate by URL
* - Ensure at most one tab has isActive=true
*
* @param tabs - Input web tab contexts
* @returns Sanitized web tab contexts
*/
export function sanitizeWebTabContexts(tabs: WebTabContext[]): WebTabContext[] {
const merged = mergeWebTabContexts(tabs);
let hasActive = false;
return merged.map((tab) => {
if (!tab.isActive) return tab;
if (!hasActive) {
hasActive = true;
return tab;
}
// Remove duplicate isActive flags
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isActive: _unused, ...rest } = tab;
return rest;
});
}