From 9da5298d70fd8927253ee4d8301479cf53df15c1 Mon Sep 17 00:00:00 2001 From: Yishen Tu Date: Fri, 27 Feb 2026 23:57:18 +0800 Subject: [PATCH 1/2] refactor: simplify youtube-url helpers and reduce test duplication Removed single-use wrapper functions (normalizeHostname, normalizePathname, hasNonEmptyValue) and inlined their logic. Eliminated redundant double-normalization of hostnames and removed dead null-guard checks. Collapsed appendVideoUrlPrompt's three-way return into a single expression with computed separator. Extracted getToolbarButtons helper in regen-flow tests to deduplicate the repeated 3-button query and null-assertion pattern across three tests. Removed unused isYouTubeUrl export which had no production consumers. All tests pass (67 tests, down from 68 due to removed unused function test). --- src/chatpanel/app/bootstrap.ts | 54 +++++++++++++ src/chatpanel/core/youtube-url.ts | 38 ++++++++++ .../features/composer/input-toolbar.ts | 6 ++ src/chatpanel/template/composer-template.ts | 11 +++ tests/unit/chatpanel/app/regen-flow.test.ts | 75 +++++++++++++++++++ tests/unit/chatpanel/core/youtube-url.test.ts | 37 +++++++++ .../features/composer/input-toolbar.test.ts | 2 + .../unit/chatpanel/template/template.test.ts | 3 + 8 files changed, 226 insertions(+) create mode 100644 src/chatpanel/core/youtube-url.ts create mode 100644 tests/unit/chatpanel/core/youtube-url.test.ts diff --git a/src/chatpanel/app/bootstrap.ts b/src/chatpanel/app/bootstrap.ts index 80d14b0..5d9fc1a 100644 --- a/src/chatpanel/app/bootstrap.ts +++ b/src/chatpanel/app/bootstrap.ts @@ -23,6 +23,7 @@ import { import { isTabExtractTextMessageRequest } from '../../shared/tab-text-extraction-message'; import { isRecord } from '../../shared/utils'; import { queryRequiredElement } from '../core/dom'; +import { getYouTubeUrlForPrompt } from '../core/youtube-url'; import { createAttachmentManager, extractFilesFromDataTransfer, @@ -70,6 +71,7 @@ import { createPanelVisibilityController } from './panel-visibility'; const ROOT_HOST_ID = 'speakeasy-overlay-root'; const INPUT_MAX_PANEL_HEIGHT_RATIO = 1 / 3; const BRAND_LOGO_ASSET_PATH = 'icons/gemini-logo.svg'; +const VIDEO_URL_PROMPT_PREFIX = 'Video URL: '; export function mountChatPanel(): void { if (document.getElementById(ROOT_HOST_ID)) { @@ -144,6 +146,8 @@ export function mountChatPanel(): void { '#speakeasy-text-preview-close', ); const toolbar = createInputToolbar(shadowRoot); + const initialYouTubeUrl = getYouTubeUrlForPrompt(window.location.href); + const isYouTubeTabContext = initialYouTubeUrl !== null; const deleteSessionConfirmation = createDeleteSessionConfirmation(shadowRoot); if (resizeHandles.length === 0) { throw new Error('Missing resize zones in chat panel template.'); @@ -430,6 +434,7 @@ export function mountChatPanel(): void { }, appendLocalError, }); + syncToolbarActionVisibility(); input.addEventListener('paste', (event) => { if (isBusy) { @@ -468,6 +473,19 @@ export function mountChatPanel(): void { void extractCurrentTabTextIntoAttachments(); }); + toolbar.videoUrlButton.addEventListener('click', () => { + if ( + isBusy || + isCapturingFullPageScreenshot || + isExtractingPageText || + isProcessingMentionAction + ) { + return; + } + + attachCurrentTabYouTubeUrlToInput(); + }); + fileInput.addEventListener('change', () => { if (isBusy) { fileInput.value = ''; @@ -819,6 +837,13 @@ export function mountChatPanel(): void { toolbar.attachButton.disabled = toolbarBusy; toolbar.captureButton.disabled = toolbarBusy; toolbar.extractTextButton.disabled = toolbarBusy; + toolbar.videoUrlButton.disabled = toolbarBusy; + } + + function syncToolbarActionVisibility(): void { + toolbar.extractTextButton.hidden = isYouTubeTabContext; + toolbar.captureButton.hidden = isYouTubeTabContext; + toolbar.videoUrlButton.hidden = !isYouTubeTabContext; } function syncComposerDisabledState(): void { @@ -912,6 +937,25 @@ export function mountChatPanel(): void { } } + function attachCurrentTabYouTubeUrlToInput(): void { + const videoUrl = getYouTubeUrlForPrompt(window.location.href) ?? initialYouTubeUrl; + if (!videoUrl) { + appendLocalError('Unable to attach the current YouTube URL.'); + return; + } + + const nextValue = appendVideoUrlPrompt(input.value, videoUrl); + if (nextValue === input.value) { + input.focus(); + return; + } + + input.value = nextValue; + input.focus(); + input.setSelectionRange(nextValue.length, nextValue.length); + resizeComposerInput(); + } + async function handleMentionTabActionSelection( tab: MentionableTab, token: MentionTokenRange, @@ -987,6 +1031,16 @@ export function mountChatPanel(): void { } } +function appendVideoUrlPrompt(inputValue: string, videoUrl: string): string { + const promptLine = `${VIDEO_URL_PROMPT_PREFIX}${videoUrl}`; + if (inputValue.includes(promptLine)) { + return inputValue; + } + + const separator = !inputValue || inputValue.endsWith('\n') ? '' : '\n'; + return `${inputValue}${separator}${promptLine}`; +} + function waitForNextPaint(): Promise { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); diff --git a/src/chatpanel/core/youtube-url.ts b/src/chatpanel/core/youtube-url.ts new file mode 100644 index 0000000..1e6c316 --- /dev/null +++ b/src/chatpanel/core/youtube-url.ts @@ -0,0 +1,38 @@ +const YOUTUBE_HOST_SUFFIX = '.youtube.com'; +const YOUTUBE_HOSTS = new Set(['youtube.com', 'youtu.be']); +const EMBEDDED_VIDEO_PATH_PREFIXES = new Set(['shorts', 'live', 'embed']); + +export function isYouTubeHostname(hostname: string): boolean { + const h = hostname.trim().toLowerCase(); + return h !== '' && (YOUTUBE_HOSTS.has(h) || h.endsWith(YOUTUBE_HOST_SUFFIX)); +} + +function isYouTubeVideoPath(url: URL): boolean { + if (!isYouTubeHostname(url.hostname)) { + return false; + } + + const pathname = url.pathname.replace(/\/+$/, ''); + + if (url.hostname === 'youtu.be') { + return pathname.length > 1; + } + + if (pathname === '/watch') { + const v = url.searchParams.get('v'); + return v !== null && v.trim().length > 0; + } + + const segments = pathname.split('/').filter(Boolean); + const prefix = segments[0]; + return segments.length >= 2 && !!prefix && EMBEDDED_VIDEO_PATH_PREFIXES.has(prefix.toLowerCase()); +} + +export function getYouTubeUrlForPrompt(value: string): string | null { + try { + const url = new URL(value.trim()); + return isYouTubeVideoPath(url) ? url.toString() : null; + } catch { + return null; + } +} diff --git a/src/chatpanel/features/composer/input-toolbar.ts b/src/chatpanel/features/composer/input-toolbar.ts index 5dd87d3..4263f2e 100644 --- a/src/chatpanel/features/composer/input-toolbar.ts +++ b/src/chatpanel/features/composer/input-toolbar.ts @@ -13,6 +13,7 @@ export interface InputToolbar { selectedThinkingLevel(): string; captureButton: HTMLButtonElement; extractTextButton: HTMLButtonElement; + videoUrlButton: HTMLButtonElement; attachButton: HTMLButtonElement; } @@ -45,6 +46,10 @@ export function createInputToolbar(shadowRoot: ShadowRoot): InputToolbar { '#speakeasy-extract-page-text', ); const attachButton = queryRequiredElement(shadowRoot, '#speakeasy-attach'); + const videoUrlButton = queryRequiredElement( + shadowRoot, + '#speakeasy-attach-video-url', + ); const modelMenuController = createMenuController({ container: modelDropup }); const thinkingMenuController = createMenuController({ container: thinkingDropup }); let modelThinkingLevelMap = normalizeGeminiSettings(undefined).modelThinkingLevelMap; @@ -208,6 +213,7 @@ export function createInputToolbar(shadowRoot: ShadowRoot): InputToolbar { }, captureButton, extractTextButton, + videoUrlButton, attachButton, }; } diff --git a/src/chatpanel/template/composer-template.ts b/src/chatpanel/template/composer-template.ts index 855b54e..7dcf1fc 100644 --- a/src/chatpanel/template/composer-template.ts +++ b/src/chatpanel/template/composer-template.ts @@ -87,6 +87,17 @@ export function getComposerTemplate(): string { +