diff --git a/src/chatpanel/app/bootstrap.ts b/src/chatpanel/app/bootstrap.ts index 80d14b0..a79f259 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,23 @@ export function mountChatPanel(): void { } } +function appendVideoUrlPrompt(inputValue: string, videoUrl: string): string { + const promptLine = `${VIDEO_URL_PROMPT_PREFIX}${videoUrl}`; + const lines = inputValue.split('\n'); + const existingPromptIndex = lines.findIndex((line) => line.startsWith(VIDEO_URL_PROMPT_PREFIX)); + if (existingPromptIndex >= 0) { + if (lines[existingPromptIndex] === promptLine) { + return inputValue; + } + + lines[existingPromptIndex] = promptLine; + return lines.join('\n'); + } + + 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 { +