From 077db7233b594b6ed9b8c9868a058456194aaf26 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 16 Apr 2026 14:46:15 -0700 Subject: [PATCH 1/8] Add Chrome PT tab sync extension and DNA integration (#136) Expose prodtrack_detail_url on versions, user setting for auto-sync, header PT tab button, extension bridge and install prompt. Signed-off-by: James Spadafora Made-with: Cursor --- backend/src/dna/models/entity.py | 4 + backend/src/dna/models/user_settings.py | 6 + .../dna/prodtrack_providers/mock_provider.py | 3 + .../src/dna/prodtrack_providers/shotgrid.py | 14 +- backend/src/dna/storage_providers/mongodb.py | 1 + backend/tests/providers/test_mock_provider.py | 6 + backend/tests/test_main.py | 1 + backend/tests/test_user_settings.py | 9 ++ frontend/packages/app/.env.example | 6 + .../app/src/components/ContentArea.tsx | 122 ++++++++++++++-- .../ProdtrackTabSyncInstallDialog.tsx | 54 ++++++++ .../app/src/components/SettingsModal.tsx | 53 +++++++ .../app/src/components/VersionHeader.test.tsx | 31 +++++ .../app/src/components/VersionHeader.tsx | 55 +++++++- .../app/src/hooks/useDraftNote.test.tsx | 3 + .../sendProdtrackTabSync.test.ts | 69 +++++++++ .../prodtrackTabSync/sendProdtrackTabSync.ts | 131 ++++++++++++++++++ frontend/packages/app/src/test/render.tsx | 13 +- frontend/packages/app/src/vite-env.d.ts | 13 ++ frontend/packages/core/src/interfaces.ts | 3 + prodtrack-tab-sync-extension/README.md | 41 ++++++ prodtrack-tab-sync-extension/background.js | 85 ++++++++++++ prodtrack-tab-sync-extension/manifest.json | 18 +++ 23 files changed, 721 insertions(+), 20 deletions(-) create mode 100644 frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx create mode 100644 frontend/packages/app/src/components/VersionHeader.test.tsx create mode 100644 frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts create mode 100644 frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts create mode 100644 prodtrack-tab-sync-extension/README.md create mode 100644 prodtrack-tab-sync-extension/background.js create mode 100644 prodtrack-tab-sync-extension/manifest.json diff --git a/backend/src/dna/models/entity.py b/backend/src/dna/models/entity.py index 24a98e5f..8d989503 100644 --- a/backend/src/dna/models/entity.py +++ b/backend/src/dna/models/entity.py @@ -174,6 +174,10 @@ class Version(EntityBase): ) task: Optional["Task"] = Field(default=None, description="Associated task") notes: list["Note"] = Field(default_factory=list, description="Associated notes") + prodtrack_detail_url: Optional[str] = Field( + default=None, + description="Web UI URL for this version in the production tracking system", + ) @field_validator("notes", mode="before") @classmethod diff --git a/backend/src/dna/models/user_settings.py b/backend/src/dna/models/user_settings.py index 79397404..ebc5cfb5 100644 --- a/backend/src/dna/models/user_settings.py +++ b/backend/src/dna/models/user_settings.py @@ -23,6 +23,11 @@ class UserSettingsUpdate(BaseModel): default=None, description="Regenerate AI note when transcript segments are updated", ) + sync_prodtrack_tab_on_version_change: Optional[bool] = Field( + default=None, + description="When true, DNA tells the browser extension to open the PT " + "version page whenever the selected version changes", + ) class UserSettings(BaseModel): @@ -35,5 +40,6 @@ class UserSettings(BaseModel): note_prompt: str = "" regenerate_on_version_change: bool = False regenerate_on_transcript_update: bool = False + sync_prodtrack_tab_on_version_change: bool = False updated_at: datetime created_at: datetime diff --git a/backend/src/dna/prodtrack_providers/mock_provider.py b/backend/src/dna/prodtrack_providers/mock_provider.py index bb72cb4d..07d9eb18 100644 --- a/backend/src/dna/prodtrack_providers/mock_provider.py +++ b/backend/src/dna/prodtrack_providers/mock_provider.py @@ -159,6 +159,9 @@ def _version_from_row( entity=entity, task=task, notes=notes or [], + prodtrack_detail_url=( + f"https://mock-shotgrid.example.com/detail/Version/{row['id']}" + ), ) def _playlist_from_row( diff --git a/backend/src/dna/prodtrack_providers/shotgrid.py b/backend/src/dna/prodtrack_providers/shotgrid.py index 5b859397..bc21d242 100644 --- a/backend/src/dna/prodtrack_providers/shotgrid.py +++ b/backend/src/dna/prodtrack_providers/shotgrid.py @@ -2,7 +2,7 @@ import contextlib import os -from typing import Any, Optional +from typing import Any, Optional, cast from shotgun_api3 import Shotgun @@ -305,9 +305,15 @@ def get_entity( if not sg_entity: raise ValueError(f"Entity not found: {entity_type} {entity_id}") - return self._convert_sg_entity_to_dna_entity( + entity = self._convert_sg_entity_to_dna_entity( sg_entity, entity_mapping, entity_type, resolve_links=resolve_links ) + if entity_type == "version": + version = cast(Version, entity) + base = (self.url or "").rstrip("/") + if base: + version.prodtrack_detail_url = f"{base}/detail/Version/{version.id}" + return entity def _resolve_linked_field(self, data): """Resolve linked entity data by fetching the full entity.""" @@ -772,6 +778,10 @@ def get_versions_for_playlist(self, playlist_id: int) -> list[Version]: if version.id in notes_by_version_id: version.notes = notes_by_version_id[version.id] + base = (self.url or "").rstrip("/") + if base: + version.prodtrack_detail_url = f"{base}/detail/Version/{version.id}" + versions.append(version) return versions diff --git a/backend/src/dna/storage_providers/mongodb.py b/backend/src/dna/storage_providers/mongodb.py index af0fe4e3..021e5e05 100644 --- a/backend/src/dna/storage_providers/mongodb.py +++ b/backend/src/dna/storage_providers/mongodb.py @@ -290,6 +290,7 @@ async def upsert_user_settings( "note_prompt": "", "regenerate_on_version_change": False, "regenerate_on_transcript_update": False, + "sync_prodtrack_tab_on_version_change": False, } set_on_insert = { "created_at": now, diff --git a/backend/tests/providers/test_mock_provider.py b/backend/tests/providers/test_mock_provider.py index a5777783..b37bded2 100644 --- a/backend/tests/providers/test_mock_provider.py +++ b/backend/tests/providers/test_mock_provider.py @@ -168,6 +168,9 @@ def test_get_entity_note_with_links(mock_provider): def test_get_entity_version(mock_provider): version = mock_provider.get_entity("version", 300, resolve_links=True) assert version.id == 300 + assert version.prodtrack_detail_url == ( + "https://mock-shotgrid.example.com/detail/Version/300" + ) assert version.name == "v_001" assert version.status == "rev" assert version.task is not None @@ -441,6 +444,9 @@ def test_get_versions_for_playlist(mock_provider): versions = mock_provider.get_versions_for_playlist(400) assert len(versions) == 1 assert versions[0].id == 300 + assert versions[0].prodtrack_detail_url == ( + "https://mock-shotgrid.example.com/detail/Version/300" + ) assert versions[0].task is not None diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 0e5cc902..e816e216 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -944,6 +944,7 @@ def test_generate_note_returns_200( note_prompt="Test prompt {{ transcript }}", regenerate_on_version_change=False, regenerate_on_transcript_update=False, + sync_prodtrack_tab_on_version_change=False, updated_at=datetime.now(timezone.utc), created_at=datetime.now(timezone.utc), ) diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index 57a5c96f..ec996b61 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -21,6 +21,7 @@ def test_user_settings_update_defaults(self): assert update.note_prompt is None assert update.regenerate_on_version_change is None assert update.regenerate_on_transcript_update is None + assert update.sync_prodtrack_tab_on_version_change is None def test_user_settings_update_with_values(self): """Test UserSettingsUpdate with values.""" @@ -42,6 +43,7 @@ def test_user_settings_full_model(self): note_prompt="My custom prompt", regenerate_on_version_change=True, regenerate_on_transcript_update=False, + sync_prodtrack_tab_on_version_change=True, updated_at=now, created_at=now, ) @@ -50,6 +52,7 @@ def test_user_settings_full_model(self): assert settings.note_prompt == "My custom prompt" assert settings.regenerate_on_version_change is True assert settings.regenerate_on_transcript_update is False + assert settings.sync_prodtrack_tab_on_version_change is True def test_user_settings_defaults(self): """Test UserSettings default values.""" @@ -63,6 +66,7 @@ def test_user_settings_defaults(self): assert settings.note_prompt == "" assert settings.regenerate_on_version_change is False assert settings.regenerate_on_transcript_update is False + assert settings.sync_prodtrack_tab_on_version_change is False class TestUserSettingsEndpoints: @@ -98,6 +102,7 @@ def test_get_user_settings_returns_200(self, mock_storage_provider, auth_client) assert data["note_prompt"] == "Custom prompt" assert data["regenerate_on_version_change"] is True assert data["regenerate_on_transcript_update"] is False + assert data["sync_prodtrack_tab_on_version_change"] is False mock_storage_provider.get_user_settings.assert_called_once_with( "test@example.com" ) @@ -300,6 +305,7 @@ async def test_get_user_settings_returns_settings( "note_prompt": "Custom prompt", "regenerate_on_version_change": True, "regenerate_on_transcript_update": False, + "sync_prodtrack_tab_on_version_change": False, "updated_at": now, "created_at": now, } @@ -312,6 +318,7 @@ async def test_get_user_settings_returns_settings( assert result.note_prompt == "Custom prompt" assert result.regenerate_on_version_change is True assert result.regenerate_on_transcript_update is False + assert result.sync_prodtrack_tab_on_version_change is False @pytest.mark.asyncio async def test_get_user_settings_returns_none( @@ -336,6 +343,7 @@ async def test_upsert_user_settings(self, provider_with_mock, mock_collection): "note_prompt": "Updated prompt", "regenerate_on_version_change": True, "regenerate_on_transcript_update": True, + "sync_prodtrack_tab_on_version_change": False, "updated_at": now, "created_at": now, } @@ -353,6 +361,7 @@ async def test_upsert_user_settings(self, provider_with_mock, mock_collection): assert result.note_prompt == "Updated prompt" assert result.regenerate_on_version_change is True assert result.regenerate_on_transcript_update is True + assert result.sync_prodtrack_tab_on_version_change is False mock_collection.find_one_and_update.assert_called_once() @pytest.mark.asyncio diff --git a/frontend/packages/app/.env.example b/frontend/packages/app/.env.example index c408ea29..bc23100a 100644 --- a/frontend/packages/app/.env.example +++ b/frontend/packages/app/.env.example @@ -6,3 +6,9 @@ VITE_AUTH_PROVIDER=none # Required only when VITE_AUTH_PROVIDER=google VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com + +# Chrome extension ID from chrome://extensions (DNA Production Tracking Tab Sync) +# VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID= + +# Optional: link shown in the install prompt (Chrome Web Store or docs) +# VITE_PRODTRACK_TAB_SYNC_INSTALL_URL=https://github.com/AcademySoftwareFoundation/dna/blob/main/prodtrack-tab-sync-extension/README.md diff --git a/frontend/packages/app/src/components/ContentArea.tsx b/frontend/packages/app/src/components/ContentArea.tsx index a86ea33d..ada16300 100644 --- a/frontend/packages/app/src/components/ContentArea.tsx +++ b/frontend/packages/app/src/components/ContentArea.tsx @@ -1,11 +1,15 @@ -import { useRef, useCallback, useMemo } from 'react'; +import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; import styled from 'styled-components'; -import type { Version, SearchResult } from '@dna/core'; +import { useQuery } from '@tanstack/react-query'; +import type { Version, SearchResult, UserSettings } from '@dna/core'; import { VersionHeader } from './VersionHeader'; import { NoteEditor, type NoteEditorHandle } from './NoteEditor'; import { AssistantPanel } from './AssistantPanel'; +import { ProdtrackTabSyncInstallDialog } from './ProdtrackTabSyncInstallDialog'; import { usePlaylistMetadata, useSetInReview, useDraftNote } from '../hooks'; import { useHotkeyAction } from '../hotkeys'; +import { apiHandler } from '../api'; +import { openProdtrackVersionInExtension } from '../prodtrackTabSync/sendProdtrackTabSync'; interface ContentAreaProps { version?: Version | null; @@ -60,6 +64,9 @@ function formatDate(dateString?: string): string { const IN_REVIEW_STATUS = 'rev'; +const DEFAULT_PT_SYNC_INSTALL_URL = + 'https://github.com/AcademySoftwareFoundation/dna/blob/main/prodtrack-tab-sync-extension/README.md'; + export function ContentArea({ version, versions = [], @@ -152,16 +159,97 @@ export function ContentArea({ enabled: !!version && !!playlistId, }); + const [installDialogOpen, setInstallDialogOpen] = useState(false); + const extensionId = + import.meta.env.VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?.trim() ?? ''; + const installDocUrl = + import.meta.env.VITE_PRODTRACK_TAB_SYNC_INSTALL_URL?.trim() || + DEFAULT_PT_SYNC_INSTALL_URL; + + const { data: userSettings } = useQuery({ + queryKey: ['userSettings', userEmail], + queryFn: () => apiHandler.getUserSettings({ userEmail: userEmail! }), + enabled: !!userEmail, + }); + + const syncProdtrackOnVersionChange = + userSettings?.sync_prodtrack_tab_on_version_change === true; + + const handleSyncProdtrackTab = useCallback(async () => { + const url = version?.prodtrack_detail_url; + if (!url) return; + if (!extensionId) { + setInstallDialogOpen(true); + return; + } + const result = await openProdtrackVersionInExtension(extensionId, url); + if (!result.ok) { + if ( + result.reason === 'no_extension' || + result.reason === 'no_extension_id' || + result.reason === 'no_chrome' + ) { + setInstallDialogOpen(true); + } + } + }, [version?.prodtrack_detail_url, extensionId]); + + useEffect(() => { + if (!version?.prodtrack_detail_url) return; + if (!syncProdtrackOnVersionChange) return; + if (!extensionId) return; + let cancelled = false; + void (async () => { + const result = await openProdtrackVersionInExtension( + extensionId, + version.prodtrack_detail_url! + ); + if (cancelled) return; + if (!result.ok) { + if ( + result.reason === 'no_extension' || + result.reason === 'no_extension_id' || + result.reason === 'no_chrome' + ) { + setInstallDialogOpen(true); + } + } + })(); + return () => { + cancelled = true; + }; + }, [ + version?.id, + version?.prodtrack_detail_url, + syncProdtrackOnVersionChange, + extensionId, + ]); + + const syncProdtrackTitle = !version?.prodtrack_detail_url + ? 'Production tracking URL is not available for this version.' + : !extensionId + ? 'Set VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID in your DNA app environment (see install instructions).' + : 'Open this version in the extension-controlled production-tracking tab.'; + + const syncProdtrackDisabled = !version?.prodtrack_detail_url; + if (!version) { return ( - - - No version selected - - Select a version from the sidebar to view its details - - - + <> + + + + No version selected + + Select a version from the sidebar to view its details + + + + ); } @@ -179,7 +267,13 @@ export function ContentArea({ } return ( - + <> + + - + + ); } diff --git a/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx b/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx new file mode 100644 index 00000000..575800a2 --- /dev/null +++ b/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx @@ -0,0 +1,54 @@ +import { + AlertDialog, + Button, + Flex, + Link, + Text, +} from '@radix-ui/themes'; + +const DEFAULT_INSTALL_DOC_URL = + 'https://github.com/AcademySoftwareFoundation/dna/blob/main/prodtrack-tab-sync-extension/README.md'; + +interface ProdtrackTabSyncInstallDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + installDocUrl?: string; +} + +export function ProdtrackTabSyncInstallDialog({ + open, + onOpenChange, + installDocUrl = DEFAULT_INSTALL_DOC_URL, +}: ProdtrackTabSyncInstallDialogProps) { + return ( + + + Install the DNA tab sync extension + + + + DNA could not reach the Chrome extension that keeps your + production-tracking tab in sync. Install the extension, add your + DNA site to externally_connectable in + its manifest if needed, set{' '} + VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID{' '} + in your DNA environment, then try again. + + + + Installation instructions + + + + + + + + + + + + ); +} diff --git a/frontend/packages/app/src/components/SettingsModal.tsx b/frontend/packages/app/src/components/SettingsModal.tsx index ddf9af12..d57f333c 100644 --- a/frontend/packages/app/src/components/SettingsModal.tsx +++ b/frontend/packages/app/src/components/SettingsModal.tsx @@ -9,6 +9,7 @@ import { Flex, Switch, Tooltip, + Text, } from '@radix-ui/themes'; import * as Tabs from '@radix-ui/react-tabs'; import { Loader2, Info } from 'lucide-react'; @@ -224,10 +225,12 @@ interface GeneralTabProps { notePrompt: string; regenerateOnVersionChange: boolean; regenerateOnTranscriptUpdate: boolean; + syncProdtrackTabOnVersionChange: boolean; isPending: boolean; onNotePromptChange: (e: React.ChangeEvent) => void; onRegenerateOnVersionChange: (checked: boolean) => void; onRegenerateOnTranscriptUpdate: (checked: boolean) => void; + onSyncProdtrackTabOnVersionChange: (checked: boolean) => void; } function GeneralTab({ @@ -235,10 +238,12 @@ function GeneralTab({ notePrompt, regenerateOnVersionChange, regenerateOnTranscriptUpdate, + syncProdtrackTabOnVersionChange, isPending, onNotePromptChange, onRegenerateOnVersionChange, onRegenerateOnTranscriptUpdate, + onSyncProdtrackTabOnVersionChange, }: GeneralTabProps) { const { mode, setMode } = useThemeMode(); @@ -342,6 +347,34 @@ function GeneralTab({ + +
+ Production tracking (browser) + + Requires the DNA tab sync Chrome extension and{' '} + + VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID + {' '} + in your DNA environment. + + + + + + Sync PT tab when version changes + + + When enabled, the extension updates your production-tracking + tab whenever you select a different version. When disabled, + use the "PT tab" button in the version header. + + + +
); } @@ -510,6 +543,8 @@ export function SettingsModal({ useState(false); const [regenerateOnTranscriptUpdate, setRegenerateOnTranscriptUpdate] = useState(false); + const [syncProdtrackTabOnVersionChange, setSyncProdtrackTabOnVersionChange] = + useState(false); const [isDirty, setIsDirty] = useState(false); const { getAllActions, getKeysForAction, setKeysForAction, resetToDefaults } = @@ -537,11 +572,15 @@ export function SettingsModal({ setNotePrompt(settings.note_prompt); setRegenerateOnVersionChange(settings.regenerate_on_version_change); setRegenerateOnTranscriptUpdate(settings.regenerate_on_transcript_update); + setSyncProdtrackTabOnVersionChange( + settings.sync_prodtrack_tab_on_version_change + ); setIsDirty(false); } else if (settings === null) { setNotePrompt(''); setRegenerateOnVersionChange(false); setRegenerateOnTranscriptUpdate(false); + setSyncProdtrackTabOnVersionChange(false); setIsDirty(false); } }, [settings]); @@ -564,17 +603,27 @@ export function SettingsModal({ setIsDirty(true); }, []); + const handleSyncProdtrackTabOnVersionChange = useCallback( + (checked: boolean) => { + setSyncProdtrackTabOnVersionChange(checked); + setIsDirty(true); + }, + [] + ); + const handleSave = useCallback(() => { mutation.mutate({ note_prompt: notePrompt, regenerate_on_version_change: regenerateOnVersionChange, regenerate_on_transcript_update: regenerateOnTranscriptUpdate, + sync_prodtrack_tab_on_version_change: syncProdtrackTabOnVersionChange, }); }, [ mutation, notePrompt, regenerateOnVersionChange, regenerateOnTranscriptUpdate, + syncProdtrackTabOnVersionChange, ]); const handleOpenChange = useCallback( @@ -620,10 +669,14 @@ export function SettingsModal({ notePrompt={notePrompt} regenerateOnVersionChange={regenerateOnVersionChange} regenerateOnTranscriptUpdate={regenerateOnTranscriptUpdate} + syncProdtrackTabOnVersionChange={syncProdtrackTabOnVersionChange} isPending={mutation.isPending} onNotePromptChange={handleNotePromptChange} onRegenerateOnVersionChange={handleRegenerateOnVersionChange} onRegenerateOnTranscriptUpdate={handleRegenerateOnTranscriptUpdate} + onSyncProdtrackTabOnVersionChange={ + handleSyncProdtrackTabOnVersionChange + } /> diff --git a/frontend/packages/app/src/components/VersionHeader.test.tsx b/frontend/packages/app/src/components/VersionHeader.test.tsx new file mode 100644 index 00000000..ed3696ec --- /dev/null +++ b/frontend/packages/app/src/components/VersionHeader.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '../test/render'; +import { HotkeysProvider } from '../hotkeys'; +import { VersionHeader } from './VersionHeader'; +import { apiHandler } from '../api'; + +beforeEach(() => { + vi.spyOn(apiHandler, 'getVersionStatuses').mockResolvedValue([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('VersionHeader', () => { + it('renders PT tab button when onSyncProdtrackTab is provided', () => { + const onSync = vi.fn(); + render( + + + + ); + expect(screen.getByRole('button', { name: /PT tab/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/app/src/components/VersionHeader.tsx b/frontend/packages/app/src/components/VersionHeader.tsx index fa7a12fc..f2f3f5c1 100644 --- a/frontend/packages/app/src/components/VersionHeader.tsx +++ b/frontend/packages/app/src/components/VersionHeader.tsx @@ -1,6 +1,14 @@ import styled from 'styled-components'; import { Tooltip } from '@radix-ui/themes'; -import { ChevronLeft, Eye, ChevronRight, RotateCw, Target, ChevronDown } from 'lucide-react'; +import { + ChevronLeft, + Eye, + ChevronRight, + RotateCw, + Target, + ChevronDown, + ExternalLink, +} from 'lucide-react'; import { UserAvatar } from './UserAvatar'; import { useHotkeyConfig } from '../hotkeys'; import { useVersionStatuses } from '../hooks'; @@ -21,6 +29,9 @@ interface VersionHeaderProps { onRefresh?: () => void; onSetInReview?: () => void; onVersionStatusChange?: (code: string) => void; + onSyncProdtrackTab?: () => void; + syncProdtrackDisabled?: boolean; + syncProdtrackTitle?: string; canGoBack?: boolean; canGoNext?: boolean; hasInReview?: boolean; @@ -100,6 +111,33 @@ const InReviewButton = styled.button` } `; +const SyncProdtrackButton = styled.button` + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + font-size: 14px; + font-weight: 500; + font-family: ${({ theme }) => theme.fonts.sans}; + color: ${({ theme }) => theme.colors.text.secondary}; + background: transparent; + border: 1px solid ${({ theme }) => theme.colors.border.default}; + border-radius: ${({ theme }) => theme.radii.md}; + cursor: pointer; + transition: all ${({ theme }) => theme.transitions.fast}; + + &:hover:not(:disabled) { + background: ${({ theme }) => theme.colors.bg.surfaceHover}; + color: ${({ theme }) => theme.colors.text.primary}; + border-color: ${({ theme }) => theme.colors.border.strong}; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +`; + const NextVersionButton = styled.button` display: flex; align-items: center; @@ -329,6 +367,9 @@ export function VersionHeader({ onRefresh, onSetInReview, onVersionStatusChange, + onSyncProdtrackTab, + syncProdtrackDisabled = false, + syncProdtrackTitle = 'Open current version in production tracking (browser tab)', canGoBack = true, canGoNext = true, hasInReview = true, @@ -354,6 +395,18 @@ export function VersionHeader({ In Review + {onSyncProdtrackTab && ( + + + + PT tab + + + )} Next Version diff --git a/frontend/packages/app/src/hooks/useDraftNote.test.tsx b/frontend/packages/app/src/hooks/useDraftNote.test.tsx index c41cbcdd..004fd46d 100644 --- a/frontend/packages/app/src/hooks/useDraftNote.test.tsx +++ b/frontend/packages/app/src/hooks/useDraftNote.test.tsx @@ -108,6 +108,7 @@ describe('useDraftNote', () => { to: [], cc: [], links: [], + attachmentIds: [], versionStatus: 'pending', published: false, edited: false, @@ -142,6 +143,7 @@ describe('useDraftNote', () => { to: [], cc: [], links: [], + attachmentIds: [], versionStatus: '', published: false, edited: false, @@ -251,6 +253,7 @@ describe('useDraftNote', () => { to: [], cc: [], links: [], + attachmentIds: [], versionStatus: '', published: false, edited: false, diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts new file mode 100644 index 00000000..cb9d117a --- /dev/null +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + openProdtrackVersionInExtension, + pingProdtrackTabExtension, +} from './sendProdtrackTabSync'; + +afterEach(() => { + Reflect.deleteProperty(globalThis, 'chrome'); +}); + +describe('openProdtrackVersionInExtension', () => { + it('returns no_extension_id when extension id is empty', async () => { + const r = await openProdtrackVersionInExtension( + ' ', + 'https://studio.shotgrid.autodesk.com/detail/Version/1' + ); + expect(r).toEqual({ ok: false, reason: 'no_extension_id' }); + }); + + it('returns invalid_url when url is not http(s)', async () => { + const r = await openProdtrackVersionInExtension('abc', 'ftp://x'); + expect(r).toEqual({ ok: false, reason: 'invalid_url' }); + }); + + it('returns no_chrome when chrome.runtime is missing', async () => { + const r = await openProdtrackVersionInExtension( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/1' + ); + expect(r).toEqual({ ok: false, reason: 'no_chrome' }); + }); + + it('returns ok when extension responds with ok true', async () => { + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + _msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, _msg, cb) => { + cb({ ok: true }); + }, + lastError: undefined, + }, + }; + + const r = await openProdtrackVersionInExtension( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/99' + ); + expect(r).toEqual({ ok: true }); + }); +}); + +describe('pingProdtrackTabExtension', () => { + it('returns no_extension_id when id empty', async () => { + const r = await pingProdtrackTabExtension(''); + expect(r).toEqual({ ok: false, reason: 'no_extension_id' }); + }); +}); diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts new file mode 100644 index 00000000..f6e4ea89 --- /dev/null +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts @@ -0,0 +1,131 @@ +export type ProdtrackTabSyncResult = + | { ok: true } + | { + ok: false; + reason: + | 'no_chrome' + | 'no_extension_id' + | 'no_extension' + | 'invalid_url' + | 'error'; + detail?: string; + }; + +type ChromeRuntime = { + sendMessage: ( + extensionId: string, + message: object, + responseCallback?: (response: unknown) => void + ) => void; + lastError?: { message?: string }; +}; + +function getChromeRuntime(): ChromeRuntime | undefined { + if (typeof globalThis === 'undefined') return undefined; + const chromeApi = ( + globalThis as { + chrome?: { runtime?: ChromeRuntime }; + } + ).chrome; + return chromeApi?.runtime; +} + +function sendExternalMessage( + extensionId: string, + message: object, + timeoutMs: number +): Promise { + const runtime = getChromeRuntime(); + if (!runtime?.sendMessage) { + return Promise.resolve(undefined); + } + + return new Promise((resolve) => { + const timer = window.setTimeout(() => resolve(undefined), timeoutMs); + try { + runtime.sendMessage(extensionId, message, (response: unknown) => { + window.clearTimeout(timer); + if (runtime.lastError?.message) { + resolve({ __error: runtime.lastError.message }); + return; + } + resolve(response); + }); + } catch (e) { + window.clearTimeout(timer); + resolve({ __error: e instanceof Error ? e.message : String(e) }); + } + }); +} + +function parseAck(raw: unknown): { ok: boolean } | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.ok === true) return { ok: true }; + return null; +} + +export async function pingProdtrackTabExtension( + extensionId: string, + timeoutMs = 400 +): Promise { + const trimmed = extensionId.trim(); + if (!trimmed) { + return { ok: false, reason: 'no_extension_id' }; + } + const runtime = getChromeRuntime(); + if (!runtime?.sendMessage) { + return { ok: false, reason: 'no_chrome' }; + } + + const raw = await sendExternalMessage(trimmed, { type: 'PING' }, timeoutMs); + if (raw && typeof raw === 'object' && '__error' in raw) { + return { + ok: false, + reason: 'no_extension', + detail: String((raw as { __error: string }).__error), + }; + } + if (raw === undefined || parseAck(raw)?.ok !== true) { + return { ok: false, reason: 'no_extension' }; + } + return { ok: true }; +} + +export async function openProdtrackVersionInExtension( + extensionId: string, + url: string, + timeoutMs = 800 +): Promise { + const trimmed = extensionId.trim(); + if (!trimmed) { + return { ok: false, reason: 'no_extension_id' }; + } + if (!url.startsWith('http')) { + return { ok: false, reason: 'invalid_url' }; + } + const runtime = getChromeRuntime(); + if (!runtime?.sendMessage) { + return { ok: false, reason: 'no_chrome' }; + } + + const raw = await sendExternalMessage( + trimmed, + { type: 'OPEN_VERSION', url }, + timeoutMs + ); + + if (raw && typeof raw === 'object' && '__error' in raw) { + return { + ok: false, + reason: 'error', + detail: String((raw as { __error: string }).__error), + }; + } + + if (parseAck(raw)?.ok === true) { + return { ok: true }; + } + + return { ok: false, reason: 'no_extension' }; +} diff --git a/frontend/packages/app/src/test/render.tsx b/frontend/packages/app/src/test/render.tsx index 2d6b446a..7f1b0a7e 100644 --- a/frontend/packages/app/src/test/render.tsx +++ b/frontend/packages/app/src/test/render.tsx @@ -5,6 +5,7 @@ import { ThemeProvider } from 'styled-components'; import { Theme } from '@radix-ui/themes'; import { theme } from '../styles'; import { AuthProvider } from '../contexts/AuthContext'; +import { ThemeModeProvider } from '../contexts/ThemeContext'; interface WrapperProps { children: ReactNode; @@ -26,11 +27,13 @@ function AllTheProviders({ children }: WrapperProps) { return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/frontend/packages/app/src/vite-env.d.ts b/frontend/packages/app/src/vite-env.d.ts index 11f02fe2..76e65a9e 100644 --- a/frontend/packages/app/src/vite-env.d.ts +++ b/frontend/packages/app/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; + readonly VITE_WS_URL: string; + readonly VITE_AUTH_PROVIDER?: string; + readonly VITE_GOOGLE_CLIENT_ID?: string; + readonly VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?: string; + readonly VITE_PRODTRACK_TAB_SYNC_INSTALL_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/packages/core/src/interfaces.ts b/frontend/packages/core/src/interfaces.ts index a50ef99a..026f315e 100644 --- a/frontend/packages/core/src/interfaces.ts +++ b/frontend/packages/core/src/interfaces.ts @@ -83,6 +83,7 @@ export interface Version extends EntityBase { entity?: Shot | Asset; task?: Task; notes: Note[]; + prodtrack_detail_url?: string; } export interface Playlist extends EntityBase { @@ -331,6 +332,7 @@ export interface UserSettings { note_prompt: string; regenerate_on_version_change: boolean; regenerate_on_transcript_update: boolean; + sync_prodtrack_tab_on_version_change: boolean; updated_at: string; created_at: string; } @@ -339,6 +341,7 @@ export interface UserSettingsUpdate { note_prompt?: string; regenerate_on_version_change?: boolean; regenerate_on_transcript_update?: boolean; + sync_prodtrack_tab_on_version_change?: boolean; } export interface GetUserSettingsParams { diff --git a/prodtrack-tab-sync-extension/README.md b/prodtrack-tab-sync-extension/README.md new file mode 100644 index 00000000..fec09f7f --- /dev/null +++ b/prodtrack-tab-sync-extension/README.md @@ -0,0 +1,41 @@ +# DNA Production Tracking Tab Sync (Chrome extension) + +Chrome extension (Manifest V3) that pairs with the DNA web app ([issue #136](https://github.com/AcademySoftwareFoundation/dna/issues/136)): DNA sends the ShotGrid / production-tracking version detail URL, and this extension opens or updates a **single controlled tab** next to your DNA tab when possible. + +## Install (development) + +1. Open Chrome → **Extensions** → enable **Developer mode**. +2. **Load unpacked** → select this folder `prodtrack-tab-sync-extension/`. +3. Copy the extension **ID** from the card (32-char string). +4. In DNA frontend, set `VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID` to that ID in `frontend/packages/app/.env` and restart the dev server. + +## Allow DNA origins + +The extension only accepts messages from origins listed under `externally_connectable.matches` in [`manifest.json`](./manifest.json). + +For local development, `http://localhost:*` and `http://127.0.0.1:*` are included by default. + +**For production or other hosts**, add your DNA site pattern to `manifest.json`, for example: + +```json +"matches": [ + "http://127.0.0.1:*/*", + "http://localhost:*/*", + "https://your-dna-host.example.com/*" +] +``` + +Then reload the extension in `chrome://extensions`. + +## Chrome Web Store + +When published, the install prompt in DNA can point users to the listing URL via `VITE_PRODTRACK_TAB_SYNC_INSTALL_URL` (see DNA `.env.example`). + +## Split view + +Chrome does not expose a stable API for extensions to force two arbitrary tabs into split view. This extension opens the production-tracking tab **in the same window, immediately after the active tab**, so you can use Chrome’s built-in split controls if you want a tiled layout. + +## Message protocol (DNA → extension) + +- `{ "type": "PING" }` → `{ "ok": true, "pong": true }` (presence check). +- `{ "type": "OPEN_VERSION", "url": "" }` → `{ "ok": true }` or `{ "ok": false, "error": "..." }`. diff --git a/prodtrack-tab-sync-extension/background.js b/prodtrack-tab-sync-extension/background.js new file mode 100644 index 00000000..d0bb79ce --- /dev/null +++ b/prodtrack-tab-sync-extension/background.js @@ -0,0 +1,85 @@ +let controlledTabId = null; + +function reply(sendResponse, payload) { + try { + sendResponse(payload); + } catch { + /* channel may be closed */ + } +} + +async function getDnaAnchorTab() { + const win = await chrome.windows.getLastFocused({ populate: true }); + if (!win?.tabs?.length) return null; + const active = win.tabs.find((t) => t.active); + return active ?? null; +} + +async function openOrUpdateControlledTab(url) { + const tryTab = async (id) => { + if (id == null) return false; + try { + const tab = await chrome.tabs.get(id); + if (tab?.id != null) { + await chrome.tabs.update(tab.id, { url, active: false }); + return true; + } + } catch { + /* tab closed */ + } + return false; + }; + + if (await tryTab(controlledTabId)) return; + + const dnaTab = await getDnaAnchorTab(); + const createProps = { url, active: false }; + + if (dnaTab?.windowId != null) { + createProps.windowId = dnaTab.windowId; + if (typeof dnaTab.index === 'number') { + createProps.index = dnaTab.index + 1; + } + } + + const created = await chrome.tabs.create(createProps); + if (created?.id != null) { + controlledTabId = created.id; + } +} + +chrome.tabs.onRemoved.addListener((tabId) => { + if (tabId === controlledTabId) controlledTabId = null; +}); + +chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => { + if (!message || typeof message !== 'object') { + reply(sendResponse, { ok: false, error: 'invalid_message' }); + return; + } + + if (message.type === 'PING') { + reply(sendResponse, { ok: true, pong: true }); + return true; + } + + if (message.type === 'OPEN_VERSION') { + const url = message.url; + if (typeof url !== 'string' || !url.startsWith('http')) { + reply(sendResponse, { ok: false, error: 'invalid_url' }); + return true; + } + openOrUpdateControlledTab(url) + .then(() => reply(sendResponse, { ok: true })) + .catch((err) => + reply(sendResponse, { + ok: false, + error: err?.message || String(err), + }) + ); + return true; + } + + reply(sendResponse, { ok: false, error: 'unknown_type' }); + return true; +}); diff --git a/prodtrack-tab-sync-extension/manifest.json b/prodtrack-tab-sync-extension/manifest.json new file mode 100644 index 00000000..0c6df9c5 --- /dev/null +++ b/prodtrack-tab-sync-extension/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 3, + "name": "DNA Production Tracking Tab Sync", + "version": "0.1.0", + "description": "Keeps a controlled ShotGrid / production-tracking tab in sync with DNA version selection.", + "background": { + "service_worker": "background.js" + }, + "permissions": ["tabs"], + "host_permissions": ["https://*/*", "http://*/*"], + "externally_connectable": { + "matches": [ + "http://127.0.0.1:*/*", + "http://localhost:*/*", + "https://localhost:*/*" + ] + } +} From e9a9f955d7902f5b6387163545db489a40702e7b Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 16 Apr 2026 16:25:02 -0700 Subject: [PATCH 2/8] Update user settings to enable auto-sync for production tracking tab - Changed default value of `sync_prodtrack_tab_on_version_change` to `True` in `UserSettings` model. - Updated MongoDB storage provider to reflect the new default setting. - Adjusted tests to verify the new default behavior for user settings. - Modified frontend components to ensure they respect the updated sync behavior. This change enhances user experience by ensuring the production tracking tab syncs automatically when version changes occur. Signed-off-by: James Spadafora --- backend/src/dna/models/user_settings.py | 2 +- backend/src/dna/storage_providers/mongodb.py | 8 ++- backend/tests/test_user_settings.py | 4 +- .../app/src/components/ContentArea.tsx | 56 +++++++++---------- .../app/src/components/SettingsModal.tsx | 16 +++--- 5 files changed, 46 insertions(+), 40 deletions(-) diff --git a/backend/src/dna/models/user_settings.py b/backend/src/dna/models/user_settings.py index ebc5cfb5..938ba294 100644 --- a/backend/src/dna/models/user_settings.py +++ b/backend/src/dna/models/user_settings.py @@ -40,6 +40,6 @@ class UserSettings(BaseModel): note_prompt: str = "" regenerate_on_version_change: bool = False regenerate_on_transcript_update: bool = False - sync_prodtrack_tab_on_version_change: bool = False + sync_prodtrack_tab_on_version_change: bool = True updated_at: datetime created_at: datetime diff --git a/backend/src/dna/storage_providers/mongodb.py b/backend/src/dna/storage_providers/mongodb.py index 021e5e05..71107ac3 100644 --- a/backend/src/dna/storage_providers/mongodb.py +++ b/backend/src/dna/storage_providers/mongodb.py @@ -285,12 +285,16 @@ async def upsert_user_settings( """Create or update user settings.""" now = datetime.now(timezone.utc) query = {"user_email": user_email} - update_fields = {k: v for k, v in data.model_dump().items() if v is not None} + update_fields = { + k: v + for k, v in data.model_dump(exclude_unset=True).items() + if v is not None + } defaults = { "note_prompt": "", "regenerate_on_version_change": False, "regenerate_on_transcript_update": False, - "sync_prodtrack_tab_on_version_change": False, + "sync_prodtrack_tab_on_version_change": True, } set_on_insert = { "created_at": now, diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index ec996b61..b432d68d 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -66,7 +66,7 @@ def test_user_settings_defaults(self): assert settings.note_prompt == "" assert settings.regenerate_on_version_change is False assert settings.regenerate_on_transcript_update is False - assert settings.sync_prodtrack_tab_on_version_change is False + assert settings.sync_prodtrack_tab_on_version_change is True class TestUserSettingsEndpoints: @@ -102,7 +102,7 @@ def test_get_user_settings_returns_200(self, mock_storage_provider, auth_client) assert data["note_prompt"] == "Custom prompt" assert data["regenerate_on_version_change"] is True assert data["regenerate_on_transcript_update"] is False - assert data["sync_prodtrack_tab_on_version_change"] is False + assert data["sync_prodtrack_tab_on_version_change"] is True mock_storage_provider.get_user_settings.assert_called_once_with( "test@example.com" ) diff --git a/frontend/packages/app/src/components/ContentArea.tsx b/frontend/packages/app/src/components/ContentArea.tsx index ada16300..c7c3436a 100644 --- a/frontend/packages/app/src/components/ContentArea.tsx +++ b/frontend/packages/app/src/components/ContentArea.tsx @@ -166,14 +166,18 @@ export function ContentArea({ import.meta.env.VITE_PRODTRACK_TAB_SYNC_INSTALL_URL?.trim() || DEFAULT_PT_SYNC_INSTALL_URL; - const { data: userSettings } = useQuery({ - queryKey: ['userSettings', userEmail], - queryFn: () => apiHandler.getUserSettings({ userEmail: userEmail! }), - enabled: !!userEmail, - }); + const { data: userSettings, isSuccess: userSettingsQuerySuccess } = + useQuery({ + queryKey: ['userSettings', userEmail], + queryFn: () => apiHandler.getUserSettings({ userEmail: userEmail! }), + enabled: !!userEmail, + }); + - const syncProdtrackOnVersionChange = - userSettings?.sync_prodtrack_tab_on_version_change === true; + const shouldAutoSyncProdtrackTab = + userSettingsQuerySuccess && + (userSettings === null || + (userSettings.sync_prodtrack_tab_on_version_change ?? true) === true); const handleSyncProdtrackTab = useCallback(async () => { const url = version?.prodtrack_detail_url; @@ -196,32 +200,28 @@ export function ContentArea({ useEffect(() => { if (!version?.prodtrack_detail_url) return; - if (!syncProdtrackOnVersionChange) return; + if (!shouldAutoSyncProdtrackTab) return; if (!extensionId) return; - let cancelled = false; - void (async () => { - const result = await openProdtrackVersionInExtension( - extensionId, - version.prodtrack_detail_url! - ); - if (cancelled) return; - if (!result.ok) { - if ( - result.reason === 'no_extension' || - result.reason === 'no_extension_id' || - result.reason === 'no_chrome' - ) { - setInstallDialogOpen(true); + const url = version.prodtrack_detail_url; + const timer = window.setTimeout(() => { + void (async () => { + const result = await openProdtrackVersionInExtension(extensionId, url); + if (!result.ok) { + if ( + result.reason === 'no_extension' || + result.reason === 'no_extension_id' || + result.reason === 'no_chrome' + ) { + setInstallDialogOpen(true); + } } - } - })(); - return () => { - cancelled = true; - }; + })(); + }, 120); + return () => window.clearTimeout(timer); }, [ version?.id, version?.prodtrack_detail_url, - syncProdtrackOnVersionChange, + shouldAutoSyncProdtrackTab, extensionId, ]); diff --git a/frontend/packages/app/src/components/SettingsModal.tsx b/frontend/packages/app/src/components/SettingsModal.tsx index d57f333c..bb33442d 100644 --- a/frontend/packages/app/src/components/SettingsModal.tsx +++ b/frontend/packages/app/src/components/SettingsModal.tsx @@ -368,9 +368,10 @@ function GeneralTab({ Sync PT tab when version changes - When enabled, the extension updates your production-tracking - tab whenever you select a different version. When disabled, - use the "PT tab" button in the version header. + When enabled (default), the extension updates your + production-tracking tab whenever you select a different version. + Turn off to update the PT tab only with the "PT tab" + button in the version header. @@ -544,7 +545,7 @@ export function SettingsModal({ const [regenerateOnTranscriptUpdate, setRegenerateOnTranscriptUpdate] = useState(false); const [syncProdtrackTabOnVersionChange, setSyncProdtrackTabOnVersionChange] = - useState(false); + useState(true); const [isDirty, setIsDirty] = useState(false); const { getAllActions, getKeysForAction, setKeysForAction, resetToDefaults } = @@ -561,7 +562,8 @@ export function SettingsModal({ const mutation = useMutation({ mutationFn: (data: UserSettingsUpdate) => apiHandler.upsertUserSettings({ userEmail, data }), - onSuccess: () => { + onSuccess: (saved) => { + queryClient.setQueryData(['userSettings', userEmail], saved); queryClient.invalidateQueries({ queryKey: ['userSettings', userEmail] }); setIsDirty(false); }, @@ -573,14 +575,14 @@ export function SettingsModal({ setRegenerateOnVersionChange(settings.regenerate_on_version_change); setRegenerateOnTranscriptUpdate(settings.regenerate_on_transcript_update); setSyncProdtrackTabOnVersionChange( - settings.sync_prodtrack_tab_on_version_change + settings.sync_prodtrack_tab_on_version_change ?? true ); setIsDirty(false); } else if (settings === null) { setNotePrompt(''); setRegenerateOnVersionChange(false); setRegenerateOnTranscriptUpdate(false); - setSyncProdtrackTabOnVersionChange(false); + setSyncProdtrackTabOnVersionChange(true); setIsDirty(false); } }, [settings]); From 527a25b99230ac9d7fed7450128f760fec03de8c Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 16 Apr 2026 16:30:20 -0700 Subject: [PATCH 3/8] Resolve noop auth provider issue Signed-off-by: James Spadafora --- .../packages/app/src/contexts/AuthContext.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/packages/app/src/contexts/AuthContext.tsx b/frontend/packages/app/src/contexts/AuthContext.tsx index 329646f6..bf2ad9d3 100644 --- a/frontend/packages/app/src/contexts/AuthContext.tsx +++ b/frontend/packages/app/src/contexts/AuthContext.tsx @@ -64,12 +64,20 @@ function NoopAuthProviderInner({ children }: NoopAuthProviderInnerProps) { }); useEffect(() => { - if (token && user) { + if (token !== 'noop-token' || !user?.email) return; + localStorage.setItem(STORAGE_KEY, user.email); + setToken(user.email); + }, [token, user?.email]); + + useEffect(() => { + const authToken = + token === 'noop-token' && user?.email ? user.email : token; + if (authToken && user) { apiHandler.setUser({ id: user.id, email: user.email, name: user.name, - token: token, + token: authToken, }); } else { apiHandler.setUser(null); @@ -91,12 +99,10 @@ function NoopAuthProviderInner({ children }: NoopAuthProviderInnerProps) { name: email.split('@')[0], }; - const noopToken = 'noop-token'; - - localStorage.setItem(STORAGE_KEY, noopToken); + localStorage.setItem(STORAGE_KEY, email); localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(authUser)); - setToken(noopToken); + setToken(email); setUser(authUser); }, []); From 7a6eb953bc79c050008da5dc950992544cb42504 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 16 Apr 2026 16:38:20 -0700 Subject: [PATCH 4/8] Add support for Chrome split view in tab synchronization - Introduced functions to handle split view IDs for tabs, allowing controlled tabs to attach to anchor tabs in a split view. - Updated the `openOrUpdateControlledTab` function to attempt attaching to the anchor tab's split view when opening or updating a controlled tab. - Enhanced error handling to ensure robustness when interacting with Chrome's tab API. This change improves the user experience by maintaining tab organization in split view scenarios. Signed-off-by: James Spadafora --- prodtrack-tab-sync-extension/background.js | 47 +++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/prodtrack-tab-sync-extension/background.js b/prodtrack-tab-sync-extension/background.js index d0bb79ce..ca8c784f 100644 --- a/prodtrack-tab-sync-extension/background.js +++ b/prodtrack-tab-sync-extension/background.js @@ -8,6 +8,39 @@ function reply(sendResponse, payload) { } } +function splitViewNoneConstant() { + if (typeof chrome.tabs?.SPLIT_VIEW_ID_NONE === 'number') { + return chrome.tabs.SPLIT_VIEW_ID_NONE; + } + return -1; +} + +function tabSplitViewId(tab) { + const v = tab?.splitViewId; + if (typeof v !== 'number') return splitViewNoneConstant(); + return v; +} + +/** + * If the anchor tab is already in a Chrome split view, try to attach the + * controlled tab to the same split. Chrome 140+ exposes splitViewId on Tab; + * tabs.update(splitViewId) is not in the public schema yet — this is a + * forward-compatible best-effort (see README). Always wrapped in try/catch. + */ +async function tryAttachControlledToAnchorSplit(anchorTabId, controlledTabId) { + if (anchorTabId == null || controlledTabId == null) return false; + const none = splitViewNoneConstant(); + try { + const anchor = await chrome.tabs.get(anchorTabId); + const sid = tabSplitViewId(anchor); + if (sid === none) return false; + await chrome.tabs.update(controlledTabId, { splitViewId: sid }); + return true; + } catch { + return false; + } +} + async function getDnaAnchorTab() { const win = await chrome.windows.getLastFocused({ populate: true }); if (!win?.tabs?.length) return null; @@ -30,7 +63,13 @@ async function openOrUpdateControlledTab(url) { return false; }; - if (await tryTab(controlledTabId)) return; + if (await tryTab(controlledTabId)) { + const anchor = await getDnaAnchorTab(); + if (anchor?.id != null && controlledTabId != null) { + await tryAttachControlledToAnchorSplit(anchor.id, controlledTabId); + } + return; + } const dnaTab = await getDnaAnchorTab(); const createProps = { url, active: false }; @@ -40,11 +79,17 @@ async function openOrUpdateControlledTab(url) { if (typeof dnaTab.index === 'number') { createProps.index = dnaTab.index + 1; } + if (typeof dnaTab.id === 'number') { + createProps.openerTabId = dnaTab.id; + } } const created = await chrome.tabs.create(createProps); if (created?.id != null) { controlledTabId = created.id; + if (dnaTab?.id != null) { + await tryAttachControlledToAnchorSplit(dnaTab.id, created.id); + } } } From 3c63225bca195996dfab78000bffcc53b70f176f Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 16 Apr 2026 16:50:27 -0700 Subject: [PATCH 5/8] Enhance production tracking tab sync instructions and Chrome extension functionality - Updated the ProdtrackTabSyncInstallDialog to include additional instructions for configuring the Chrome extension when served over HTTP on non-localhost origins. - Simplified the SettingsModal description regarding the Chrome extension requirement. - Improved the prodtrack-tab-sync-extension to handle tab synchronization more effectively, including new functions for managing tab origins and split views. - Updated the extension manifest to allow external messaging from all origins and clarified the README regarding external message handling. These changes improve user guidance and enhance the functionality of the tab synchronization feature. Signed-off-by: James Spadafora --- .../ProdtrackTabSyncInstallDialog.tsx | 9 +- .../app/src/components/SettingsModal.tsx | 6 +- prodtrack-tab-sync-extension/README.md | 25 ++- prodtrack-tab-sync-extension/background.js | 154 ++++++++++++++++-- prodtrack-tab-sync-extension/manifest.json | 7 +- 5 files changed, 158 insertions(+), 43 deletions(-) diff --git a/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx b/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx index 575800a2..b10f092f 100644 --- a/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx +++ b/frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx @@ -28,11 +28,12 @@ export function ProdtrackTabSyncInstallDialog({ DNA could not reach the Chrome extension that keeps your - production-tracking tab in sync. Install the extension, add your - DNA site to externally_connectable in - its manifest if needed, set{' '} + production-tracking tab in sync. Install the extension, set{' '} VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID{' '} - in your DNA environment, then try again. + in your DNA environment, then try again. If DNA is served over HTTP + on a host other than localhost or 127.0.0.1, add that origin to{' '} + externally_connectable.matches in the + extension manifest (see the extension README). diff --git a/frontend/packages/app/src/components/SettingsModal.tsx b/frontend/packages/app/src/components/SettingsModal.tsx index bb33442d..f5ee1605 100644 --- a/frontend/packages/app/src/components/SettingsModal.tsx +++ b/frontend/packages/app/src/components/SettingsModal.tsx @@ -351,11 +351,7 @@ function GeneralTab({
Production tracking (browser) - Requires the DNA tab sync Chrome extension and{' '} - - VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID - {' '} - in your DNA environment. + Requires the DNA tab sync Chrome extension. t.active); - return active ?? null; + const active = win.tabs.find((t) => t.active) ?? null; + if (!active) return null; + + const none = splitViewNoneConstant(); + const sid = tabSplitViewId(active); + const candidates = + sid !== none + ? win.tabs.filter((t) => tabSplitViewId(t) === sid) + : [active]; + + if (senderOrigin) { + const dnaInGroup = candidates.find((t) => sameOrigin(tabOrigin(t?.url), senderOrigin)); + if (dnaInGroup) return dnaInGroup; + } + + return active; } -async function openOrUpdateControlledTab(url) { +/** + * Any other tab sharing DNA's splitViewId (the other pane). Prefer one that + * already matches the prodtrack URL hostname when several share the split. + */ +function findSplitViewPartnerTab(dnaAnchor, tabs, prodtrackUrl) { + const none = splitViewNoneConstant(); + const sid = tabSplitViewId(dnaAnchor); + if (sid === none) return null; + + const others = tabs.filter( + (t) => typeof t.id === 'number' && t.id !== dnaAnchor.id && tabSplitViewId(t) === sid + ); + if (!others.length) return null; + + let targetHost = null; + try { + targetHost = new URL(prodtrackUrl).hostname; + } catch { + /* ignore */ + } + if (targetHost) { + const sameHost = others.find((t) => { + try { + return new URL(t.url).hostname === targetHost; + } catch { + return false; + } + }); + if (sameHost) return sameHost; + } + + return [...others].sort((a, b) => (a.index ?? 0) - (b.index ?? 0))[0]; +} + +async function openOrUpdateControlledTab(url, sender) { const tryTab = async (id) => { if (id == null) return false; try { @@ -63,32 +150,63 @@ async function openOrUpdateControlledTab(url) { return false; }; + const dnaTab = await resolveDnaAnchorTab(sender); + let dnaAnchorForSplit = dnaTab; + if (dnaTab?.id != null) { + try { + dnaAnchorForSplit = await chrome.tabs.get(dnaTab.id); + } catch { + /* use resolved tab */ + } + } + const windowTabs = + dnaAnchorForSplit?.windowId != null + ? await getTabsInWindow(dnaAnchorForSplit.windowId) + : []; + const splitPartner = + dnaAnchorForSplit != null + ? findSplitViewPartnerTab(dnaAnchorForSplit, windowTabs, url) + : null; + + if (splitPartner?.id != null) { + try { + await chrome.tabs.update(splitPartner.id, { url, active: false }); + controlledTabId = splitPartner.id; + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, splitPartner.id); + } + return; + } catch { + /* fall through: try tracked tab or create */ + } + } + if (await tryTab(controlledTabId)) { - const anchor = await getDnaAnchorTab(); - if (anchor?.id != null && controlledTabId != null) { - await tryAttachControlledToAnchorSplit(anchor.id, controlledTabId); + if (dnaAnchorForSplit?.id != null && controlledTabId != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, controlledTabId); } return; } - const dnaTab = await getDnaAnchorTab(); + controlledTabId = null; + const createProps = { url, active: false }; - if (dnaTab?.windowId != null) { - createProps.windowId = dnaTab.windowId; - if (typeof dnaTab.index === 'number') { - createProps.index = dnaTab.index + 1; + if (dnaAnchorForSplit?.windowId != null) { + createProps.windowId = dnaAnchorForSplit.windowId; + if (typeof dnaAnchorForSplit.index === 'number') { + createProps.index = dnaAnchorForSplit.index + 1; } - if (typeof dnaTab.id === 'number') { - createProps.openerTabId = dnaTab.id; + if (typeof dnaAnchorForSplit.id === 'number') { + createProps.openerTabId = dnaAnchorForSplit.id; } } const created = await chrome.tabs.create(createProps); if (created?.id != null) { controlledTabId = created.id; - if (dnaTab?.id != null) { - await tryAttachControlledToAnchorSplit(dnaTab.id, created.id); + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, created.id); } } } @@ -97,7 +215,7 @@ chrome.tabs.onRemoved.addListener((tabId) => { if (tabId === controlledTabId) controlledTabId = null; }); -chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => { +chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { if (!message || typeof message !== 'object') { reply(sendResponse, { ok: false, error: 'invalid_message' }); return; @@ -114,7 +232,7 @@ chrome.runtime.onMessageExternal.addListener((message, _sender, sendResponse) => reply(sendResponse, { ok: false, error: 'invalid_url' }); return true; } - openOrUpdateControlledTab(url) + openOrUpdateControlledTab(url, sender) .then(() => reply(sendResponse, { ok: true })) .catch((err) => reply(sendResponse, { diff --git a/prodtrack-tab-sync-extension/manifest.json b/prodtrack-tab-sync-extension/manifest.json index 0c6df9c5..4a39ccee 100644 --- a/prodtrack-tab-sync-extension/manifest.json +++ b/prodtrack-tab-sync-extension/manifest.json @@ -9,10 +9,11 @@ "permissions": ["tabs"], "host_permissions": ["https://*/*", "http://*/*"], "externally_connectable": { + "ids": ["*"], "matches": [ - "http://127.0.0.1:*/*", - "http://localhost:*/*", - "https://localhost:*/*" + "https://*/*", + "*://localhost/*", + "*://127.0.0.1/*" ] } } From 46f69c9e2d5e1e1c0a410f27b197fbf26bedca19 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 23 Apr 2026 12:09:26 -0700 Subject: [PATCH 6/8] Refactor production tracking tab sync functionality - Removed the ProdtrackTabSyncInstallDialog component as it is no longer needed. - Updated the ContentArea component to streamline the handling of production tracking tab synchronization, including the use of a new function to open URLs in a new tab if the extension is not available. - Enhanced the VersionHeader component to conditionally render the production tracking tab button based on the availability of the extension. - Improved tests to reflect changes in the production tracking tab sync logic. These updates simplify the user experience and improve the functionality of the production tracking feature. Signed-off-by: James Spadafora --- .../app/src/components/ContentArea.tsx | 82 +++++-------------- .../ProdtrackTabSyncInstallDialog.tsx | 55 ------------- .../app/src/components/VersionHeader.test.tsx | 26 +++++- .../app/src/components/VersionHeader.tsx | 42 ++++++++-- .../sendProdtrackTabSync.test.ts | 69 +++++++++++++++- .../prodtrackTabSync/sendProdtrackTabSync.ts | 25 ++++++ 6 files changed, 175 insertions(+), 124 deletions(-) delete mode 100644 frontend/packages/app/src/components/ProdtrackTabSyncInstallDialog.tsx diff --git a/frontend/packages/app/src/components/ContentArea.tsx b/frontend/packages/app/src/components/ContentArea.tsx index c7c3436a..c14468ae 100644 --- a/frontend/packages/app/src/components/ContentArea.tsx +++ b/frontend/packages/app/src/components/ContentArea.tsx @@ -1,15 +1,14 @@ -import { useRef, useCallback, useMemo, useState, useEffect } from 'react'; +import { useRef, useCallback, useMemo, useEffect } from 'react'; import styled from 'styled-components'; import { useQuery } from '@tanstack/react-query'; import type { Version, SearchResult, UserSettings } from '@dna/core'; import { VersionHeader } from './VersionHeader'; import { NoteEditor, type NoteEditorHandle } from './NoteEditor'; import { AssistantPanel } from './AssistantPanel'; -import { ProdtrackTabSyncInstallDialog } from './ProdtrackTabSyncInstallDialog'; import { usePlaylistMetadata, useSetInReview, useDraftNote } from '../hooks'; import { useHotkeyAction } from '../hotkeys'; import { apiHandler } from '../api'; -import { openProdtrackVersionInExtension } from '../prodtrackTabSync/sendProdtrackTabSync'; +import { openProdtrackVersionViaExtensionOrNewTab } from '../prodtrackTabSync/sendProdtrackTabSync'; interface ContentAreaProps { version?: Version | null; @@ -64,9 +63,6 @@ function formatDate(dateString?: string): string { const IN_REVIEW_STATUS = 'rev'; -const DEFAULT_PT_SYNC_INSTALL_URL = - 'https://github.com/AcademySoftwareFoundation/dna/blob/main/prodtrack-tab-sync-extension/README.md'; - export function ContentArea({ version, versions = [], @@ -159,12 +155,8 @@ export function ContentArea({ enabled: !!version && !!playlistId, }); - const [installDialogOpen, setInstallDialogOpen] = useState(false); const extensionId = import.meta.env.VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?.trim() ?? ''; - const installDocUrl = - import.meta.env.VITE_PRODTRACK_TAB_SYNC_INSTALL_URL?.trim() || - DEFAULT_PT_SYNC_INSTALL_URL; const { data: userSettings, isSuccess: userSettingsQuerySuccess } = useQuery({ @@ -179,23 +171,10 @@ export function ContentArea({ (userSettings === null || (userSettings.sync_prodtrack_tab_on_version_change ?? true) === true); - const handleSyncProdtrackTab = useCallback(async () => { + const handleSyncProdtrackTab = useCallback(() => { const url = version?.prodtrack_detail_url; - if (!url) return; - if (!extensionId) { - setInstallDialogOpen(true); - return; - } - const result = await openProdtrackVersionInExtension(extensionId, url); - if (!result.ok) { - if ( - result.reason === 'no_extension' || - result.reason === 'no_extension_id' || - result.reason === 'no_chrome' - ) { - setInstallDialogOpen(true); - } - } + if (!url || !extensionId) return; + void openProdtrackVersionViaExtensionOrNewTab(extensionId, url); }, [version?.prodtrack_detail_url, extensionId]); useEffect(() => { @@ -204,18 +183,7 @@ export function ContentArea({ if (!extensionId) return; const url = version.prodtrack_detail_url; const timer = window.setTimeout(() => { - void (async () => { - const result = await openProdtrackVersionInExtension(extensionId, url); - if (!result.ok) { - if ( - result.reason === 'no_extension' || - result.reason === 'no_extension_id' || - result.reason === 'no_chrome' - ) { - setInstallDialogOpen(true); - } - } - })(); + void openProdtrackVersionViaExtensionOrNewTab(extensionId, url); }, 120); return () => window.clearTimeout(timer); }, [ @@ -227,29 +195,22 @@ export function ContentArea({ const syncProdtrackTitle = !version?.prodtrack_detail_url ? 'Production tracking URL is not available for this version.' - : !extensionId - ? 'Set VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID in your DNA app environment (see install instructions).' - : 'Open this version in the extension-controlled production-tracking tab.'; + : extensionId + ? 'Open in the tab sync extension when available; otherwise opens in a new tab.' + : 'Open production tracking in a new browser tab.'; const syncProdtrackDisabled = !version?.prodtrack_detail_url; if (!version) { return ( - <> - - - - No version selected - - Select a version from the sidebar to view its details - - - - + + + No version selected + + Select a version from the sidebar to view its details + + + ); } @@ -268,11 +229,6 @@ export function ContentArea({ return ( <> - void; - installDocUrl?: string; -} - -export function ProdtrackTabSyncInstallDialog({ - open, - onOpenChange, - installDocUrl = DEFAULT_INSTALL_DOC_URL, -}: ProdtrackTabSyncInstallDialogProps) { - return ( - - - Install the DNA tab sync extension - - - - DNA could not reach the Chrome extension that keeps your - production-tracking tab in sync. Install the extension, set{' '} - VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID{' '} - in your DNA environment, then try again. If DNA is served over HTTP - on a host other than localhost or 127.0.0.1, add that origin to{' '} - externally_connectable.matches in the - extension manifest (see the extension README). - - - - Installation instructions - - - - - - - - - - - - ); -} diff --git a/frontend/packages/app/src/components/VersionHeader.test.tsx b/frontend/packages/app/src/components/VersionHeader.test.tsx index ed3696ec..aaff34e7 100644 --- a/frontend/packages/app/src/components/VersionHeader.test.tsx +++ b/frontend/packages/app/src/components/VersionHeader.test.tsx @@ -13,7 +13,7 @@ afterEach(() => { }); describe('VersionHeader', () => { - it('renders PT tab button when onSyncProdtrackTab is provided', () => { + it('renders PT tab button when extension sync is enabled', () => { const onSync = vi.fn(); render( @@ -21,6 +21,8 @@ describe('VersionHeader', () => { shotCode="SHOT" versionNumber="v001" projectId={1} + prodtrackDetailUrl="https://studio.shotgrid.autodesk.com/detail/Version/1" + prodtrackTabUsesExtension onSyncProdtrackTab={onSync} syncProdtrackDisabled={false} /> @@ -28,4 +30,26 @@ describe('VersionHeader', () => { ); expect(screen.getByRole('button', { name: /PT tab/i })).toBeInTheDocument(); }); + + it('renders PT tab as a new-tab link when extension sync is off', () => { + render( + + + + ); + const link = screen.getByRole('link', { name: /PT tab/i }); + expect(link).toHaveAttribute( + 'href', + 'https://studio.shotgrid.autodesk.com/detail/Version/2' + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); }); diff --git a/frontend/packages/app/src/components/VersionHeader.tsx b/frontend/packages/app/src/components/VersionHeader.tsx index f2f3f5c1..0cc47906 100644 --- a/frontend/packages/app/src/components/VersionHeader.tsx +++ b/frontend/packages/app/src/components/VersionHeader.tsx @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Tooltip } from '@radix-ui/themes'; import { ChevronLeft, @@ -29,7 +29,9 @@ interface VersionHeaderProps { onRefresh?: () => void; onSetInReview?: () => void; onVersionStatusChange?: (code: string) => void; - onSyncProdtrackTab?: () => void; + prodtrackDetailUrl?: string | null; + prodtrackTabUsesExtension?: boolean; + onSyncProdtrackTab?: () => void | Promise; syncProdtrackDisabled?: boolean; syncProdtrackTitle?: string; canGoBack?: boolean; @@ -111,7 +113,7 @@ const InReviewButton = styled.button` } `; -const SyncProdtrackButton = styled.button` +const syncProdtrackSurface = css` display: flex; align-items: center; gap: 6px; @@ -125,6 +127,11 @@ const SyncProdtrackButton = styled.button` border-radius: ${({ theme }) => theme.radii.md}; cursor: pointer; transition: all ${({ theme }) => theme.transitions.fast}; + box-sizing: border-box; +`; + +const SyncProdtrackButton = styled.button` + ${syncProdtrackSurface} &:hover:not(:disabled) { background: ${({ theme }) => theme.colors.bg.surfaceHover}; @@ -138,6 +145,17 @@ const SyncProdtrackButton = styled.button` } `; +const SyncProdtrackLink = styled.a` + ${syncProdtrackSurface} + text-decoration: none; + + &:hover { + background: ${({ theme }) => theme.colors.bg.surfaceHover}; + color: ${({ theme }) => theme.colors.text.primary}; + border-color: ${({ theme }) => theme.colors.border.strong}; + } +`; + const NextVersionButton = styled.button` display: flex; align-items: center; @@ -367,6 +385,8 @@ export function VersionHeader({ onRefresh, onSetInReview, onVersionStatusChange, + prodtrackDetailUrl, + prodtrackTabUsesExtension = false, onSyncProdtrackTab, syncProdtrackDisabled = false, syncProdtrackTitle = 'Open current version in production tracking (browser tab)', @@ -395,11 +415,11 @@ export function VersionHeader({ In Review - {onSyncProdtrackTab && ( + {prodtrackDetailUrl && prodtrackTabUsesExtension && onSyncProdtrackTab && ( void onSyncProdtrackTab()} disabled={syncProdtrackDisabled} > @@ -407,6 +427,18 @@ export function VersionHeader({ )} + {prodtrackDetailUrl && !prodtrackTabUsesExtension && ( + + + + PT tab + + + )} Next Version diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts index cb9d117a..bdc8c3ce 100644 --- a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { openProdtrackVersionInExtension, + openProdtrackUrlInUncontrolledNewTab, + openProdtrackVersionViaExtensionOrNewTab, pingProdtrackTabExtension, } from './sendProdtrackTabSync'; @@ -61,6 +63,71 @@ describe('openProdtrackVersionInExtension', () => { }); }); +describe('openProdtrackUrlInUncontrolledNewTab', () => { + it('does nothing for non-http URLs', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + openProdtrackUrlInUncontrolledNewTab('javascript:alert(1)'); + expect(open).not.toHaveBeenCalled(); + open.mockRestore(); + }); + + it('opens http(s) URL in a new tab and clears opener', () => { + const mockWin = { opener: {} as unknown }; + const open = vi.spyOn(window, 'open').mockImplementation(() => mockWin as Window); + openProdtrackUrlInUncontrolledNewTab('https://example.com/v/1'); + expect(open).toHaveBeenCalledWith('https://example.com/v/1', '_blank'); + expect(mockWin.opener).toBeNull(); + open.mockRestore(); + }); +}); + +describe('openProdtrackVersionViaExtensionOrNewTab', () => { + it('opens a new tab when the extension does not respond', async () => { + const mockWin = { opener: {} as unknown }; + const open = vi.spyOn(window, 'open').mockImplementation(() => mockWin as Window); + await openProdtrackVersionViaExtensionOrNewTab( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/1' + ); + expect(open).toHaveBeenCalledWith( + 'https://studio.shotgrid.autodesk.com/detail/Version/1', + '_blank' + ); + open.mockRestore(); + }); + + it('does not open a new tab when the extension succeeds', async () => { + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + _msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, _msg, cb) => { + cb({ ok: true }); + }, + lastError: undefined, + }, + }; + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + await openProdtrackVersionViaExtensionOrNewTab( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/2' + ); + expect(open).not.toHaveBeenCalled(); + open.mockRestore(); + }); +}); + describe('pingProdtrackTabExtension', () => { it('returns no_extension_id when id empty', async () => { const r = await pingProdtrackTabExtension(''); diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts index f6e4ea89..181d3014 100644 --- a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts @@ -65,6 +65,16 @@ function parseAck(raw: unknown): { ok: boolean } | null { return null; } +/** Opens the production-tracking URL in a normal new browser tab (not extension-controlled). */ +export function openProdtrackUrlInUncontrolledNewTab(url: string): void { + if (!url.startsWith('http')) return; + if (typeof window === 'undefined' || typeof window.open !== 'function') return; + const opened = window.open(url, '_blank'); + if (opened) { + opened.opener = null; + } +} + export async function pingProdtrackTabExtension( extensionId: string, timeoutMs = 400 @@ -129,3 +139,18 @@ export async function openProdtrackVersionInExtension( return { ok: false, reason: 'no_extension' }; } + +export async function openProdtrackVersionViaExtensionOrNewTab( + extensionId: string, + url: string, + timeoutMs = 800 +): Promise { + const result = await openProdtrackVersionInExtension( + extensionId, + url, + timeoutMs + ); + if (!result.ok) { + openProdtrackUrlInUncontrolledNewTab(url); + } +} From 70979cfe16e4b09999726c6f0d9a23b9ff728a57 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Thu, 23 Apr 2026 12:26:39 -0700 Subject: [PATCH 7/8] Sync the tab ID back to the frontend. Signed-off-by: James Spadafora Made-with: Cursor --- .../app/src/components/ContentArea.tsx | 26 +++++- .../sendProdtrackTabSync.test.ts | 79 ++++++++++++++++++- .../prodtrackTabSync/sendProdtrackTabSync.ts | 64 +++++++++++---- prodtrack-tab-sync-extension/README.md | 2 +- prodtrack-tab-sync-extension/background.js | 59 +++++++++++--- 5 files changed, 194 insertions(+), 36 deletions(-) diff --git a/frontend/packages/app/src/components/ContentArea.tsx b/frontend/packages/app/src/components/ContentArea.tsx index c14468ae..8ea16730 100644 --- a/frontend/packages/app/src/components/ContentArea.tsx +++ b/frontend/packages/app/src/components/ContentArea.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback, useMemo, useEffect } from 'react'; +import { useRef, useCallback, useMemo, useEffect, useState } from 'react'; import styled from 'styled-components'; import { useQuery } from '@tanstack/react-query'; import type { Version, SearchResult, UserSettings } from '@dna/core'; @@ -158,6 +158,12 @@ export function ContentArea({ const extensionId = import.meta.env.VITE_PRODTRACK_TAB_SYNC_EXTENSION_ID?.trim() ?? ''; + const [prodtrackControlledTabId, setProdtrackControlledTabId] = useState< + number | null + >(null); + const prodtrackTabIdRef = useRef(null); + prodtrackTabIdRef.current = prodtrackControlledTabId; + const { data: userSettings, isSuccess: userSettingsQuerySuccess } = useQuery({ queryKey: ['userSettings', userEmail], @@ -174,8 +180,14 @@ export function ContentArea({ const handleSyncProdtrackTab = useCallback(() => { const url = version?.prodtrack_detail_url; if (!url || !extensionId) return; - void openProdtrackVersionViaExtensionOrNewTab(extensionId, url); - }, [version?.prodtrack_detail_url, extensionId]); + void openProdtrackVersionViaExtensionOrNewTab(extensionId, url, { + tabId: prodtrackControlledTabId ?? undefined, + }).then((result) => { + if (result.ok && typeof result.tabId === 'number') { + setProdtrackControlledTabId(result.tabId); + } + }); + }, [version?.prodtrack_detail_url, extensionId, prodtrackControlledTabId]); useEffect(() => { if (!version?.prodtrack_detail_url) return; @@ -183,7 +195,13 @@ export function ContentArea({ if (!extensionId) return; const url = version.prodtrack_detail_url; const timer = window.setTimeout(() => { - void openProdtrackVersionViaExtensionOrNewTab(extensionId, url); + void openProdtrackVersionViaExtensionOrNewTab(extensionId, url, { + tabId: prodtrackTabIdRef.current ?? undefined, + }).then((result) => { + if (result.ok && typeof result.tabId === 'number') { + setProdtrackControlledTabId(result.tabId); + } + }); }, 120); return () => window.clearTimeout(timer); }, [ diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts index bdc8c3ce..d64e02f9 100644 --- a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.test.ts @@ -32,7 +32,7 @@ describe('openProdtrackVersionInExtension', () => { expect(r).toEqual({ ok: false, reason: 'no_chrome' }); }); - it('returns ok when extension responds with ok true', async () => { + it('returns ok when extension responds with ok true (legacy, no tabId)', async () => { ( globalThis as { chrome?: { @@ -61,6 +61,77 @@ describe('openProdtrackVersionInExtension', () => { ); expect(r).toEqual({ ok: true }); }); + + it('sends tabId in message when options include it', async () => { + let lastMsg: object = {}; + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, msg, cb) => { + lastMsg = msg; + cb({ ok: true, tabId: 99 }); + }, + lastError: undefined, + }, + }; + + const r = await openProdtrackVersionInExtension( + 'abcdefghijklmnopabcdefghijklmnop', + 'https://studio.shotgrid.autodesk.com/detail/Version/1', + { tabId: 99 } + ); + expect(lastMsg).toEqual( + expect.objectContaining({ + type: 'OPEN_VERSION', + url: 'https://studio.shotgrid.autodesk.com/detail/Version/1', + tabId: 99, + }) + ); + expect(r).toEqual({ ok: true, tabId: 99 }); + }); + + it('omits tabId from message when option is not a positive id', async () => { + let lastMsg: object = {}; + ( + globalThis as { + chrome?: { + runtime: { + sendMessage: ( + _id: string, + msg: object, + cb: (r: unknown) => void + ) => void; + lastError?: { message?: string }; + }; + }; + } + ).chrome = { + runtime: { + sendMessage: (_id, msg, cb) => { + lastMsg = msg; + cb({ ok: true, tabId: 3 }); + }, + lastError: undefined, + }, + }; + + await openProdtrackVersionInExtension('abcdefghijklmnopabcdefghijklmnop', 'https://a.com/b', { + tabId: 0, + }); + expect((lastMsg as { tabId?: number }).tabId).toBeUndefined(); + }); }); describe('openProdtrackUrlInUncontrolledNewTab', () => { @@ -85,10 +156,11 @@ describe('openProdtrackVersionViaExtensionOrNewTab', () => { it('opens a new tab when the extension does not respond', async () => { const mockWin = { opener: {} as unknown }; const open = vi.spyOn(window, 'open').mockImplementation(() => mockWin as Window); - await openProdtrackVersionViaExtensionOrNewTab( + const r = await openProdtrackVersionViaExtensionOrNewTab( 'abcdefghijklmnopabcdefghijklmnop', 'https://studio.shotgrid.autodesk.com/detail/Version/1' ); + expect(r).toEqual({ ok: false, reason: 'no_chrome' }); expect(open).toHaveBeenCalledWith( 'https://studio.shotgrid.autodesk.com/detail/Version/1', '_blank' @@ -119,10 +191,11 @@ describe('openProdtrackVersionViaExtensionOrNewTab', () => { }, }; const open = vi.spyOn(window, 'open').mockImplementation(() => null); - await openProdtrackVersionViaExtensionOrNewTab( + const r = await openProdtrackVersionViaExtensionOrNewTab( 'abcdefghijklmnopabcdefghijklmnop', 'https://studio.shotgrid.autodesk.com/detail/Version/2' ); + expect(r).toEqual({ ok: true }); expect(open).not.toHaveBeenCalled(); open.mockRestore(); }); diff --git a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts index 181d3014..468b851f 100644 --- a/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts +++ b/frontend/packages/app/src/prodtrackTabSync/sendProdtrackTabSync.ts @@ -1,5 +1,5 @@ export type ProdtrackTabSyncResult = - | { ok: true } + | { ok: true; tabId?: number } | { ok: false; reason: @@ -58,11 +58,28 @@ function sendExternalMessage( }); } -function parseAck(raw: unknown): { ok: boolean } | null { +export type OpenVersionOptions = { + /** Last known controlled tab id from a prior OPEN_VERSION; forwarded to the extension */ + tabId?: number; + /** Defaults to 800 */ + timeoutMs?: number; +}; + +function parseOpenVersionResponse( + raw: unknown +): { ok: true; tabId?: number } | null { if (!raw || typeof raw !== 'object') return null; const o = raw as Record; - if (o.ok === true) return { ok: true }; - return null; + if (o.ok !== true) return null; + if (typeof o.tabId === 'number' && Number.isFinite(o.tabId)) { + return { ok: true, tabId: o.tabId }; + } + return { ok: true }; +} + +function parsePingResponse(raw: unknown): boolean { + if (!raw || typeof raw !== 'object') return false; + return (raw as { ok?: unknown }).ok === true; } /** Opens the production-tracking URL in a normal new browser tab (not extension-controlled). */ @@ -96,16 +113,20 @@ export async function pingProdtrackTabExtension( detail: String((raw as { __error: string }).__error), }; } - if (raw === undefined || parseAck(raw)?.ok !== true) { + if (raw === undefined || !parsePingResponse(raw)) { return { ok: false, reason: 'no_extension' }; } return { ok: true }; } +/** + * @param timeoutOrOptions — A millisecond timeout (default 800) or open options + * including `tabId` (last known controlled tab) and `timeoutMs`. + */ export async function openProdtrackVersionInExtension( extensionId: string, url: string, - timeoutMs = 800 + timeoutOrOptions: number | OpenVersionOptions = 800 ): Promise { const trimmed = extensionId.trim(); if (!trimmed) { @@ -119,11 +140,20 @@ export async function openProdtrackVersionInExtension( return { ok: false, reason: 'no_chrome' }; } - const raw = await sendExternalMessage( - trimmed, - { type: 'OPEN_VERSION', url }, - timeoutMs - ); + const openOpts = + typeof timeoutOrOptions === 'number' ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions; + const timeoutMs = openOpts.timeoutMs ?? 800; + const lastKnownTabId = openOpts.tabId; + + const message: { type: string; url: string; tabId?: number } = { + type: 'OPEN_VERSION', + url, + }; + if (typeof lastKnownTabId === 'number' && lastKnownTabId > 0) { + message.tabId = lastKnownTabId; + } + + const raw = await sendExternalMessage(trimmed, message, timeoutMs); if (raw && typeof raw === 'object' && '__error' in raw) { return { @@ -133,8 +163,9 @@ export async function openProdtrackVersionInExtension( }; } - if (parseAck(raw)?.ok === true) { - return { ok: true }; + const ack = parseOpenVersionResponse(raw); + if (ack != null) { + return { ok: true, tabId: ack.tabId }; } return { ok: false, reason: 'no_extension' }; @@ -143,14 +174,15 @@ export async function openProdtrackVersionInExtension( export async function openProdtrackVersionViaExtensionOrNewTab( extensionId: string, url: string, - timeoutMs = 800 -): Promise { + timeoutOrOptions: number | OpenVersionOptions = 800 +): Promise { const result = await openProdtrackVersionInExtension( extensionId, url, - timeoutMs + timeoutOrOptions ); if (!result.ok) { openProdtrackUrlInUncontrolledNewTab(url); } + return result; } diff --git a/prodtrack-tab-sync-extension/README.md b/prodtrack-tab-sync-extension/README.md index ee66d41d..d8e5b9b2 100644 --- a/prodtrack-tab-sync-extension/README.md +++ b/prodtrack-tab-sync-extension/README.md @@ -37,4 +37,4 @@ This extension therefore: ## Message protocol (DNA → extension) - `{ "type": "PING" }` → `{ "ok": true, "pong": true }` (presence check). -- `{ "type": "OPEN_VERSION", "url": "" }` → `{ "ok": true }` or `{ "ok": false, "error": "..." }`. +- `{ "type": "OPEN_VERSION", "url": "", "tabId"?: }` — `tabId` is optional: last known Chrome **tab** id of the production-tracking window from a prior `OPEN_VERSION` success. The extension **tries that id first** (if still open); if it is missing, it uses split-view heuristics, the extension’s in-memory id, or creates a new tab. Response: `{ "ok": true, "tabId": }` (the id that was navigated) or `{ "ok": false, "error": "..." }`. diff --git a/prodtrack-tab-sync-extension/background.js b/prodtrack-tab-sync-extension/background.js index fc65f1f6..3dc03e2e 100644 --- a/prodtrack-tab-sync-extension/background.js +++ b/prodtrack-tab-sync-extension/background.js @@ -135,21 +135,47 @@ function findSplitViewPartnerTab(dnaAnchor, tabs, prodtrackUrl) { return [...others].sort((a, b) => (a.index ?? 0) - (b.index ?? 0))[0]; } -async function openOrUpdateControlledTab(url, sender) { - const tryTab = async (id) => { - if (id == null) return false; +/** + * @param {string} url + * @param {chrome.runtime.MessageSender} sender + * @param {unknown} [clientTabId] — last known tab id from the page; used first if still valid + * @returns {Promise} Chrome tab id that was navigated, or null on total failure + */ +async function openOrUpdateControlledTab(url, sender, clientTabId) { + const updateTabById = async (id) => { + if (id == null) return null; try { const tab = await chrome.tabs.get(id); if (tab?.id != null) { await chrome.tabs.update(tab.id, { url, active: false }); - return true; + return tab.id; } } catch { /* tab closed */ } - return false; + return null; }; + if (typeof clientTabId === 'number' && clientTabId > 0) { + const fromClient = await updateTabById(clientTabId); + if (fromClient != null) { + controlledTabId = fromClient; + const dnaTab = await resolveDnaAnchorTab(sender); + let dnaAnchorForSplit = dnaTab; + if (dnaTab?.id != null) { + try { + dnaAnchorForSplit = await chrome.tabs.get(dnaTab.id); + } catch { + /* use resolved */ + } + } + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, fromClient); + } + return fromClient; + } + } + const dnaTab = await resolveDnaAnchorTab(sender); let dnaAnchorForSplit = dnaTab; if (dnaTab?.id != null) { @@ -175,17 +201,18 @@ async function openOrUpdateControlledTab(url, sender) { if (dnaAnchorForSplit?.id != null) { await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, splitPartner.id); } - return; + return splitPartner.id; } catch { /* fall through: try tracked tab or create */ } } - if (await tryTab(controlledTabId)) { - if (dnaAnchorForSplit?.id != null && controlledTabId != null) { - await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, controlledTabId); + const fromTracked = await updateTabById(controlledTabId); + if (fromTracked != null) { + if (dnaAnchorForSplit?.id != null) { + await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, fromTracked); } - return; + return fromTracked; } controlledTabId = null; @@ -208,7 +235,9 @@ async function openOrUpdateControlledTab(url, sender) { if (dnaAnchorForSplit?.id != null) { await tryAttachControlledToAnchorSplit(dnaAnchorForSplit.id, created.id); } + return created.id; } + return null; } chrome.tabs.onRemoved.addListener((tabId) => { @@ -232,8 +261,14 @@ chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => reply(sendResponse, { ok: false, error: 'invalid_url' }); return true; } - openOrUpdateControlledTab(url, sender) - .then(() => reply(sendResponse, { ok: true })) + openOrUpdateControlledTab(url, sender, message.tabId) + .then((tabId) => { + if (typeof tabId === 'number') { + reply(sendResponse, { ok: true, tabId }); + } else { + reply(sendResponse, { ok: false, error: 'no_tab' }); + } + }) .catch((err) => reply(sendResponse, { ok: false, From b2398e77606e06b9ee78d65c81a5697f9b251b9e Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Tue, 28 Apr 2026 10:39:20 -0700 Subject: [PATCH 8/8] Resolve failing tests Signed-off-by: James Spadafora --- backend/src/dna/models/user_settings_response.py | 1 + backend/src/main.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/backend/src/dna/models/user_settings_response.py b/backend/src/dna/models/user_settings_response.py index 937c61d3..71ce20e6 100644 --- a/backend/src/dna/models/user_settings_response.py +++ b/backend/src/dna/models/user_settings_response.py @@ -16,5 +16,6 @@ class UserSettingsResponse(BaseModel): default_note_prompt: str = "" regenerate_on_version_change: bool = False regenerate_on_transcript_update: bool = False + sync_prodtrack_tab_on_version_change: bool = True updated_at: datetime created_at: datetime diff --git a/backend/src/main.py b/backend/src/main.py index 3e03dd91..8249789c 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1201,6 +1201,9 @@ def _user_settings_to_response(settings: UserSettings) -> UserSettingsResponse: default_note_prompt=get_default_note_prompt(), regenerate_on_version_change=settings.regenerate_on_version_change, regenerate_on_transcript_update=settings.regenerate_on_transcript_update, + sync_prodtrack_tab_on_version_change=( + settings.sync_prodtrack_tab_on_version_change + ), updated_at=settings.updated_at, created_at=settings.created_at, ) @@ -1218,6 +1221,7 @@ def _empty_user_settings_response(user_email: str) -> UserSettingsResponse: default_note_prompt=default, regenerate_on_version_change=False, regenerate_on_transcript_update=False, + sync_prodtrack_tab_on_version_change=True, updated_at=now, created_at=now, )