Browse Source

fix: resolve URL loading timeout issues in @ mentions (#5160)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Daniel Riccio <[email protected]>
Murilo Pires 8 tháng trước cách đây
mục cha
commit
64279166c4

+ 121 - 271
src/core/mentions/__tests__/index.spec.ts

@@ -1,310 +1,160 @@
-import type { Mock } from "vitest"
-
-// Mock modules - must come before imports
-vi.mock("vscode", () => {
-	const createMockUri = (scheme: string, path: string) => ({
-		scheme,
-		authority: "",
-		path,
-		query: "",
-		fragment: "",
-		fsPath: path,
-		with: vi.fn(),
-		toString: () => path,
-		toJSON: () => ({
-			scheme,
-			authority: "",
-			path,
-			query: "",
-			fragment: "",
-		}),
-	})
+// npx vitest core/mentions/__tests__/index.spec.ts
 
-	const mockExecuteCommand = vi.fn()
-	const mockOpenExternal = vi.fn()
-	const mockShowErrorMessage = vi.fn()
-
-	return {
-		workspace: {
-			workspaceFolders: [
-				{
-					uri: { fsPath: "/test/workspace" },
-				},
-			] as { uri: { fsPath: string } }[] | undefined,
-			getWorkspaceFolder: vi.fn().mockReturnValue("/test/workspace"),
-			fs: {
-				stat: vi.fn(),
-				writeFile: vi.fn(),
-			},
-			openTextDocument: vi.fn().mockResolvedValue({}),
-		},
-		window: {
-			showErrorMessage: mockShowErrorMessage,
-			showInformationMessage: vi.fn(),
-			showWarningMessage: vi.fn(),
-			createTextEditorDecorationType: vi.fn(),
-			createOutputChannel: vi.fn(),
-			createWebviewPanel: vi.fn(),
-			showTextDocument: vi.fn().mockResolvedValue({}),
-			activeTextEditor: undefined as
-				| undefined
-				| {
-						document: {
-							uri: { fsPath: string }
-						}
-				  },
-		},
-		commands: {
-			executeCommand: mockExecuteCommand,
-		},
-		env: {
-			openExternal: mockOpenExternal,
-		},
-		Uri: {
-			parse: vi.fn((url: string) => createMockUri("https", url)),
-			file: vi.fn((path: string) => createMockUri("file", path)),
-		},
-		Position: vi.fn(),
-		Range: vi.fn(),
-		TextEdit: vi.fn(),
-		WorkspaceEdit: vi.fn(),
-		DiagnosticSeverity: {
-			Error: 0,
-			Warning: 1,
-			Information: 2,
-			Hint: 3,
-		},
-	}
-})
-vi.mock("../../../services/browser/UrlContentFetcher")
-vi.mock("../../../utils/git")
-vi.mock("../../../utils/path")
-vi.mock("fs/promises", () => ({
-	default: {
-		stat: vi.fn(),
-		readdir: vi.fn(),
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import * as vscode from "vscode"
+import { parseMentions } from "../index"
+import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
+import { t } from "../../../i18n"
+
+// Mock vscode
+vi.mock("vscode", () => ({
+	window: {
+		showErrorMessage: vi.fn(),
 	},
-	stat: vi.fn(),
-	readdir: vi.fn(),
-}))
-vi.mock("../../../integrations/misc/open-file", () => ({
-	openFile: vi.fn(),
-}))
-vi.mock("../../../integrations/misc/extract-text", () => ({
-	extractTextFromFile: vi.fn(),
 }))
 
-// Now import the modules that use the mocks
-import { parseMentions, openMention } from "../index"
-import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
-import * as git from "../../../utils/git"
-import { getWorkspacePath } from "../../../utils/path"
-import fs from "fs/promises"
-import * as path from "path"
-import { openFile } from "../../../integrations/misc/open-file"
-import { extractTextFromFile } from "../../../integrations/misc/extract-text"
-import * as vscode from "vscode"
-;(getWorkspacePath as Mock).mockReturnValue("/test/workspace")
+// Mock i18n
+vi.mock("../../../i18n", () => ({
+	t: vi.fn((key: string) => key),
+}))
 
-describe("mentions", () => {
-	const mockCwd = "/test/workspace"
+describe("parseMentions - URL error handling", () => {
 	let mockUrlContentFetcher: UrlContentFetcher
+	let consoleErrorSpy: any
 
 	beforeEach(() => {
 		vi.clearAllMocks()
+		consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
 
-		// Create a mock instance with just the methods we need
 		mockUrlContentFetcher = {
-			launchBrowser: vi.fn().mockResolvedValue(undefined),
-			closeBrowser: vi.fn().mockResolvedValue(undefined),
-			urlToMarkdown: vi.fn().mockResolvedValue(""),
-		} as unknown as UrlContentFetcher
-
-		// Reset all vscode mocks using vi.mocked
-		vi.mocked(vscode.workspace.fs.stat).mockReset()
-		vi.mocked(vscode.workspace.fs.writeFile).mockReset()
-		vi.mocked(vscode.workspace.openTextDocument)
-			.mockReset()
-			.mockResolvedValue({} as any)
-		vi.mocked(vscode.window.showTextDocument)
-			.mockReset()
-			.mockResolvedValue({} as any)
-		vi.mocked(vscode.window.showErrorMessage).mockReset()
-		vi.mocked(vscode.commands.executeCommand).mockReset()
-		vi.mocked(vscode.env.openExternal).mockReset()
+			launchBrowser: vi.fn(),
+			urlToMarkdown: vi.fn(),
+			closeBrowser: vi.fn(),
+		} as any
+	})
+
+	it("should handle timeout errors with appropriate message", async () => {
+		const timeoutError = new Error("Navigation timeout of 30000 ms exceeded")
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(timeoutError)
+
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
+
+		expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching URL https://example.com:", timeoutError)
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded")
 	})
 
-	describe("parseMentions", () => {
-		let mockUrlFetcher: UrlContentFetcher
+	it("should handle DNS resolution errors", async () => {
+		const dnsError = new Error("net::ERR_NAME_NOT_RESOLVED")
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(dnsError)
 
-		beforeEach(() => {
-			mockUrlFetcher = new (UrlContentFetcher as any)()
-			;(fs.stat as Mock).mockResolvedValue({ isFile: () => true, isDirectory: () => false })
-			;(extractTextFromFile as Mock).mockResolvedValue("Mock file content")
-		})
+		const result = await parseMentions("Check @https://nonexistent.example", "/test", mockUrlContentFetcher)
 
-		it("should parse git commit mentions", async () => {
-			const commitHash = "abc1234"
-			const commitInfo = `abc1234 Fix bug in parser
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED")
+	})
 
-Author: John Doe
-Date: Mon Jan 5 23:50:06 2025 -0500
+	it("should handle network disconnection errors", async () => {
+		const networkError = new Error("net::ERR_INTERNET_DISCONNECTED")
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(networkError)
 
-Detailed commit message with multiple lines
-- Fixed parsing issue
-- Added tests`
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
-			vi.mocked(git.getCommitInfo).mockResolvedValue(commitInfo)
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED")
+	})
 
-			const result = await parseMentions(`Check out this commit @${commitHash}`, mockCwd, mockUrlContentFetcher)
+	it("should handle 403 Forbidden errors", async () => {
+		const forbiddenError = new Error("403 Forbidden")
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(forbiddenError)
 
-			expect(result).toContain(`'${commitHash}' (see below for commit info)`)
-			expect(result).toContain(`<git_commit hash="${commitHash}">`)
-			expect(result).toContain(commitInfo)
-		})
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
-		it("should handle errors fetching git info", async () => {
-			const commitHash = "abc1234"
-			const errorMessage = "Failed to get commit info"
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content: 403 Forbidden")
+	})
 
-			vi.mocked(git.getCommitInfo).mockRejectedValue(new Error(errorMessage))
+	it("should handle 404 Not Found errors", async () => {
+		const notFoundError = new Error("404 Not Found")
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(notFoundError)
 
-			const result = await parseMentions(`Check out this commit @${commitHash}`, mockCwd, mockUrlContentFetcher)
+		const result = await parseMentions("Check @https://example.com/missing", "/test", mockUrlContentFetcher)
 
-			expect(result).toContain(`'${commitHash}' (see below for commit info)`)
-			expect(result).toContain(`<git_commit hash="${commitHash}">`)
-			expect(result).toContain(`Error fetching commit info: ${errorMessage}`)
-		})
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content: 404 Not Found")
+	})
 
-		it("should correctly parse mentions with escaped spaces and fetch content", async () => {
-			const text = "Please check the file @/path/to/file\\ with\\ spaces.txt"
-			const expectedUnescaped = "path/to/file with spaces.txt" // Note: leading '/' removed by slice(1) in parseMentions
-			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
+	it("should handle generic errors with fallback message", async () => {
+		const genericError = new Error("Some unexpected error")
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(genericError)
 
-			const result = await parseMentions(text, mockCwd, mockUrlFetcher)
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
-			// Check if fs.stat was called with the unescaped path
-			expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
-			// Check if extractTextFromFile was called with the unescaped path
-			expect(extractTextFromFile).toHaveBeenCalledWith(expectedAbsPath)
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content: Some unexpected error")
+	})
 
-			// Check the output format
-			expect(result).toContain(`'path/to/file\\ with\\ spaces.txt' (see below for file content)`)
-			expect(result).toContain(
-				`<file_content path="path/to/file\\ with\\ spaces.txt">\nMock file content\n</file_content>`,
-			)
-		})
+	it("should handle non-Error objects thrown", async () => {
+		const nonErrorObject = { code: "UNKNOWN", details: "Something went wrong" }
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockRejectedValue(nonErrorObject)
 
-		it("should handle folder mentions with escaped spaces", async () => {
-			const text = "Look in @/my\\ documents/folder\\ name/"
-			const expectedUnescaped = "my documents/folder name/"
-			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
-			;(fs.stat as Mock).mockResolvedValue({ isFile: () => false, isDirectory: () => true })
-			;(fs.readdir as Mock).mockResolvedValue([]) // Empty directory
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
-			const result = await parseMentions(text, mockCwd, mockUrlFetcher)
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
+		expect(result).toContain("Error fetching content:")
+	})
 
-			expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
-			expect(fs.readdir).toHaveBeenCalledWith(expectedAbsPath, { withFileTypes: true })
-			expect(result).toContain(`'my\\ documents/folder\\ name/' (see below for folder content)`)
-			expect(result).toContain(`<folder_content path="my\\ documents/folder\\ name/">`) // Content check might be more complex
-		})
+	it("should handle browser launch errors correctly", async () => {
+		const launchError = new Error("Failed to launch browser")
+		vi.mocked(mockUrlContentFetcher.launchBrowser).mockRejectedValue(launchError)
 
-		it("should handle errors when accessing paths with escaped spaces", async () => {
-			const text = "Check @/nonexistent\\ file.txt"
-			const expectedUnescaped = "nonexistent file.txt"
-			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
-			const mockError = new Error("ENOENT: no such file or directory")
-			;(fs.stat as Mock).mockRejectedValue(mockError)
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
+
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+			"Error fetching content for https://example.com: Failed to launch browser",
+		)
+		expect(result).toContain("Error fetching content: Failed to launch browser")
+		// Should not attempt to fetch URL if browser launch failed
+		expect(mockUrlContentFetcher.urlToMarkdown).not.toHaveBeenCalled()
+	})
+
+	it("should handle browser launch errors without message property", async () => {
+		const launchError = "String error"
+		vi.mocked(mockUrlContentFetcher.launchBrowser).mockRejectedValue(launchError)
+
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
+
+		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+			"Error fetching content for https://example.com: String error",
+		)
+		expect(result).toContain("Error fetching content: String error")
+	})
 
-			const result = await parseMentions(text, mockCwd, mockUrlFetcher)
+	it("should successfully fetch URL content when no errors occur", async () => {
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown).mockResolvedValue("# Example Content\n\nThis is the content.")
 
-			expect(fs.stat).toHaveBeenCalledWith(expectedAbsPath)
-			expect(result).toContain(
-				`<file_content path="nonexistent\\ file.txt">\nError fetching content: Failed to access path "nonexistent\\ file.txt": ${mockError.message}\n</file_content>`,
-			)
-		})
+		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
-		// Add more tests for parseMentions if needed (URLs, other mentions combined with escaped paths etc.)
+		expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
+		expect(result).toContain('<url_content url="https://example.com">')
+		expect(result).toContain("# Example Content\n\nThis is the content.")
+		expect(result).toContain("</url_content>")
 	})
 
-	describe("openMention", () => {
-		beforeEach(() => {
-			;(getWorkspacePath as Mock).mockReturnValue(mockCwd)
-		})
-
-		it("should handle URLs", async () => {
-			const url = "https://example.com"
-			await openMention(url)
-			const mockUri = vscode.Uri.parse(url)
-			expect(vscode.env.openExternal).toHaveBeenCalled()
-			const calledArg = (vscode.env.openExternal as Mock).mock.calls[0][0]
-			expect(calledArg).toEqual(
-				expect.objectContaining({
-					scheme: mockUri.scheme,
-					authority: mockUri.authority,
-					path: mockUri.path,
-					query: mockUri.query,
-					fragment: mockUri.fragment,
-				}),
-			)
-		})
-
-		it("should unescape file path before opening", async () => {
-			const mention = "/file\\ with\\ spaces.txt"
-			const expectedUnescaped = "file with spaces.txt"
-			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
-
-			await openMention(mention)
-
-			expect(openFile).toHaveBeenCalledWith(expectedAbsPath)
-			expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
-		})
-
-		it("should unescape folder path before revealing", async () => {
-			const mention = "/folder\\ with\\ spaces/"
-			const expectedUnescaped = "folder with spaces/"
-			const expectedAbsPath = path.resolve(mockCwd, expectedUnescaped)
-			const expectedUri = { fsPath: expectedAbsPath } // From mock
-			;(vscode.Uri.file as Mock).mockReturnValue(expectedUri)
-
-			await openMention(mention)
-
-			expect(vscode.commands.executeCommand).toHaveBeenCalledWith("revealInExplorer", expectedUri)
-			expect(vscode.Uri.file).toHaveBeenCalledWith(expectedAbsPath)
-			expect(openFile).not.toHaveBeenCalled()
-		})
-
-		it("should handle mentions without paths correctly", async () => {
-			await openMention("problems")
-			expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.actions.view.problems")
-
-			await openMention("terminal")
-			expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.terminal.focus")
-
-			await openMention("http://example.com")
-			expect(vscode.env.openExternal).toHaveBeenCalled() // Check if called, specific URI mock might be needed for detailed check
-
-			await openMention("git-changes") // Assuming no specific action for this yet
-			// Add expectations if an action is defined for git-changes
-
-			await openMention("a1b2c3d") // Assuming no specific action for commit hashes yet
-			// Add expectations if an action is defined for commit hashes
-		})
-
-		it("should do nothing if mention is undefined or empty", async () => {
-			await openMention(undefined)
-			await openMention("")
-			expect(openFile).not.toHaveBeenCalled()
-			expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
-			expect(vscode.env.openExternal).not.toHaveBeenCalled()
-		})
-
-		it("should do nothing if cwd is not available", async () => {
-			;(getWorkspacePath as Mock).mockReturnValue(undefined)
-			await openMention("/some\\ path.txt")
-			expect(openFile).not.toHaveBeenCalled()
-			expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
-		})
+	it("should handle multiple URLs with mixed success and failure", async () => {
+		vi.mocked(mockUrlContentFetcher.urlToMarkdown)
+			.mockResolvedValueOnce("# First Site")
+			.mockRejectedValueOnce(new Error("timeout"))
+
+		const result = await parseMentions(
+			"Check @https://example1.com and @https://example2.com",
+			"/test",
+			mockUrlContentFetcher,
+		)
+
+		expect(result).toContain('<url_content url="https://example1.com">')
+		expect(result).toContain("# First Site")
+		expect(result).toContain('<url_content url="https://example2.com">')
+		expect(result).toContain("Error fetching content: timeout")
 	})
 })

+ 45 - 4
src/core/mentions/index.ts

@@ -19,6 +19,32 @@ import { FileContextTracker } from "../context-tracking/FileContextTracker"
 
 import { RooIgnoreController } from "../ignore/RooIgnoreController"
 
+import { t } from "../../i18n"
+
+function getUrlErrorMessage(error: unknown): string {
+	const errorMessage = error instanceof Error ? error.message : String(error)
+
+	// Check for common error patterns and return appropriate message
+	if (errorMessage.includes("timeout")) {
+		return t("common:errors.url_timeout")
+	}
+	if (errorMessage.includes("net::ERR_NAME_NOT_RESOLVED")) {
+		return t("common:errors.url_not_found")
+	}
+	if (errorMessage.includes("net::ERR_INTERNET_DISCONNECTED")) {
+		return t("common:errors.no_internet")
+	}
+	if (errorMessage.includes("403") || errorMessage.includes("Forbidden")) {
+		return t("common:errors.url_forbidden")
+	}
+	if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
+		return t("common:errors.url_page_not_found")
+	}
+
+	// Default error message
+	return t("common:errors.url_fetch_failed", { error: errorMessage })
+}
+
 export async function openMention(mention?: string): Promise<void> {
 	if (!mention) {
 		return
@@ -84,7 +110,8 @@ export async function parseMentions(
 			await urlContentFetcher.launchBrowser()
 		} catch (error) {
 			launchBrowserError = error
-			vscode.window.showErrorMessage(`Error fetching content for ${urlMention}: ${error.message}`)
+			const errorMessage = error instanceof Error ? error.message : String(error)
+			vscode.window.showErrorMessage(`Error fetching content for ${urlMention}: ${errorMessage}`)
 		}
 	}
 
@@ -92,14 +119,28 @@ export async function parseMentions(
 		if (mention.startsWith("http")) {
 			let result: string
 			if (launchBrowserError) {
-				result = `Error fetching content: ${launchBrowserError.message}`
+				const errorMessage =
+					launchBrowserError instanceof Error ? launchBrowserError.message : String(launchBrowserError)
+				result = `Error fetching content: ${errorMessage}`
 			} else {
 				try {
 					const markdown = await urlContentFetcher.urlToMarkdown(mention)
 					result = markdown
 				} catch (error) {
-					vscode.window.showErrorMessage(`Error fetching content for ${mention}: ${error.message}`)
-					result = `Error fetching content: ${error.message}`
+					console.error(`Error fetching URL ${mention}:`, error)
+
+					// Get raw error message for AI
+					const rawErrorMessage = error instanceof Error ? error.message : String(error)
+
+					// Get localized error message for UI notification
+					const localizedErrorMessage = getUrlErrorMessage(error)
+
+					vscode.window.showErrorMessage(
+						t("common:errors.url_fetch_error_with_url", { url: mention, error: localizedErrorMessage }),
+					)
+
+					// Send raw error message to AI model
+					result = `Error fetching content: ${rawErrorMessage}`
 				}
 			}
 			parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`

+ 7 - 0
src/i18n/locales/ca/common.json

@@ -62,6 +62,13 @@
 		"condensed_recently": "El context s'ha condensat recentment; s'omet aquest intent",
 		"condense_handler_invalid": "El gestor de l'API per condensar el context no és vàlid",
 		"condense_context_grew": "La mida del context ha augmentat durant la condensació; s'omet aquest intent",
+		"url_timeout": "El lloc web ha trigat massa a carregar (timeout). Això pot ser degut a una connexió lenta, un lloc web pesat o temporalment no disponible. Pots tornar-ho a provar més tard o comprovar si la URL és correcta.",
+		"url_not_found": "No s'ha pogut trobar l'adreça del lloc web. Comprova si la URL és correcta i torna-ho a provar.",
+		"no_internet": "No hi ha connexió a internet. Comprova la teva connexió de xarxa i torna-ho a provar.",
+		"url_forbidden": "L'accés a aquest lloc web està prohibit. El lloc pot bloquejar l'accés automatitzat o requerir autenticació.",
+		"url_page_not_found": "No s'ha trobat la pàgina. Comprova si la URL és correcta.",
+		"url_fetch_failed": "Error en obtenir el contingut de la URL: {{error}}",
+		"url_fetch_error_with_url": "Error en obtenir contingut per {{url}}: {{error}}",
 		"share_task_failed": "Ha fallat compartir la tasca. Si us plau, torna-ho a provar.",
 		"share_no_active_task": "No hi ha cap tasca activa per compartir",
 		"share_auth_required": "Es requereix autenticació. Si us plau, inicia sessió per compartir tasques.",

+ 7 - 0
src/i18n/locales/de/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Kontext wurde kürzlich verdichtet; dieser Versuch wird übersprungen",
 		"condense_handler_invalid": "API-Handler zum Verdichten des Kontexts ist ungültig",
 		"condense_context_grew": "Kontextgröße ist während der Verdichtung gewachsen; dieser Versuch wird übersprungen",
+		"url_timeout": "Die Website hat zu lange zum Laden gebraucht (Timeout). Das könnte an einer langsamen Verbindung, einer schweren Website oder vorübergehender Nichtverfügbarkeit liegen. Du kannst es später nochmal versuchen oder prüfen, ob die URL korrekt ist.",
+		"url_not_found": "Die Website-Adresse konnte nicht gefunden werden. Bitte prüfe, ob die URL korrekt ist und versuche es erneut.",
+		"no_internet": "Keine Internetverbindung. Bitte prüfe deine Netzwerkverbindung und versuche es erneut.",
+		"url_forbidden": "Zugriff auf diese Website ist verboten. Die Seite könnte automatisierten Zugriff blockieren oder eine Authentifizierung erfordern.",
+		"url_page_not_found": "Die Seite wurde nicht gefunden. Bitte prüfe, ob die URL korrekt ist.",
+		"url_fetch_failed": "Fehler beim Abrufen des URL-Inhalts: {{error}}",
+		"url_fetch_error_with_url": "Fehler beim Abrufen des Inhalts für {{url}}: {{error}}",
 		"share_task_failed": "Teilen der Aufgabe fehlgeschlagen. Bitte versuche es erneut.",
 		"share_no_active_task": "Keine aktive Aufgabe zum Teilen",
 		"share_auth_required": "Authentifizierung erforderlich. Bitte melde dich an, um Aufgaben zu teilen.",

+ 7 - 0
src/i18n/locales/en/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Context was condensed recently; skipping this attempt",
 		"condense_handler_invalid": "API handler for condensing context is invalid",
 		"condense_context_grew": "Context size increased during condensing; skipping this attempt",
+		"url_timeout": "The website took too long to load (timeout). This could be due to a slow connection, heavy website, or the site being temporarily unavailable. You can try again later or check if the URL is correct.",
+		"url_not_found": "The website address could not be found. Please check if the URL is correct and try again.",
+		"no_internet": "No internet connection. Please check your network connection and try again.",
+		"url_forbidden": "Access to this website is forbidden. The site may block automated access or require authentication.",
+		"url_page_not_found": "The page was not found. Please check if the URL is correct.",
+		"url_fetch_failed": "Failed to fetch URL content: {{error}}",
+		"url_fetch_error_with_url": "Error fetching content for {{url}}: {{error}}",
 		"share_task_failed": "Failed to share task. Please try again.",
 		"share_no_active_task": "No active task to share",
 		"share_auth_required": "Authentication required. Please sign in to share tasks.",

+ 7 - 0
src/i18n/locales/es/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "El contexto se condensó recientemente; se omite este intento",
 		"condense_handler_invalid": "El manejador de API para condensar el contexto no es válido",
 		"condense_context_grew": "El tamaño del contexto aumentó durante la condensación; se omite este intento",
+		"url_timeout": "El sitio web tardó demasiado en cargar (timeout). Esto podría deberse a una conexión lenta, un sitio web pesado o que esté temporalmente no disponible. Puedes intentarlo más tarde o verificar si la URL es correcta.",
+		"url_not_found": "No se pudo encontrar la dirección del sitio web. Por favor verifica si la URL es correcta e inténtalo de nuevo.",
+		"no_internet": "Sin conexión a internet. Por favor verifica tu conexión de red e inténtalo de nuevo.",
+		"url_forbidden": "El acceso a este sitio web está prohibido. El sitio puede bloquear el acceso automatizado o requerir autenticación.",
+		"url_page_not_found": "La página no fue encontrada. Por favor verifica si la URL es correcta.",
+		"url_fetch_failed": "Error al obtener el contenido de la URL: {{error}}",
+		"url_fetch_error_with_url": "Error al obtener contenido para {{url}}: {{error}}",
 		"share_task_failed": "Error al compartir la tarea. Por favor, inténtalo de nuevo.",
 		"share_no_active_task": "No hay tarea activa para compartir",
 		"share_auth_required": "Se requiere autenticación. Por favor, inicia sesión para compartir tareas.",

+ 7 - 0
src/i18n/locales/fr/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Le contexte a été condensé récemment ; cette tentative est ignorée",
 		"condense_handler_invalid": "Le gestionnaire d'API pour condenser le contexte est invalide",
 		"condense_context_grew": "La taille du contexte a augmenté pendant la condensation ; cette tentative est ignorée",
+		"url_timeout": "Le site web a pris trop de temps à charger (timeout). Cela pourrait être dû à une connexion lente, un site web lourd ou temporairement indisponible. Tu peux réessayer plus tard ou vérifier si l'URL est correcte.",
+		"url_not_found": "L'adresse du site web n'a pas pu être trouvée. Vérifie si l'URL est correcte et réessaie.",
+		"no_internet": "Pas de connexion internet. Vérifie ta connexion réseau et réessaie.",
+		"url_forbidden": "L'accès à ce site web est interdit. Le site peut bloquer l'accès automatisé ou nécessiter une authentification.",
+		"url_page_not_found": "La page n'a pas été trouvée. Vérifie si l'URL est correcte.",
+		"url_fetch_failed": "Échec de récupération du contenu de l'URL : {{error}}",
+		"url_fetch_error_with_url": "Erreur lors de la récupération du contenu pour {{url}} : {{error}}",
 		"share_task_failed": "Échec du partage de la tâche. Veuillez réessayer.",
 		"share_no_active_task": "Aucune tâche active à partager",
 		"share_auth_required": "Authentification requise. Veuillez vous connecter pour partager des tâches.",

+ 7 - 0
src/i18n/locales/hi/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "संदर्भ हाल ही में संक्षिप्त किया गया था; इस प्रयास को छोड़ा जा रहा है",
 		"condense_handler_invalid": "संदर्भ को संक्षिप्त करने के लिए API हैंडलर अमान्य है",
 		"condense_context_grew": "संक्षिप्तीकरण के दौरान संदर्भ का आकार बढ़ गया; इस प्रयास को छोड़ा जा रहा है",
+		"url_timeout": "वेबसाइट लोड होने में बहुत समय लगा (टाइमआउट)। यह धीमे कनेक्शन, भारी वेबसाइट या अस्थायी रूप से अनुपलब्ध होने के कारण हो सकता है। आप बाद में फिर से कोशिश कर सकते हैं या जांच सकते हैं कि URL सही है या नहीं।",
+		"url_not_found": "वेबसाइट का पता नहीं मिल सका। कृपया जांचें कि URL सही है और फिर से कोशिश करें।",
+		"no_internet": "इंटरनेट कनेक्शन नहीं है। कृपया अपना नेटवर्क कनेक्शन जांचें और फिर से कोशिश करें।",
+		"url_forbidden": "इस वेबसाइट तक पहुंच प्रतिबंधित है। साइट स्वचालित पहुंच को ब्लॉक कर सकती है या प्रमाणीकरण की आवश्यकता हो सकती है।",
+		"url_page_not_found": "पेज नहीं मिला। कृपया जांचें कि URL सही है।",
+		"url_fetch_failed": "URL सामग्री प्राप्त करने में त्रुटि: {{error}}",
+		"url_fetch_error_with_url": "{{url}} के लिए सामग्री प्राप्त करने में त्रुटि: {{error}}",
 		"share_task_failed": "कार्य साझा करने में विफल। कृपया पुनः प्रयास करें।",
 		"share_no_active_task": "साझा करने के लिए कोई सक्रिय कार्य नहीं",
 		"share_auth_required": "प्रमाणीकरण आवश्यक है। कार्य साझा करने के लिए कृपया साइन इन करें।",

+ 7 - 0
src/i18n/locales/id/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Konteks baru saja dikompres; melewati percobaan ini",
 		"condense_handler_invalid": "Handler API untuk mengompres konteks tidak valid",
 		"condense_context_grew": "Ukuran konteks bertambah saat mengompres; melewati percobaan ini",
+		"url_timeout": "Situs web membutuhkan waktu terlalu lama untuk dimuat (timeout). Ini bisa disebabkan oleh koneksi lambat, situs web berat, atau sementara tidak tersedia. Kamu bisa mencoba lagi nanti atau memeriksa apakah URL sudah benar.",
+		"url_not_found": "Alamat situs web tidak dapat ditemukan. Silakan periksa apakah URL sudah benar dan coba lagi.",
+		"no_internet": "Tidak ada koneksi internet. Silakan periksa koneksi jaringan kamu dan coba lagi.",
+		"url_forbidden": "Akses ke situs web ini dilarang. Situs mungkin memblokir akses otomatis atau memerlukan autentikasi.",
+		"url_page_not_found": "Halaman tidak ditemukan. Silakan periksa apakah URL sudah benar.",
+		"url_fetch_failed": "Gagal mengambil konten URL: {{error}}",
+		"url_fetch_error_with_url": "Error mengambil konten untuk {{url}}: {{error}}",
 		"share_task_failed": "Gagal membagikan tugas. Silakan coba lagi.",
 		"share_no_active_task": "Tidak ada tugas aktif untuk dibagikan",
 		"share_auth_required": "Autentikasi diperlukan. Silakan masuk untuk berbagi tugas.",

+ 7 - 0
src/i18n/locales/it/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Il contesto è stato condensato di recente; questo tentativo viene saltato",
 		"condense_handler_invalid": "Il gestore API per condensare il contesto non è valido",
 		"condense_context_grew": "La dimensione del contesto è aumentata durante la condensazione; questo tentativo viene saltato",
+		"url_timeout": "Il sito web ha impiegato troppo tempo a caricarsi (timeout). Questo potrebbe essere dovuto a una connessione lenta, un sito web pesante o temporaneamente non disponibile. Puoi riprovare più tardi o verificare se l'URL è corretto.",
+		"url_not_found": "L'indirizzo del sito web non è stato trovato. Verifica se l'URL è corretto e riprova.",
+		"no_internet": "Nessuna connessione internet. Verifica la tua connessione di rete e riprova.",
+		"url_forbidden": "L'accesso a questo sito web è vietato. Il sito potrebbe bloccare l'accesso automatizzato o richiedere autenticazione.",
+		"url_page_not_found": "La pagina non è stata trovata. Verifica se l'URL è corretto.",
+		"url_fetch_failed": "Errore nel recupero del contenuto URL: {{error}}",
+		"url_fetch_error_with_url": "Errore nel recupero del contenuto per {{url}}: {{error}}",
 		"share_task_failed": "Condivisione dell'attività fallita. Riprova.",
 		"share_no_active_task": "Nessuna attività attiva da condividere",
 		"share_auth_required": "Autenticazione richiesta. Accedi per condividere le attività.",

+ 7 - 0
src/i18n/locales/ja/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "コンテキストは最近圧縮されました;この試行をスキップします",
 		"condense_handler_invalid": "コンテキストを圧縮するためのAPIハンドラーが無効です",
 		"condense_context_grew": "圧縮中にコンテキストサイズが増加しました;この試行をスキップします",
+		"url_timeout": "ウェブサイトの読み込みがタイムアウトしました。接続が遅い、ウェブサイトが重い、または一時的に利用できない可能性があります。後でもう一度試すか、URLが正しいか確認してください。",
+		"url_not_found": "ウェブサイトのアドレスが見つかりませんでした。URLが正しいか確認してもう一度試してください。",
+		"no_internet": "インターネット接続がありません。ネットワーク接続を確認してもう一度試してください。",
+		"url_forbidden": "このウェブサイトへのアクセスが禁止されています。サイトが自動アクセスをブロックしているか、認証が必要な可能性があります。",
+		"url_page_not_found": "ページが見つかりませんでした。URLが正しいか確認してください。",
+		"url_fetch_failed": "URLコンテンツの取得に失敗しました:{{error}}",
+		"url_fetch_error_with_url": "{{url}} のコンテンツ取得エラー:{{error}}",
 		"share_task_failed": "タスクの共有に失敗しました",
 		"share_no_active_task": "共有するアクティブなタスクがありません",
 		"share_auth_required": "認証が必要です。タスクを共有するにはサインインしてください。",

+ 7 - 0
src/i18n/locales/ko/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "컨텍스트가 최근 압축되었습니다; 이 시도를 건너뜁니다",
 		"condense_handler_invalid": "컨텍스트 압축을 위한 API 핸들러가 유효하지 않습니다",
 		"condense_context_grew": "압축 중 컨텍스트 크기가 증가했습니다; 이 시도를 건너뜁니다",
+		"url_timeout": "웹사이트 로딩이 너무 오래 걸렸습니다(타임아웃). 느린 연결, 무거운 웹사이트 또는 일시적으로 사용할 수 없는 상태일 수 있습니다. 나중에 다시 시도하거나 URL이 올바른지 확인해 주세요.",
+		"url_not_found": "웹사이트 주소를 찾을 수 없습니다. URL이 올바른지 확인하고 다시 시도해 주세요.",
+		"no_internet": "인터넷 연결이 없습니다. 네트워크 연결을 확인하고 다시 시도해 주세요.",
+		"url_forbidden": "이 웹사이트에 대한 접근이 금지되었습니다. 사이트가 자동 접근을 차단하거나 인증이 필요할 수 있습니다.",
+		"url_page_not_found": "페이지를 찾을 수 없습니다. URL이 올바른지 확인해 주세요.",
+		"url_fetch_failed": "URL 콘텐츠 가져오기 실패: {{error}}",
+		"url_fetch_error_with_url": "{{url}} 콘텐츠 가져오기 오류: {{error}}",
 		"share_task_failed": "작업 공유에 실패했습니다",
 		"share_no_active_task": "공유할 활성 작업이 없습니다",
 		"share_auth_required": "인증이 필요합니다. 작업을 공유하려면 로그인하세요.",

+ 7 - 0
src/i18n/locales/nl/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Context is recent gecomprimeerd; deze poging wordt overgeslagen",
 		"condense_handler_invalid": "API-handler voor het comprimeren van context is ongeldig",
 		"condense_context_grew": "Contextgrootte nam toe tijdens comprimeren; deze poging wordt overgeslagen",
+		"url_timeout": "De website deed er te lang over om te laden (timeout). Dit kan komen door een trage verbinding, een zware website of tijdelijke onbeschikbaarheid. Je kunt het later opnieuw proberen of controleren of de URL correct is.",
+		"url_not_found": "Het websiteadres kon niet worden gevonden. Controleer of de URL correct is en probeer opnieuw.",
+		"no_internet": "Geen internetverbinding. Controleer je netwerkverbinding en probeer opnieuw.",
+		"url_forbidden": "Toegang tot deze website is verboden. De site kan geautomatiseerde toegang blokkeren of authenticatie vereisen.",
+		"url_page_not_found": "De pagina werd niet gevonden. Controleer of de URL correct is.",
+		"url_fetch_failed": "Fout bij ophalen van URL-inhoud: {{error}}",
+		"url_fetch_error_with_url": "Fout bij ophalen van inhoud voor {{url}}: {{error}}",
 		"share_task_failed": "Delen van taak mislukt",
 		"share_no_active_task": "Geen actieve taak om te delen",
 		"share_auth_required": "Authenticatie vereist. Log in om taken te delen.",

+ 7 - 0
src/i18n/locales/pl/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Kontekst został niedawno skondensowany; pomijanie tej próby",
 		"condense_handler_invalid": "Nieprawidłowy handler API do kondensowania kontekstu",
 		"condense_context_grew": "Rozmiar kontekstu wzrósł podczas kondensacji; pomijanie tej próby",
+		"url_timeout": "Strona internetowa ładowała się zbyt długo (timeout). Może to być spowodowane wolnym połączeniem, ciężką stroną lub tymczasową niedostępnością. Możesz spróbować ponownie później lub sprawdzić, czy URL jest poprawny.",
+		"url_not_found": "Nie można znaleźć adresu strony internetowej. Sprawdź, czy URL jest poprawny i spróbuj ponownie.",
+		"no_internet": "Brak połączenia z internetem. Sprawdź połączenie sieciowe i spróbuj ponownie.",
+		"url_forbidden": "Dostęp do tej strony internetowej jest zabroniony. Strona może blokować automatyczny dostęp lub wymagać uwierzytelnienia.",
+		"url_page_not_found": "Strona nie została znaleziona. Sprawdź, czy URL jest poprawny.",
+		"url_fetch_failed": "Błąd pobierania zawartości URL: {{error}}",
+		"url_fetch_error_with_url": "Błąd pobierania zawartości dla {{url}}: {{error}}",
 		"share_task_failed": "Nie udało się udostępnić zadania",
 		"share_no_active_task": "Brak aktywnego zadania do udostępnienia",
 		"share_auth_required": "Wymagana autoryzacja. Zaloguj się, aby udostępniać zadania.",

+ 7 - 0
src/i18n/locales/pt-BR/common.json

@@ -62,6 +62,13 @@
 		"condensed_recently": "O contexto foi condensado recentemente; pulando esta tentativa",
 		"condense_handler_invalid": "O manipulador de API para condensar o contexto é inválido",
 		"condense_context_grew": "O tamanho do contexto aumentou durante a condensação; pulando esta tentativa",
+		"url_timeout": "O site demorou muito para carregar (timeout). Isso pode ser devido a uma conexão lenta, site pesado ou temporariamente indisponível. Você pode tentar novamente mais tarde ou verificar se a URL está correta.",
+		"url_not_found": "O endereço do site não pôde ser encontrado. Verifique se a URL está correta e tente novamente.",
+		"no_internet": "Sem conexão com a internet. Verifique sua conexão de rede e tente novamente.",
+		"url_forbidden": "O acesso a este site está proibido. O site pode bloquear acesso automatizado ou exigir autenticação.",
+		"url_page_not_found": "A página não foi encontrada. Verifique se a URL está correta.",
+		"url_fetch_failed": "Falha ao buscar conteúdo da URL: {{error}}",
+		"url_fetch_error_with_url": "Erro ao buscar conteúdo para {{url}}: {{error}}",
 		"share_task_failed": "Falha ao compartilhar tarefa",
 		"share_no_active_task": "Nenhuma tarefa ativa para compartilhar",
 		"share_auth_required": "Autenticação necessária. Faça login para compartilhar tarefas.",

+ 7 - 0
src/i18n/locales/ru/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Контекст был недавно сжат; пропускаем эту попытку",
 		"condense_handler_invalid": "Обработчик API для сжатия контекста недействителен",
 		"condense_context_grew": "Размер контекста увеличился во время сжатия; пропускаем эту попытку",
+		"url_timeout": "Веб-сайт слишком долго загружался (таймаут). Это может быть из-за медленного соединения, тяжелого веб-сайта или временной недоступности. Ты можешь попробовать позже или проверить правильность URL.",
+		"url_not_found": "Адрес веб-сайта не найден. Проверь правильность URL и попробуй снова.",
+		"no_internet": "Нет подключения к интернету. Проверь сетевое подключение и попробуй снова.",
+		"url_forbidden": "Доступ к этому веб-сайту запрещен. Сайт может блокировать автоматический доступ или требовать аутентификацию.",
+		"url_page_not_found": "Страница не найдена. Проверь правильность URL.",
+		"url_fetch_failed": "Ошибка получения содержимого URL: {{error}}",
+		"url_fetch_error_with_url": "Ошибка получения содержимого для {{url}}: {{error}}",
 		"share_task_failed": "Не удалось поделиться задачей",
 		"share_no_active_task": "Нет активной задачи для совместного использования",
 		"share_auth_required": "Требуется аутентификация. Войдите в систему для совместного доступа к задачам.",

+ 7 - 0
src/i18n/locales/tr/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Bağlam yakın zamanda sıkıştırıldı; bu deneme atlanıyor",
 		"condense_handler_invalid": "Bağlamı sıkıştırmak için API işleyicisi geçersiz",
 		"condense_context_grew": "Sıkıştırma sırasında bağlam boyutu arttı; bu deneme atlanıyor",
+		"url_timeout": "Web sitesi yüklenmesi çok uzun sürdü (zaman aşımı). Bu yavaş bağlantı, ağır web sitesi veya geçici olarak kullanılamama nedeniyle olabilir. Daha sonra tekrar deneyebilir veya URL'nin doğru olup olmadığını kontrol edebilirsin.",
+		"url_not_found": "Web sitesi adresi bulunamadı. URL'nin doğru olup olmadığını kontrol et ve tekrar dene.",
+		"no_internet": "İnternet bağlantısı yok. Ağ bağlantını kontrol et ve tekrar dene.",
+		"url_forbidden": "Bu web sitesine erişim yasak. Site otomatik erişimi engelliyor veya kimlik doğrulama gerektiriyor olabilir.",
+		"url_page_not_found": "Sayfa bulunamadı. URL'nin doğru olup olmadığını kontrol et.",
+		"url_fetch_failed": "URL içeriği getirme hatası: {{error}}",
+		"url_fetch_error_with_url": "{{url}} için içerik getirme hatası: {{error}}",
 		"share_task_failed": "Görev paylaşılamadı",
 		"share_no_active_task": "Paylaşılacak aktif görev yok",
 		"share_auth_required": "Kimlik doğrulama gerekli. Görevleri paylaşmak için lütfen giriş yapın.",

+ 7 - 0
src/i18n/locales/vi/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "Ngữ cảnh đã được nén gần đây; bỏ qua lần thử này",
 		"condense_handler_invalid": "Trình xử lý API để nén ngữ cảnh không hợp lệ",
 		"condense_context_grew": "Kích thước ngữ cảnh tăng lên trong quá trình nén; bỏ qua lần thử này",
+		"url_timeout": "Trang web mất quá nhiều thời gian để tải (timeout). Điều này có thể do kết nối chậm, trang web nặng hoặc tạm thời không khả dụng. Bạn có thể thử lại sau hoặc kiểm tra xem URL có đúng không.",
+		"url_not_found": "Không thể tìm thấy địa chỉ trang web. Vui lòng kiểm tra URL có đúng không và thử lại.",
+		"no_internet": "Không có kết nối internet. Vui lòng kiểm tra kết nối mạng và thử lại.",
+		"url_forbidden": "Truy cập vào trang web này bị cấm. Trang có thể chặn truy cập tự động hoặc yêu cầu xác thực.",
+		"url_page_not_found": "Không tìm thấy trang. Vui lòng kiểm tra URL có đúng không.",
+		"url_fetch_failed": "Lỗi lấy nội dung URL: {{error}}",
+		"url_fetch_error_with_url": "Lỗi lấy nội dung cho {{url}}: {{error}}",
 		"share_task_failed": "Không thể chia sẻ nhiệm vụ",
 		"share_no_active_task": "Không có nhiệm vụ hoạt động để chia sẻ",
 		"share_auth_required": "Cần xác thực. Vui lòng đăng nhập để chia sẻ nhiệm vụ.",

+ 7 - 0
src/i18n/locales/zh-CN/common.json

@@ -63,6 +63,13 @@
 		"condensed_recently": "上下文最近已压缩;跳过此次尝试",
 		"condense_handler_invalid": "压缩上下文的API处理程序无效",
 		"condense_context_grew": "压缩过程中上下文大小增加;跳过此次尝试",
+		"url_timeout": "网站加载超时。这可能是由于网络连接缓慢、网站负载过重或暂时不可用。你可以稍后重试或检查 URL 是否正确。",
+		"url_not_found": "找不到网站地址。请检查 URL 是否正确并重试。",
+		"no_internet": "无网络连接。请检查网络连接并重试。",
+		"url_forbidden": "访问此网站被禁止。该网站可能阻止自动访问或需要身份验证。",
+		"url_page_not_found": "页面未找到。请检查 URL 是否正确。",
+		"url_fetch_failed": "获取 URL 内容失败:{{error}}",
+		"url_fetch_error_with_url": "获取 {{url}} 内容时出错:{{error}}",
 		"share_task_failed": "分享任务失败。请重试。",
 		"share_no_active_task": "没有活跃任务可分享",
 		"share_auth_required": "需要身份验证。请登录以分享任务。",

+ 7 - 0
src/i18n/locales/zh-TW/common.json

@@ -58,6 +58,13 @@
 		"condensed_recently": "上下文最近已壓縮;跳過此次嘗試",
 		"condense_handler_invalid": "壓縮上下文的 API 處理程式無效",
 		"condense_context_grew": "壓縮過程中上下文大小增加;跳過此次嘗試",
+		"url_timeout": "網站載入超時。這可能是由於網路連線緩慢、網站負載過重或暫時無法使用。你可以稍後重試或檢查 URL 是否正確。",
+		"url_not_found": "找不到網站位址。請檢查 URL 是否正確並重試。",
+		"no_internet": "無網路連線。請檢查網路連線並重試。",
+		"url_forbidden": "存取此網站被禁止。該網站可能封鎖自動存取或需要身分驗證。",
+		"url_page_not_found": "找不到頁面。請檢查 URL 是否正確。",
+		"url_fetch_failed": "取得 URL 內容失敗:{{error}}",
+		"url_fetch_error_with_url": "取得 {{url}} 內容時發生錯誤:{{error}}",
 		"share_task_failed": "分享工作失敗。請重試。",
 		"share_no_active_task": "沒有活躍的工作可分享",
 		"share_auth_required": "需要身份驗證。請登入以分享工作。",

+ 9 - 3
src/services/browser/BrowserSession.ts

@@ -10,6 +10,9 @@ import { fileExistsAtPath } from "../../utils/fs"
 import { BrowserActionResult } from "../../shared/ExtensionMessage"
 import { discoverChromeHostUrl, tryChromeHostUrl } from "./browserDiscovery"
 
+// Timeout constants
+const BROWSER_NAVIGATION_TIMEOUT = 15_000 // 15 seconds
+
 interface PCRStats {
 	puppeteer: { launch: typeof launch }
 	executablePath: string
@@ -320,7 +323,7 @@ export class BrowserSession {
 	 * Navigate to a URL with standard loading options
 	 */
 	private async navigatePageToUrl(page: Page, url: string): Promise<void> {
-		await page.goto(url, { timeout: 7_000, waitUntil: ["domcontentloaded", "networkidle2"] })
+		await page.goto(url, { timeout: BROWSER_NAVIGATION_TIMEOUT, waitUntil: ["domcontentloaded", "networkidle2"] })
 		await this.waitTillHTMLStable(page)
 	}
 
@@ -403,7 +406,10 @@ export class BrowserSession {
 				console.log(`Root domain: ${this.getRootDomain(currentUrl)}`)
 				console.log(`New URL: ${normalizedNewUrl}`)
 				return this.doAction(async (page) => {
-					await page.reload({ timeout: 7_000, waitUntil: ["domcontentloaded", "networkidle2"] })
+					await page.reload({
+						timeout: BROWSER_NAVIGATION_TIMEOUT,
+						waitUntil: ["domcontentloaded", "networkidle2"],
+					})
 					await this.waitTillHTMLStable(page)
 				})
 			}
@@ -476,7 +482,7 @@ export class BrowserSession {
 			await page
 				.waitForNavigation({
 					waitUntil: ["domcontentloaded", "networkidle2"],
-					timeout: 7000,
+					timeout: BROWSER_NAVIGATION_TIMEOUT,
 				})
 				.catch(() => {})
 			await this.waitTillHTMLStable(page)

+ 52 - 1
src/services/browser/UrlContentFetcher.ts

@@ -7,6 +7,11 @@ import TurndownService from "turndown"
 // @ts-ignore
 import PCR from "puppeteer-chromium-resolver"
 import { fileExistsAtPath } from "../../utils/fs"
+import { serializeError } from "serialize-error"
+
+// Timeout constants
+const URL_FETCH_TIMEOUT = 30_000 // 30 seconds
+const URL_FETCH_FALLBACK_TIMEOUT = 20_000 // 20 seconds for fallback
 
 interface PCRStats {
 	puppeteer: { launch: typeof launch }
@@ -48,11 +53,24 @@ export class UrlContentFetcher {
 		this.browser = await stats.puppeteer.launch({
 			args: [
 				"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
+				"--disable-dev-shm-usage",
+				"--disable-accelerated-2d-canvas",
+				"--no-first-run",
+				"--disable-gpu",
+				"--disable-features=VizDisplayCompositor",
 			],
 			executablePath: stats.executablePath,
 		})
 		// (latest version of puppeteer does not add headless to user agent)
 		this.page = await this.browser?.newPage()
+
+		// Set additional page configurations to improve loading success
+		if (this.page) {
+			await this.page.setViewport({ width: 1280, height: 720 })
+			await this.page.setExtraHTTPHeaders({
+				"Accept-Language": "en-US,en;q=0.9",
+			})
+		}
 	}
 
 	async closeBrowser(): Promise<void> {
@@ -71,7 +89,40 @@ export class UrlContentFetcher {
 		- domcontentloaded is when the basic DOM is loaded
 		this should be sufficient for most doc sites
 		*/
-		await this.page.goto(url, { timeout: 10_000, waitUntil: ["domcontentloaded", "networkidle2"] })
+		try {
+			await this.page.goto(url, {
+				timeout: URL_FETCH_TIMEOUT,
+				waitUntil: ["domcontentloaded", "networkidle2"],
+			})
+		} catch (error) {
+			// Use serialize-error to safely extract error information
+			const serializedError = serializeError(error)
+			const errorMessage = serializedError.message || String(error)
+			const errorName = serializedError.name
+
+			// Only retry for timeout or network-related errors
+			const shouldRetry =
+				errorMessage.includes("timeout") ||
+				errorMessage.includes("net::") ||
+				errorMessage.includes("NetworkError") ||
+				errorMessage.includes("ERR_") ||
+				errorName === "TimeoutError"
+
+			if (shouldRetry) {
+				// If networkidle2 fails due to timeout/network issues, try with just domcontentloaded as fallback
+				console.warn(
+					`Failed to load ${url} with networkidle2, retrying with domcontentloaded only: ${errorMessage}`,
+				)
+				await this.page.goto(url, {
+					timeout: URL_FETCH_FALLBACK_TIMEOUT,
+					waitUntil: ["domcontentloaded"],
+				})
+			} else {
+				// For other errors, throw them as-is
+				throw error
+			}
+		}
+
 		const content = await this.page.content()
 
 		// use cheerio to parse and clean up the HTML

+ 290 - 0
src/services/browser/__tests__/UrlContentFetcher.spec.ts

@@ -0,0 +1,290 @@
+// npx vitest services/browser/__tests__/UrlContentFetcher.spec.ts
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+import { UrlContentFetcher } from "../UrlContentFetcher"
+import { fileExistsAtPath } from "../../../utils/fs"
+import * as path from "path"
+
+// Mock dependencies
+vi.mock("vscode", () => ({
+	ExtensionContext: vi.fn(),
+	Uri: {
+		file: vi.fn((path) => ({ fsPath: path })),
+	},
+}))
+
+// Mock fs/promises
+vi.mock("fs/promises", () => ({
+	default: {
+		mkdir: vi.fn().mockResolvedValue(undefined),
+	},
+	mkdir: vi.fn().mockResolvedValue(undefined),
+}))
+
+// Mock utils/fs
+vi.mock("../../../utils/fs", () => ({
+	fileExistsAtPath: vi.fn().mockResolvedValue(true),
+}))
+
+// Mock cheerio
+vi.mock("cheerio", () => ({
+	load: vi.fn(() => {
+		const $ = vi.fn((selector) => ({
+			remove: vi.fn().mockReturnThis(),
+		})) as any
+		$.html = vi.fn().mockReturnValue("<html><body>Test content</body></html>")
+		return $
+	}),
+}))
+
+// Mock turndown
+vi.mock("turndown", () => {
+	return {
+		default: class MockTurndownService {
+			turndown = vi.fn().mockReturnValue("# Test content")
+		},
+	}
+})
+
+// Mock puppeteer-chromium-resolver
+vi.mock("puppeteer-chromium-resolver", () => ({
+	default: vi.fn().mockResolvedValue({
+		puppeteer: {
+			launch: vi.fn().mockResolvedValue({
+				newPage: vi.fn().mockResolvedValue({
+					goto: vi.fn(),
+					content: vi.fn().mockResolvedValue("<html><body>Test content</body></html>"),
+					setViewport: vi.fn().mockResolvedValue(undefined),
+					setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined),
+				}),
+				close: vi.fn().mockResolvedValue(undefined),
+			}),
+		},
+		executablePath: "/path/to/chromium",
+	}),
+}))
+
+// Mock serialize-error
+vi.mock("serialize-error", () => ({
+	serializeError: vi.fn((error) => {
+		if (error instanceof Error) {
+			return { message: error.message, name: error.name }
+		} else if (typeof error === "string") {
+			return { message: error }
+		} else if (error && typeof error === "object" && "message" in error) {
+			return { message: String(error.message), name: "name" in error ? String(error.name) : undefined }
+		} else {
+			return { message: String(error) }
+		}
+	}),
+}))
+
+describe("UrlContentFetcher", () => {
+	let urlContentFetcher: UrlContentFetcher
+	let mockContext: any
+	let mockPage: any
+	let mockBrowser: any
+	let PCR: any
+
+	beforeEach(async () => {
+		vi.clearAllMocks()
+
+		mockContext = {
+			globalStorageUri: {
+				fsPath: "/test/storage",
+			},
+		}
+
+		mockPage = {
+			goto: vi.fn(),
+			content: vi.fn().mockResolvedValue("<html><body>Test content</body></html>"),
+			setViewport: vi.fn().mockResolvedValue(undefined),
+			setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined),
+		}
+
+		mockBrowser = {
+			newPage: vi.fn().mockResolvedValue(mockPage),
+			close: vi.fn().mockResolvedValue(undefined),
+		}
+
+		// Reset PCR mock
+		// @ts-ignore
+		PCR = (await import("puppeteer-chromium-resolver")).default
+		vi.mocked(PCR).mockResolvedValue({
+			puppeteer: {
+				launch: vi.fn().mockResolvedValue(mockBrowser),
+			},
+			executablePath: "/path/to/chromium",
+		})
+
+		urlContentFetcher = new UrlContentFetcher(mockContext)
+	})
+
+	afterEach(() => {
+		vi.restoreAllMocks()
+	})
+
+	describe("launchBrowser", () => {
+		it("should launch browser with correct arguments", async () => {
+			await urlContentFetcher.launchBrowser()
+
+			expect(vi.mocked(PCR)).toHaveBeenCalledWith({
+				downloadPath: path.join("/test/storage", "puppeteer"),
+			})
+
+			const stats = await vi.mocked(PCR).mock.results[0].value
+			expect(stats.puppeteer.launch).toHaveBeenCalledWith({
+				args: [
+					"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
+					"--disable-dev-shm-usage",
+					"--disable-accelerated-2d-canvas",
+					"--no-first-run",
+					"--disable-gpu",
+					"--disable-features=VizDisplayCompositor",
+				],
+				executablePath: "/path/to/chromium",
+			})
+		})
+
+		it("should set viewport and headers after launching", async () => {
+			await urlContentFetcher.launchBrowser()
+
+			expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1280, height: 720 })
+			expect(mockPage.setExtraHTTPHeaders).toHaveBeenCalledWith({
+				"Accept-Language": "en-US,en;q=0.9",
+			})
+		})
+
+		it("should not launch browser if already launched", async () => {
+			await urlContentFetcher.launchBrowser()
+			const initialCallCount = vi.mocked(PCR).mock.calls.length
+
+			await urlContentFetcher.launchBrowser()
+			expect(vi.mocked(PCR)).toHaveBeenCalledTimes(initialCallCount)
+		})
+	})
+
+	describe("urlToMarkdown", () => {
+		beforeEach(async () => {
+			await urlContentFetcher.launchBrowser()
+		})
+
+		it("should successfully fetch and convert URL to markdown", async () => {
+			mockPage.goto.mockResolvedValueOnce(undefined)
+
+			const result = await urlContentFetcher.urlToMarkdown("https://example.com")
+
+			expect(mockPage.goto).toHaveBeenCalledWith("https://example.com", {
+				timeout: 30000,
+				waitUntil: ["domcontentloaded", "networkidle2"],
+			})
+			expect(result).toBe("# Test content")
+		})
+
+		it("should retry with domcontentloaded only when networkidle2 fails", async () => {
+			const timeoutError = new Error("Navigation timeout of 30000 ms exceeded")
+			mockPage.goto.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(undefined)
+
+			const result = await urlContentFetcher.urlToMarkdown("https://example.com")
+
+			expect(mockPage.goto).toHaveBeenCalledTimes(2)
+			expect(mockPage.goto).toHaveBeenNthCalledWith(1, "https://example.com", {
+				timeout: 30000,
+				waitUntil: ["domcontentloaded", "networkidle2"],
+			})
+			expect(mockPage.goto).toHaveBeenNthCalledWith(2, "https://example.com", {
+				timeout: 20000,
+				waitUntil: ["domcontentloaded"],
+			})
+			expect(result).toBe("# Test content")
+		})
+
+		it("should retry for network errors", async () => {
+			const networkError = new Error("net::ERR_CONNECTION_REFUSED")
+			mockPage.goto.mockRejectedValueOnce(networkError).mockResolvedValueOnce(undefined)
+
+			const result = await urlContentFetcher.urlToMarkdown("https://example.com")
+
+			expect(mockPage.goto).toHaveBeenCalledTimes(2)
+			expect(result).toBe("# Test content")
+		})
+
+		it("should retry for TimeoutError", async () => {
+			const timeoutError = new Error("TimeoutError: Navigation timeout")
+			timeoutError.name = "TimeoutError"
+			mockPage.goto.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(undefined)
+
+			const result = await urlContentFetcher.urlToMarkdown("https://example.com")
+
+			expect(mockPage.goto).toHaveBeenCalledTimes(2)
+			expect(result).toBe("# Test content")
+		})
+
+		it("should not retry for non-network/timeout errors", async () => {
+			const otherError = new Error("Some other error")
+			mockPage.goto.mockRejectedValueOnce(otherError)
+
+			await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Some other error")
+			expect(mockPage.goto).toHaveBeenCalledTimes(1)
+		})
+
+		it("should throw error if browser not initialized", async () => {
+			const newFetcher = new UrlContentFetcher(mockContext)
+
+			await expect(newFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Browser not initialized")
+		})
+
+		it("should handle errors without message property", async () => {
+			const errorWithoutMessage = { code: "UNKNOWN_ERROR" }
+			mockPage.goto.mockRejectedValueOnce(errorWithoutMessage)
+
+			// serialize-error will convert this to a proper error with the object stringified
+			await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow()
+
+			// Should not retry for non-network errors
+			expect(mockPage.goto).toHaveBeenCalledTimes(1)
+		})
+
+		it("should handle error objects with message property", async () => {
+			const errorWithMessage = { message: "Custom error", code: "CUSTOM_ERROR" }
+			mockPage.goto.mockRejectedValueOnce(errorWithMessage)
+
+			await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Custom error")
+
+			// Should not retry for error objects with message property (they're treated as known errors)
+			expect(mockPage.goto).toHaveBeenCalledTimes(1)
+		})
+
+		it("should retry for error objects with network-related messages", async () => {
+			const errorWithNetworkMessage = { message: "net::ERR_CONNECTION_REFUSED", code: "NETWORK_ERROR" }
+			mockPage.goto.mockRejectedValueOnce(errorWithNetworkMessage).mockResolvedValueOnce(undefined)
+
+			const result = await urlContentFetcher.urlToMarkdown("https://example.com")
+
+			// Should retry for network-related errors even in non-Error objects
+			expect(mockPage.goto).toHaveBeenCalledTimes(2)
+			expect(result).toBe("# Test content")
+		})
+
+		it("should handle string errors", async () => {
+			const stringError = "Simple string error"
+			mockPage.goto.mockRejectedValueOnce(stringError)
+
+			await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Simple string error")
+			expect(mockPage.goto).toHaveBeenCalledTimes(1)
+		})
+	})
+
+	describe("closeBrowser", () => {
+		it("should close browser and reset state", async () => {
+			await urlContentFetcher.launchBrowser()
+			await urlContentFetcher.closeBrowser()
+
+			expect(mockBrowser.close).toHaveBeenCalled()
+		})
+
+		it("should handle closing when browser not initialized", async () => {
+			await expect(urlContentFetcher.closeBrowser()).resolves.not.toThrow()
+		})
+	})
+})