Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/chatpanel/app/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -430,6 +434,7 @@ export function mountChatPanel(): void {
},
appendLocalError,
});
syncToolbarActionVisibility();

input.addEventListener('paste', (event) => {
if (isBusy) {
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
return new Promise((resolve) => {
window.requestAnimationFrame(() => resolve());
Expand Down
38 changes: 38 additions & 0 deletions src/chatpanel/core/youtube-url.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 6 additions & 0 deletions src/chatpanel/features/composer/input-toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface InputToolbar {
selectedThinkingLevel(): string;
captureButton: HTMLButtonElement;
extractTextButton: HTMLButtonElement;
videoUrlButton: HTMLButtonElement;
attachButton: HTMLButtonElement;
}

Expand Down Expand Up @@ -45,6 +46,10 @@ export function createInputToolbar(shadowRoot: ShadowRoot): InputToolbar {
'#speakeasy-extract-page-text',
);
const attachButton = queryRequiredElement<HTMLButtonElement>(shadowRoot, '#speakeasy-attach');
const videoUrlButton = queryRequiredElement<HTMLButtonElement>(
shadowRoot,
'#speakeasy-attach-video-url',
);
const modelMenuController = createMenuController({ container: modelDropup });
const thinkingMenuController = createMenuController({ container: thinkingDropup });
let modelThinkingLevelMap = normalizeGeminiSettings(undefined).modelThinkingLevelMap;
Expand Down Expand Up @@ -208,6 +213,7 @@ export function createInputToolbar(shadowRoot: ShadowRoot): InputToolbar {
},
captureButton,
extractTextButton,
videoUrlButton,
attachButton,
};
}
11 changes: 11 additions & 0 deletions src/chatpanel/template/composer-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export function getComposerTemplate(): string {
<circle cx="12" cy="13" r="3" stroke="currentColor" stroke-width="1.8" />
</svg>
</button>
<button
id="speakeasy-attach-video-url"
class="attach-btn"
type="button"
aria-label="Attach current YouTube URL"
title="Attach current YouTube URL"
hidden>
<svg class="attach-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M10.5 13.5L13.5 10.5M8.2 15.8a3.25 3.25 0 0 1 0-4.6l2.2-2.2a3.25 3.25 0 1 1 4.6 4.6l-.7.7m1.5-6.1a3.25 3.25 0 0 1 4.6 4.6l-2.2 2.2a3.25 3.25 0 1 1-4.6-4.6l.7-.7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button id="speakeasy-attach" class="attach-btn" type="button" aria-label="Attach file" title="Attach file">
<svg class="attach-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 12.5L14.8 5.7a3 3 0 1 1 4.2 4.2l-8.6 8.6a5 5 0 1 1-7.1-7.1l9-9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
Expand Down
109 changes: 109 additions & 0 deletions tests/unit/chatpanel/app/regen-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,115 @@ describe('chatpanel regenerate flow', () => {
});
});

function getToolbarButtons(shadowRoot: ShadowRoot) {
const extractTextButton = shadowRoot.querySelector(
'#speakeasy-extract-page-text',
) as HTMLButtonElement;
const captureButton = shadowRoot.querySelector(
'#speakeasy-capture-full-page',
) as HTMLButtonElement;
const videoUrlButton = shadowRoot.querySelector(
'#speakeasy-attach-video-url',
) as HTMLButtonElement;
expect(extractTextButton).not.toBeNull();
expect(captureButton).not.toBeNull();
expect(videoUrlButton).not.toBeNull();
return { extractTextButton, captureButton, videoUrlButton };
}

it('shows TXT and screenshot actions for non-YouTube tabs', async () => {
await importFreshChatpanelModule();
await flushMicrotasks();

const { extractTextButton, captureButton, videoUrlButton } = getToolbarButtons(
getChatpanelShadowRoot(),
);
expect(extractTextButton.hidden).toBe(false);
expect(captureButton.hidden).toBe(false);
expect(videoUrlButton.hidden).toBe(true);
});

it('shows TXT and screenshot actions for non-video YouTube tabs', async () => {
dom.window.location.href = 'https://www.youtube.com/results?search_query=vid123';
await importFreshChatpanelModule();
await flushMicrotasks();

const { extractTextButton, captureButton, videoUrlButton } = getToolbarButtons(
getChatpanelShadowRoot(),
);
expect(extractTextButton.hidden).toBe(false);
expect(captureButton.hidden).toBe(false);
expect(videoUrlButton.hidden).toBe(true);
});

it('replaces TXT and screenshot actions with a YouTube URL action on YouTube tabs', async () => {
const testWindow = getTestWindow();
dom.window.location.href = 'https://www.youtube.com/watch?v=vid123&t=9';
await importFreshChatpanelModule();
await flushMicrotasks();

const shadowRoot = getChatpanelShadowRoot();
const form = shadowRoot.querySelector('#speakeasy-form') as HTMLFormElement;
const input = shadowRoot.querySelector('#speakeasy-input') as HTMLTextAreaElement;
const { extractTextButton, captureButton, videoUrlButton } = getToolbarButtons(shadowRoot);

expect(form).not.toBeNull();
expect(input).not.toBeNull();

expect(extractTextButton.hidden).toBe(true);
expect(captureButton.hidden).toBe(true);
expect(videoUrlButton.hidden).toBe(false);

input.value = 'Summarize this video';
input.setSelectionRange(input.value.length, input.value.length);
videoUrlButton.dispatchEvent(new testWindow.MouseEvent('click', { bubbles: true }));
await flushMicrotasks(8);

expect(input.value).toContain('Summarize this video');
expect(input.value).toContain('Video URL: https://www.youtube.com/watch?v=vid123&t=9');

form.dispatchEvent(new testWindow.Event('submit', { bubbles: true, cancelable: true }));
await flushMicrotasks(12);

expect(sendRequest?.type).toBe('chat/send');
expect(sendRequest?.text).toContain('Summarize this video');
expect(sendRequest?.text).toContain('Video URL: https://www.youtube.com/watch?v=vid123&t=9');
});

it('replaces existing attached YouTube URL line when the current video changes', async () => {
const testWindow = getTestWindow();
dom.window.location.href = 'https://www.youtube.com/watch?v=vid123&t=90';
await importFreshChatpanelModule();
await flushMicrotasks();

const shadowRoot = getChatpanelShadowRoot();
const input = shadowRoot.querySelector('#speakeasy-input') as HTMLTextAreaElement | null;
const videoUrlButton = shadowRoot.querySelector(
'#speakeasy-attach-video-url',
) as HTMLButtonElement | null;

expect(input).not.toBeNull();
expect(videoUrlButton).not.toBeNull();
if (!input || !videoUrlButton) {
throw new Error('Expected chatpanel YouTube URL controls.');
}

input.value = 'Compare both';
input.setSelectionRange(input.value.length, input.value.length);
videoUrlButton.dispatchEvent(new testWindow.MouseEvent('click', { bubbles: true }));
await flushMicrotasks(8);

expect(input.value).toContain('Video URL: https://www.youtube.com/watch?v=vid123&t=90');

dom.window.location.href = 'https://www.youtube.com/watch?v=vid123';
videoUrlButton.dispatchEvent(new testWindow.MouseEvent('click', { bubbles: true }));
await flushMicrotasks(8);

const lines = input.value.split('\n');
const videoLines = lines.filter((line) => line.startsWith('Video URL: '));
expect(videoLines).toEqual(['Video URL: https://www.youtube.com/watch?v=vid123']);
});

it('reloads conversation history when reopening the panel', async () => {
await importFreshChatpanelModule();
await flushMicrotasks();
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/chatpanel/core/youtube-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
import {
getYouTubeUrlForPrompt,
isYouTubeHostname,
} from '../../../../src/chatpanel/core/youtube-url';

describe('chatpanel youtube url helpers', () => {
it('detects supported YouTube hostnames', () => {
expect(isYouTubeHostname('youtube.com')).toBe(true);
expect(isYouTubeHostname('www.youtube.com')).toBe(true);
expect(isYouTubeHostname('m.youtube.com')).toBe(true);
expect(isYouTubeHostname('music.youtube.com')).toBe(true);
expect(isYouTubeHostname('youtu.be')).toBe(true);
expect(isYouTubeHostname('example.com')).toBe(false);
});

it('normalizes YouTube URLs for prompt attachment and rejects other URLs', () => {
expect(getYouTubeUrlForPrompt(' https://www.youtube.com/watch?v=abc123&t=9 ')).toBe(
'https://www.youtube.com/watch?v=abc123&t=9',
);
expect(getYouTubeUrlForPrompt('https://youtu.be/abc123')).toBe('https://youtu.be/abc123');
expect(getYouTubeUrlForPrompt('https://www.youtube.com/shorts/abc123')).toBe(
'https://www.youtube.com/shorts/abc123',
);
expect(getYouTubeUrlForPrompt('https://www.youtube.com/live/abc123')).toBe(
'https://www.youtube.com/live/abc123',
);
expect(getYouTubeUrlForPrompt('https://www.youtube.com/watch')).toBeNull();
expect(
getYouTubeUrlForPrompt('https://www.youtube.com/results?search_query=abc123'),
).toBeNull();
expect(getYouTubeUrlForPrompt('https://www.youtube.com/@creator')).toBeNull();
expect(getYouTubeUrlForPrompt('https://youtu.be/')).toBeNull();
expect(getYouTubeUrlForPrompt('https://example.com/path')).toBeNull();
expect(getYouTubeUrlForPrompt('')).toBeNull();
});
});
2 changes: 2 additions & 0 deletions tests/unit/chatpanel/features/composer/input-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('chatpanel input toolbar', () => {
expect(toolbar.captureButton.id).toBe('speakeasy-capture-full-page');
expect(toolbar.extractTextButton.id).toBe('speakeasy-extract-page-text');
expect(toolbar.attachButton.id).toBe('speakeasy-attach');
expect(toolbar.videoUrlButton.id).toBe('speakeasy-attach-video-url');

customModelButton?.dispatchEvent(new testWindow.MouseEvent('click', { bubbles: true }));

Expand Down Expand Up @@ -397,6 +398,7 @@ describe('chatpanel input toolbar', () => {
<button id="speakeasy-capture-full-page" type="button">Capture</button>
<button id="speakeasy-extract-page-text" type="button">TXT</button>
<button id="speakeasy-attach" type="button">Attach</button>
<button id="speakeasy-attach-video-url" type="button">URL</button>
`);
}
});
3 changes: 3 additions & 0 deletions tests/unit/chatpanel/template/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ describe('chatpanel template', () => {
expect(template).toContain('id="speakeasy-attach"');
expect(template).toContain('aria-label="Attach file"');
expect(template).toContain('title="Attach file"');
expect(template).toContain('id="speakeasy-attach-video-url"');
expect(template).toContain('aria-label="Attach current YouTube URL"');
expect(template).toContain('title="Attach current YouTube URL"');
});

it('renders image preview markup inside the chatpanel container', () => {
Expand Down
Loading