experiment(ui): prototype two-level task navigation - #104
Conversation
…tion-architecture
PR Review — Loreframe StudioRisk: medium Automated review from Findings
Changed files
CONTRIBUTING checklist
Posted by the repo PR review workflow. Re-runs on each push to the PR. |
Code health
Markdown, JSON catalogs and tests are out of this table. Only Most complex functions
Trend vs baseline
Warnings
Ratchet passed. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 6 potential issues.
Bugbot Autofix prepared fixes for all 6 issues found in the latest run.
- ✅ Fixed: Style sheet and filters unreachable
- Restored Style Sheet, Scenes, Edits, and Multi-clip as media child-bar destinations so those still-implemented filters have a navigation path again.
- ✅ Fixed: Output folder menu is clipped
- Moved OutputFolderSelector out of the overflow-x-auto primary row so its absolute dropdown can paint below the nav without being clipped.
- ✅ Fixed: Navigation chrome ignores store changes
- Category highlight and expansion now follow mediaFilter and dashboardOpen changes from the footer, dashboard, and dialogs.
- ✅ Fixed: Settings button also opens sidebar
- hocuspocus:settings-open now closes the generation sidebar and opens Settings only, matching wizard navigate('settings').
- ✅ Fixed: Wizard steals keyboard focus
- The embedded wizard textarea no longer uses autoFocus, so first keystrokes stay in studio controls.
- ✅ Fixed: Embedded wizard stays announced modal
- The embedded rail now uses role=region without aria-modal so assistive tech no longer treats the rest of the app as inert.
Or push these changes by commenting:
@cursor push 074371ac92
Preview (074371ac92)
diff --git a/ui/src/components/MainContent/TabFilter.tsx b/ui/src/components/MainContent/TabFilter.tsx
--- a/ui/src/components/MainContent/TabFilter.tsx
+++ b/ui/src/components/MainContent/TabFilter.tsx
@@ -98,9 +98,20 @@
const topRowRef = useRef<HTMLDivElement>(null)
const childBarRef = useRef<HTMLDivElement>(null)
const magicTimerRef = useRef<number | null>(null)
- const initialCategory = categoryForMediaFilter(mediaFilter) || 'direct-generation'
+ const storeCategory = dashboardOpen
+ ? 'production' as const
+ : categoryForMediaFilter(mediaFilter)
+ ?? (sidebarMode === 'director' && sidebarOpen ? 'production' as const : null)
+ const destinationKey = `${mediaFilter}:${dashboardOpen}`
+ const initialCategory = storeCategory || 'direct-generation'
const [activeCategory, setActiveCategory] = useState<NavigationCategory | null>(initialCategory)
const [expandedCategory, setExpandedCategory] = useState<NavigationCategory | null>(initialCategory)
+ const [syncedDestination, setSyncedDestination] = useState(destinationKey)
+ if (syncedDestination !== destinationKey) {
+ setSyncedDestination(destinationKey)
+ setActiveCategory(storeCategory)
+ setExpandedCategory(storeCategory)
+ }
const language: UiLanguage = String(i18n.resolvedLanguage || i18n.language).startsWith('es') ? 'es' : 'en'
useEffect(() => {
@@ -219,6 +230,10 @@
{ value: 'series_episodes', label: t('tabs.episodes'), description: t('descriptions.mediaFilters'), icon: <Video size={15} />, action: () => openFilter('series_episodes') },
{ value: 'audio', label: t('tabs.audio'), description: t('descriptions.mediaFilters'), icon: <Activity size={15} />, action: () => openFilter('audio') },
{ value: 'model3d', label: t('tabs.model3d'), description: t('descriptions.mediaFilters'), icon: <Boxes size={15} />, action: () => openFilter('model3d') },
+ { value: 'scenes', label: t('tabs.scenes'), description: t('descriptions.mediaFilters'), icon: <MonitorPlay size={15} />, action: () => openFilter('scenes') },
+ { value: 'styles', label: t('tabs.styles'), description: t('descriptions.mediaFilters'), icon: <WandSparkles size={15} />, action: () => openFilter('styles') },
+ { value: 'avatars', label: t('tabs.edits'), description: t('descriptions.mediaFilters'), icon: <WandSparkles size={15} />, action: () => openFilter('avatars') },
+ { value: 'multiclip', label: t('tabs.multiclip'), description: t('descriptions.mediaFilters'), icon: <Clapperboard size={15} />, action: () => openFilter('multiclip') },
{ value: 'favorites', label: t('tabs.favorites'), description: t('descriptions.mediaFilters'), icon: <Sparkles size={15} />, action: () => openFilter('favorites') },
...(developerMode ? [{ value: 'auditdev' as const, label: t('tabs.auditDev'), description: t('descriptions.mediaFilters'), icon: <Activity size={15} />, action: () => openFilter('auditdev') }] : []),
]
@@ -252,15 +267,17 @@
return (
<nav aria-label={t('aria.sections')} className="flex min-w-0 flex-1 flex-col rounded-xl border border-border bg-bg-tertiary/70 p-1">
- <div ref={topRowRef} className="flex min-w-0 flex-nowrap items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
- <PrimaryButton active={activeCategory === 'direct-generation'} expanded={expandedCategory === 'direct-generation'} category="direct-generation" buttonRef={element => { categoryRefs.current['direct-generation'] = element }} icon={<Sparkles size={14} />} label={t('primary.directGeneration')} onClick={() => selectCategory('direct-generation')} />
- <PrimaryButton active={activeCategory === 'studios'} expanded={expandedCategory === 'studios'} category="studios" buttonRef={element => { categoryRefs.current.studios = element }} icon={<BookOpen size={14} />} label={t('primary.studios')} onClick={() => selectCategory('studios')} />
- <PrimaryButton active={activeCategory === 'production'} expanded={expandedCategory === 'production'} category="production" buttonRef={element => { categoryRefs.current.production = element }} icon={<Clapperboard size={14} />} label={t('primary.production')} onClick={() => selectCategory('production')} />
- <PrimaryButton active={activeCategory === 'media'} expanded={expandedCategory === 'media'} category="media" buttonRef={element => { categoryRefs.current.media = element }} icon={<Library size={14} />} label={t('primary.media')} onClick={() => selectCategory('media')} />
- <PrimaryButton active={mediaFilter === PRIMARY_DESTINATIONS.workspaces.value} icon={<FolderKanban size={14} />} label={t('tabs.workspaces')} onClick={() => openFilter(PRIMARY_DESTINATIONS.workspaces.value)} />
- <PrimaryButton active={mediaFilter === PRIMARY_DESTINATIONS.activity.value} icon={<Activity size={14} />} label={t('primary.activity')} ariaLabel={`${t('tabs.runs')} · ${t('primary.activity')}`} onClick={() => openFilter(PRIMARY_DESTINATIONS.activity.value)} />
+ <div className="flex min-w-0 items-center gap-1">
+ <div ref={topRowRef} className="flex min-w-0 flex-1 flex-nowrap items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
+ <PrimaryButton active={activeCategory === 'direct-generation'} expanded={expandedCategory === 'direct-generation'} category="direct-generation" buttonRef={element => { categoryRefs.current['direct-generation'] = element }} icon={<Sparkles size={14} />} label={t('primary.directGeneration')} onClick={() => selectCategory('direct-generation')} />
+ <PrimaryButton active={activeCategory === 'studios'} expanded={expandedCategory === 'studios'} category="studios" buttonRef={element => { categoryRefs.current.studios = element }} icon={<BookOpen size={14} />} label={t('primary.studios')} onClick={() => selectCategory('studios')} />
+ <PrimaryButton active={activeCategory === 'production'} expanded={expandedCategory === 'production'} category="production" buttonRef={element => { categoryRefs.current.production = element }} icon={<Clapperboard size={14} />} label={t('primary.production')} onClick={() => selectCategory('production')} />
+ <PrimaryButton active={activeCategory === 'media'} expanded={expandedCategory === 'media'} category="media" buttonRef={element => { categoryRefs.current.media = element }} icon={<Library size={14} />} label={t('primary.media')} onClick={() => selectCategory('media')} />
+ <PrimaryButton active={mediaFilter === PRIMARY_DESTINATIONS.workspaces.value} icon={<FolderKanban size={14} />} label={t('tabs.workspaces')} onClick={() => openFilter(PRIMARY_DESTINATIONS.workspaces.value)} />
+ <PrimaryButton active={mediaFilter === PRIMARY_DESTINATIONS.activity.value} icon={<Activity size={14} />} label={t('primary.activity')} ariaLabel={`${t('tabs.runs')} · ${t('primary.activity')}`} onClick={() => openFilter(PRIMARY_DESTINATIONS.activity.value)} />
+ </div>
- <div className="ml-auto flex items-center gap-1">
+ <div className="flex shrink-0 items-center gap-1">
<OutputFolderSelector />
{searchOpen ? (
<div className="flex items-center gap-1 rounded-lg border border-border bg-bg-secondary px-2 py-0.5">
diff --git a/ui/src/components/Sidebar/Sidebar.tsx b/ui/src/components/Sidebar/Sidebar.tsx
--- a/ui/src/components/Sidebar/Sidebar.tsx
+++ b/ui/src/components/Sidebar/Sidebar.tsx
@@ -95,8 +95,7 @@
setSidebarOpen(true)
}
const openSettings = () => {
- setToolsSidebarCollapsed(false)
- setSidebarOpen(true)
+ setSidebarOpen(false)
setSettingsOpen(true)
}
const openDirector = () => {
diff --git a/ui/src/features/agent/AgentAssistantPanel.tsx b/ui/src/features/agent/AgentAssistantPanel.tsx
--- a/ui/src/features/agent/AgentAssistantPanel.tsx
+++ b/ui/src/features/agent/AgentAssistantPanel.tsx
@@ -449,8 +449,8 @@
const panel = (
<section
- role="dialog"
- aria-modal="true"
+ role={embedded && !expanded ? 'region' : 'dialog'}
+ aria-modal={embedded && !expanded ? undefined : 'true'}
aria-label={t('title')}
data-expanded={expanded ? 'true' : 'false'}
className={`hp-agent-panel z-[100] flex flex-col overflow-hidden border border-amber-200/20 bg-[#0d0b13]/95 shadow-2xl backdrop-blur-xl ${expanded
@@ -579,7 +579,7 @@
<div className="flex items-end gap-2 rounded-xl border border-white/10 bg-black/25 p-2 focus-within:border-amber-200/35">
<Sparkles size={14} className="mb-1 shrink-0 text-amber-200/55" />
<textarea
- autoFocus
+ autoFocus={!embedded}
rows={2}
value={draft}
disabled={busy}
diff --git a/ui/tests/wizardPresentationContract.test.mjs b/ui/tests/wizardPresentationContract.test.mjs
--- a/ui/tests/wizardPresentationContract.test.mjs
+++ b/ui/tests/wizardPresentationContract.test.mjs
@@ -47,7 +47,8 @@
test('Wizard panel keeps dialog, live-region and pending-question ARIA contracts', async () => {
const panelSource = await readFile(new URL('../src/features/agent/AgentAssistantPanel.tsx', import.meta.url), 'utf8')
- assert.match(panelSource, /role="dialog"[\s\S]{0,160}aria-modal="true"/)
+ assert.match(panelSource, /role=\{embedded && !expanded \? 'region' : 'dialog'\}/)
+ assert.match(panelSource, /aria-modal=\{embedded && !expanded \? undefined : 'true'\}/)
assert.match(panelSource, /aria-live="polite"/)
assert.match(panelSource, /role="group" aria-label="Wizard pending question"/)
assert.match(panelSource, /aria-label=\{t\('title'\)\}/)You can send follow-ups to the cloud agent here.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
| { value: 'model3d', label: t('tabs.model3d'), description: t('descriptions.mediaFilters'), icon: <Boxes size={15} />, action: () => openFilter('model3d') }, | ||
| { value: 'favorites', label: t('tabs.favorites'), description: t('descriptions.mediaFilters'), icon: <Sparkles size={15} />, action: () => openFilter('favorites') }, | ||
| ...(developerMode ? [{ value: 'auditdev' as const, label: t('tabs.auditDev'), description: t('descriptions.mediaFilters'), icon: <Activity size={15} />, action: () => openFilter('auditdev') }] : []), | ||
| ] |
There was a problem hiding this comment.
Style sheet and filters unreachable
High Severity
The new child menus omit styles, scenes, avatars, and multiclip, so Style Sheet and those gallery filters have no navigation path. MainContent and galleryListQuery still handle them, and they remain in the MEDIA set, so this looks like an accidental drop rather than a removal.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
| className="text-text-muted hover:text-text-secondary"> | ||
| <X size={12} /> | ||
| <div className="ml-auto flex items-center gap-1"> | ||
| <OutputFolderSelector /> |
There was a problem hiding this comment.
Output folder menu is clipped
High Severity
OutputFolderSelector now lives inside the top nav row that uses overflow-x-auto. Its absolutely positioned dropdown extends below that short row, so the folder list is clipped and switching, creating, or deleting output folders becomes unusable.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
| const magicTimerRef = useRef<number | null>(null) | ||
| const initialCategory = categoryForMediaFilter(mediaFilter) || 'direct-generation' | ||
| const [activeCategory, setActiveCategory] = useState<NavigationCategory | null>(initialCategory) | ||
| const [expandedCategory, setExpandedCategory] = useState<NavigationCategory | null>(initialCategory) |
There was a problem hiding this comment.
Navigation chrome ignores store changes
Medium Severity
activeCategory and expandedCategory are local state and never follow mediaFilter unless the user clicks TabFilter or a wizard event fires. Footer, dashboard, and dialog navigations leave the previous category highlighted and expanded beside the real destination.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
| setToolsSidebarCollapsed(false) | ||
| setSidebarOpen(true) | ||
| setSettingsOpen(true) | ||
| } |
There was a problem hiding this comment.
Settings button also opens sidebar
Medium Severity
The nav Settings control dispatches hocuspocus:settings-open, and that handler expands the studio tools and sets sidebarOpen as well as opening the settings drawer. Users asking for Settings also get the generation sidebar, which is the opposite of wizard navigate('settings').
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
| </section> | ||
| ) | ||
|
|
||
| if (embedded && !expanded) return panel |
There was a problem hiding this comment.
Wizard steals keyboard focus
Medium Severity
The wizard column now mounts on load (expanded unless localStorage says otherwise) and the chat textarea still has autoFocus. Focus lands in Ask to the Wizard instead of the generation prompt, so the first keystrokes go to the assistant.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
| data-expanded={expanded ? 'true' : 'false'} | ||
| className={`hp-agent-panel z-[100] flex flex-col overflow-hidden border border-amber-200/20 bg-[#0d0b13]/95 shadow-2xl backdrop-blur-xl ${expanded | ||
| ? 'hp-agent-panel--expanded fixed inset-0 rounded-none text-sm sm:inset-2 sm:rounded-2xl' | ||
| : 'fixed bottom-12 left-2 h-[min(34rem,calc(100vh-5rem))] w-[min(25rem,calc(100vw-1rem))] rounded-2xl text-xs'}`} |
There was a problem hiding this comment.
Embedded wizard stays announced modal
Medium Severity
The embedded sidebar still uses role="dialog" and aria-modal="true". Assistive tech treats the rest of the app as inert even though generation, navigation, and the gallery remain interactive beside the wizard.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 630ec50. Configure here.
…tion-architecture # Conflicts: # ui/src/components/MainContent/MainContent.tsx
|
He mezclado el main actualizado y he resuelto los seis hallazgos. La sincronización de navegación usa una suscripción al store y preserva intencionadamente Generación directa cuando esta cambia la biblioteca asociada; así Imagen abre Studio Imagen + Media Imágenes sin que el resaltado salte a Media. También se añadieron pruebas para los destinos restaurados, el desplegable sin clipping, cambios externos y el contrato accesible. Validación local: npm run check, 509/509 tests, i18n, lint, build y budget correctos. @cursor review |
… audit chrome Move leftover hardcoded Spanish/English visible copy into EN+ES catalogs (director spoken/T2V, videoEditor transitions and time cards, production runs and Workspace collections, style sheet, projects, internal audit). Retarget tests and E2E so Story Lab is opened through Studios, not as a top-level tab.
TRANSITIONS stored catalog keys as string, so t(option.labelKey) was not a videoEditor key.



Resumen
Validación
Es una rama experimental de UI; el árbol de trabajo usado por Grok no se ha modificado.
Note
Medium Risk
Broad navigation and layout changes affect how users reach every major area; output-folder and studio/director routing moved but still use existing store APIs, with no backend or auth changes.
Overview
This PR restructures the main shell around a new left Wizard column and a category-based top nav, instead of one long tab strip plus agent UI in the activity footer.
Navigation replaces the horizontal scroll tabs with four primary categories (Direct generation, Studios, Production, Media), each opening a second row of destinations with category-colored styling and a visual “notch” aligned to the active parent. Direct-generation picks now set
generationMode, open studio viahocuspocus:studio-open, and drop the in-sidebar mode strip. Workspaces, Activity (Runs), output folder, search, quick EN/ES, and Settings live in the nav bar; the main content header no longer shows folder picker or output counts.Ask to the Wizard moves into new
WizardSidebar(collapsible, mobile overlay) withAgentAssistantPanelembeddedmode.ActivityFooteronly handles task history; it publishes canonical tasks throughcanonicalTaskFeedso the wizard shares the same task list.Studio sidebar loses the Director/Studio toggle and header settings; collapse is generalized for all tool modes, titles reflect direct generation vs Director, and
SettingsDrawerslides in from the right. Agent navigation actions callannounceWizardNavigationso the matching category expands and gets the wizard highlight animation.i18n, CSS, tests, and optional
HOCUSPOCUS_API_TARGETfor the Vite proxy are updated to match.Reviewed by Cursor Bugbot for commit 630ec50. Configure here.