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
4 changes: 4 additions & 0 deletions ui/scripts/check-i18n-catalogs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export function catalogReport() {
const PILOT_FILES = [
'src/components/MainContent/TabFilter.tsx',
'src/components/MainContent/MainContent.tsx',
'src/components/MainContent/MediaFeedItem.tsx',
'src/components/MainContent/VideoExtraInfoDialog.tsx',
'src/components/MainContent/VideoInfoBar.tsx',
'src/components/SettingsDrawer/SettingsDrawer.tsx',
'src/components/SettingsDrawer/SystemSettingsPanel.tsx',
'src/components/ActivityFooter.tsx',
Expand All @@ -48,6 +51,7 @@ const FORBIDDEN = [
'Output folders',
'Pregunta al mago',
'Carpetas de salida',
'Extra info',
]

export function forbiddenLiterals() {
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/MainContent/MediaFeedItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties }
import { Play, Pencil, RefreshCw, Copy, Trash2, Check, Combine, Loader2, Heart, ArrowLeftToLine, Download, FolderInput, Scissors, FastForward, BookMarked, BookOpen, Box, Film, BadgeInfo, Clock3 } from 'lucide-react'
import { SaveRecipeDialog } from '../Recipes/SaveRecipeDialog'
import { VideoExtraInfoDialog } from './VideoExtraInfoDialog'
import { useUiTranslation } from '../../i18n'
import { useStore } from '../../stores/useStore'
import { getStoredAssetUrl, fetchOutputMetadata, getFileUrl, moveOutput, uploadImage, loadComicProject, selectPipelineClipVideo } from '../../api/client'
import type { OutputFile, OutputMetadata } from '../../types'
Expand Down Expand Up @@ -83,6 +84,7 @@ function RetryImage({ url, alt }: { url: string; alt: string }) {
}

export function MediaFeedItem({ file, index, isActive, onVisible, onMeasured, style }: Props) {
const { t } = useUiTranslation('activity')
const setSelectedOutput = useStore(s => s.setSelectedOutput)
const setMediaFilter = useStore(s => s.setMediaFilter)
const loadSettingsFromOutput = useStore(s => s.loadSettingsFromOutput)
Expand Down Expand Up @@ -864,7 +866,7 @@ export function MediaFeedItem({ file, index, isActive, onVisible, onMeasured, st
title="Generate descriptions and social copy from saved prompts"
>
<BadgeInfo size={13} />
Extra info
{t('extraInfo')}
</button>
</>
)}
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/MainContent/VideoExtraInfoDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { BadgeInfo, CalendarDays, Check, Clock3, Copy, FileVideo2, Languages, Loader2, MessageSquareText, RefreshCw, SlidersHorizontal, Sparkles, X, Youtube } from 'lucide-react'
import { fetchVideoExtraInfo, generateVideoExtraInfo } from '../../api/client'
import { useUiTranslation } from '../../i18n'
import { formatGenerationBreakdown, formatGenerationDuration } from '../../lib/generationTiming'
import type { VideoClipInfo, VideoExtraInfo, VideoExtraInfoStatus } from '../../types'

Expand Down Expand Up @@ -110,6 +111,7 @@ function clipInfoAsText(clip: VideoClipInfo) {
}

export function VideoExtraInfoDialog({ name, onClose }: { name: string; onClose: () => void }) {
const { t } = useUiTranslation('activity')
const [language, setLanguage] = useState(initialLanguage)
const [status, setStatus] = useState<VideoExtraInfoStatus | null>(null)
const [data, setData] = useState<VideoExtraInfo | null>(null)
Expand Down Expand Up @@ -192,7 +194,7 @@ export function VideoExtraInfoDialog({ name, onClose }: { name: string; onClose:
<BadgeInfo size={18} />
</div>
<div className="min-w-0 flex-1">
<h2 id="video-extra-info-title" className="text-sm font-semibold text-text-primary">Extra info</h2>
<h2 id="video-extra-info-title" className="text-sm font-semibold text-text-primary">{t('extraInfo')}</h2>
<p className="mt-1 truncate text-[11px] text-text-muted" title={name}>{name}</p>
</div>
<button
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/MainContent/VideoInfoBar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useState, useRef } from 'react'
import { Pencil, RefreshCw, Copy, Trash2, Check, Combine, Loader2, Sparkles, Mic, BadgeInfo, Music2 } from 'lucide-react'
import { useStore } from '../../stores/useStore'
import { useUiTranslation } from '../../i18n'
import { getStoredAssetUrl } from '../../api/client'
import { modelDisplayName } from '../../lib/modelDisplay'
import { formatGenerationBreakdown, formatGenerationDuration } from '../../lib/generationTiming'
import { VideoExtraInfoDialog } from './VideoExtraInfoDialog'
import { AlternativeSongsDialog, canRemountVideoclip } from './AlternativeSongsDialog'

export function VideoInfoBar() {
const { t } = useUiTranslation('activity')
const outputs = useStore(s => s.filteredOutputs())
const selectedOutput = useStore(s => s.selectedOutput)
const meta = useStore(s => s.selectedOutputMeta)
Expand Down Expand Up @@ -211,7 +213,7 @@ export function VideoInfoBar() {
title="Generate descriptions and social copy from saved prompts"
>
<BadgeInfo size={14} />
Extra info
{t('extraInfo')}
</button>
{canRemountVideoclip(selected) && (
<button
Expand Down
74 changes: 71 additions & 3 deletions ui/src/features/assets/AssetsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export function AssetsPanel() {
const inspect = async (asset: AssetCatalogItem) => {
setInspectorLoading(true); setError('')
try { setInspecting(await fetchAsset(asset.id)) }
catch (reason) { setError(reason instanceof Error ? reason.message : 'No se pudo cargar Extra info') }
catch (reason) { setError(reason instanceof Error ? reason.message : tActivity('inspector.loadFailed')) }
finally { setInspectorLoading(false) }
}

Expand Down Expand Up @@ -210,14 +210,82 @@ export function AssetsPanel() {

function AssetExtraInfoDialog({ asset, loading, onClose }: { asset: AssetCatalogItem | null; loading: boolean; onClose: () => void }) {
const { t: tActivity } = useUiTranslation('activity')
const { t: tCommon } = useUiTranslation('common')
const manifest = asset?.manifest || {}
const prompts = (manifest.generation as { prompts?: Record<string, unknown> } | undefined)?.prompts || {}
const timing = (manifest.timing as Record<string, unknown> | undefined) || {}
const raw = asset ? JSON.stringify(manifest, null, 2) : ''
const copy = async (value: string) => { await navigator.clipboard?.writeText(value) }
return <div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4" role="dialog" aria-modal="true" aria-labelledby="asset-extra-info-title" onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}><div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-bg-secondary shadow-2xl"><header className="flex items-center justify-between border-b border-border p-3"><div><h2 id="asset-extra-info-title" className="text-sm font-semibold text-text-primary">{tActivity('extraInfo')}</h2><p className="text-[10px] text-text-muted">{asset?.filename || 'Cargando metadata…'}</p></div><button onClick={onClose} className="rounded px-2 py-1 text-xs text-text-muted hover:bg-bg-hover">Cerrar</button></header>{loading || !asset ? <div className="flex items-center justify-center gap-2 p-12 text-xs text-text-muted"><Loader2 size={15} className="animate-spin" /> Leyendo manifest…</div> : <div className="space-y-4 overflow-y-auto p-4 text-xs"><InfoSection title="Identidad" values={{ asset_id: asset.id, kind: asset.kind, metadata_status: asset.metadata_status, locations: asset.workspace_ids.join(', ') }} /><InfoSection title="Origen y ejecución" values={{ tool: asset.origin.tool, capability: asset.origin.capability, run_id: asset.execution.run_id, task_id: asset.execution.task_id, job_id: asset.execution.job_id, pipeline_id: asset.execution.pipeline_id, status: asset.execution.status }} /><InfoSection title="Modelo y tiempos" values={{ provider: asset.model.provider, model: asset.model.id, created_at: timing.created_at, queued_at: timing.queued_at, started_at: timing.started_at, completed_at: timing.completed_at, queue_seconds: timing.queue_seconds, inference_seconds: timing.inference_seconds, total_seconds: timing.total_seconds }} />{Object.entries(prompts).map(([name, value]) => typeof value === 'string' && value ? <section key={name} className="rounded-lg border border-border bg-bg-primary p-3"><div className="mb-2 flex items-center justify-between"><h3 className="font-semibold text-text-primary">Prompt · {name}</h3><button onClick={() => void copy(value)} className="text-[10px] text-accent-blue">Copiar</button></div><pre className="whitespace-pre-wrap break-words text-[11px] text-text-secondary">{value}</pre></section> : null)}<section className="rounded-lg border border-border bg-bg-primary p-3"><div className="mb-2 flex items-center justify-between"><h3 className="font-semibold text-text-primary">JSON completo</h3><button onClick={() => void copy(raw)} className="text-[10px] text-accent-blue">Copiar JSON</button></div><pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words text-[10px] text-text-muted">{raw || 'Metadata no disponible'}</pre></section></div>}</div></div>
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="asset-extra-info-title"
onMouseDown={event => { if (event.target === event.currentTarget) onClose() }}
>
<div className="flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-bg-secondary shadow-2xl">
<header className="flex items-center justify-between border-b border-border p-3">
<div>
<h2 id="asset-extra-info-title" className="text-sm font-semibold text-text-primary">{tActivity('extraInfo')}</h2>
<p className="text-[10px] text-text-muted">{asset?.filename || tActivity('inspector.loadingAsset')}</p>
</div>
<button onClick={onClose} className="rounded px-2 py-1 text-xs text-text-muted hover:bg-bg-hover">{tCommon('actions.close')}</button>
</header>
{loading || !asset ? (
<div className="flex items-center justify-center gap-2 p-12 text-xs text-text-muted">
<Loader2 size={15} className="animate-spin" /> {tActivity('inspector.readingManifest')}
</div>
) : (
<div className="space-y-4 overflow-y-auto p-4 text-xs">
<InfoSection
title={tActivity('inspector.identity')}
values={{ asset_id: asset.id, kind: asset.kind, metadata_status: asset.metadata_status, locations: asset.workspace_ids.join(', ') }}
/>
<InfoSection
title={tActivity('inspector.origin')}
values={{ tool: asset.origin.tool, capability: asset.origin.capability, run_id: asset.execution.run_id, task_id: asset.execution.task_id, job_id: asset.execution.job_id, pipeline_id: asset.execution.pipeline_id, status: asset.execution.status }}
/>
<InfoSection
title={tActivity('inspector.modelTiming')}
values={{ provider: asset.model.provider, model: asset.model.id, created_at: timing.created_at, queued_at: timing.queued_at, started_at: timing.started_at, completed_at: timing.completed_at, queue_seconds: timing.queue_seconds, inference_seconds: timing.inference_seconds, total_seconds: timing.total_seconds }}
/>
{Object.entries(prompts).map(([name, value]) => typeof value === 'string' && value ? (
<section key={name} className="rounded-lg border border-border bg-bg-primary p-3">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{tActivity('inspector.prompt', { name })}</h3>
<button onClick={() => void copy(value)} className="text-[10px] text-accent-blue">{tActivity('inspector.copy')}</button>
</div>
<pre className="whitespace-pre-wrap break-words text-[11px] text-text-secondary">{value}</pre>
</section>
) : null)}
<section className="rounded-lg border border-border bg-bg-primary p-3">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{tActivity('inspector.fullJson')}</h3>
<button onClick={() => void copy(raw)} className="text-[10px] text-accent-blue">{tActivity('inspector.copyJson')}</button>
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words text-[10px] text-text-muted">{raw || tActivity('inspector.unavailable')}</pre>
</section>
</div>
)}
</div>
</div>
)
}

function InfoSection({ title, values }: { title: string; values: Record<string, unknown> }) {
return <section className="rounded-lg border border-border bg-bg-primary p-3"><h3 className="mb-2 font-semibold text-text-primary">{title}</h3><dl className="grid gap-1 sm:grid-cols-2">{Object.entries(values).map(([name, value]) => <div key={name} className="grid grid-cols-[7rem_1fr] gap-2"><dt className="text-text-muted">{name}</dt><dd className="break-all text-text-secondary">{value == null || value === '' ? 'No disponible' : String(value)}</dd></div>)}</dl></section>
const { t: tActivity } = useUiTranslation('activity')
return (
<section className="rounded-lg border border-border bg-bg-primary p-3">
<h3 className="mb-2 font-semibold text-text-primary">{title}</h3>
<dl className="grid gap-1 sm:grid-cols-2">
{Object.entries(values).map(([name, value]) => (
<div key={name} className="grid grid-cols-[7rem_1fr] gap-2">
<dt className="text-text-muted">{name}</dt>
<dd className="break-all text-text-secondary">{value == null || value === '' ? tActivity('inspector.unavailable') : String(value)}</dd>
</div>
))}
</dl>
</section>
)
}
13 changes: 13 additions & 0 deletions ui/src/i18n/locales/en/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
"openHistory": "Show canonical task history",
"inboxLegacy": "Inbox / Legacy",
"extraInfo": "Extra info",
"inspector": {
"loadingAsset": "Loading asset metadata…",
"readingManifest": "Reading manifest…",
"identity": "Identity",
"origin": "Origin and execution",
"modelTiming": "Model and timing",
"copy": "Copy",
"copyJson": "Copy JSON",
"fullJson": "Full JSON",
"unavailable": "Not available",
"loadFailed": "Could not load Extra info",
"prompt": "Prompt · {{name}}"
},
"collectionsHint": "Reference collections. They are not output folders.",
"projectCount_one": "{{count}} project",
"projectCount_other": "{{count}} projects",
Expand Down
13 changes: 13 additions & 0 deletions ui/src/i18n/locales/es/activity.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
"openHistory": "Mostrar el historial canónico de tareas",
"inboxLegacy": "Inbox / Legacy",
"extraInfo": "Información adicional",
"inspector": {
"loadingAsset": "Cargando metadata…",
"readingManifest": "Leyendo manifest…",
"identity": "Identidad",
"origin": "Origen y ejecución",
"modelTiming": "Modelo y tiempos",
"copy": "Copiar",
"copyJson": "Copiar JSON",
"fullJson": "JSON completo",
"unavailable": "No disponible",
"loadFailed": "No se pudo cargar Información adicional",
"prompt": "Prompt · {{name}}"
},
"collectionsHint": "Colecciones de referencias. No son carpetas de salida.",
"projectCount_one": "{{count}} proyecto",
"projectCount_other": "{{count}} proyectos",
Expand Down
6 changes: 5 additions & 1 deletion ui/tests/assetsCatalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ test('Assets is a first-class tab with its own panel, not a fake active workspac
assert.match(panel, /tActivity\('inboxLegacy'\)/)
assert.match(panel, /tActivity\('extraInfo'\)/)
assert.match(panel, /outputFolder\.uploads/)
assert.match(panel, /JSON completo/)
assert.match(panel, /tActivity\('inspector\.fullJson'\)/)
assert.match(panel, /tActivity\('inspector\.loadFailed'\)/)
assert.match(panel, /tCommon\('actions\.close'\)/)
assert.doesNotMatch(panel, /JSON completo/)
assert.doesNotMatch(panel, /No se pudo cargar Extra info/)
assert.doesNotMatch(panel, /setActiveWorkspace|switchWorkspace/)
})
37 changes: 37 additions & 0 deletions ui/tests/i18nFoundation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ test('required glossary keys exist in both languages', async () => {
assert.equal(i18n.t('entities.outputFolder', { ns: 'navigation', lng: 'es' }), 'Carpeta de salida')
assert.equal(i18n.t('outputFolder.uploads', { ns: 'navigation', lng: 'es' }), 'Subidas')
assert.equal(i18n.t('extraInfo', { ns: 'activity', lng: 'es' }), 'Información adicional')
assert.equal(i18n.t('inspector.loadFailed', { ns: 'activity', lng: 'en' }), 'Could not load Extra info')
assert.equal(i18n.t('inspector.loadFailed', { ns: 'activity', lng: 'es' }), 'No se pudo cargar Información adicional')
assert.equal(i18n.t('actions.close', { ns: 'common', lng: 'es' }), 'Cerrar')
})

test('missing keys fall back to english without throwing', async () => {
Expand Down Expand Up @@ -203,3 +206,37 @@ test('catalogs do not rename technical ids', async () => {
test('migrated chrome no longer hardcodes the pilot phrases', () => {
assert.deepEqual(forbiddenLiterals(), [])
})

test('Extra info chrome and the Assets inspector use the activity catalog', async () => {
const fs = await import('node:fs/promises')
const files = [
'../src/components/MainContent/VideoExtraInfoDialog.tsx',
'../src/components/MainContent/MediaFeedItem.tsx',
'../src/components/MainContent/VideoInfoBar.tsx',
'../src/features/assets/AssetsPanel.tsx',
]
for (const file of files) {
const source = await fs.readFile(new URL(file, import.meta.url), 'utf8')
assert.match(source, /t(?:Activity)?\('extraInfo'\)/, file)
assert.doesNotMatch(source, />Extra info</, file)
assert.doesNotMatch(source, /['"`]Extra info['"`]/, file)
assert.doesNotMatch(source, /^\s*Extra info\s*$/m, file)
}
const panel = await fs.readFile(new URL('../src/features/assets/AssetsPanel.tsx', import.meta.url), 'utf8')
for (const key of [
'inspector.loadingAsset',
'inspector.readingManifest',
'inspector.identity',
'inspector.origin',
'inspector.modelTiming',
'inspector.copy',
'inspector.copyJson',
'inspector.fullJson',
'inspector.unavailable',
'inspector.loadFailed',
'inspector.prompt',
]) {
assert.match(panel, new RegExp(`tActivity\\('${key.replace('.', '\\.')}'`), key)
}
assert.match(panel, /tCommon\('actions\.close'\)/)
})