diff --git a/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx index eeaa9827..b4f32f0f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Calendar/CalendarScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -13,38 +12,16 @@ import { defaultLayout } from './defaultLayout'; // resolves to the first collection inside the panels rather than by redirecting, because until the // collection list has loaded there is no canonical URL to redirect to. -const ALLOWED_APP_TYPES = new Set(['calendar-nav', 'calendar-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'calendar-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const CalendarScreen = () => { - const rawWorkspace = useDashboardState('screens/calendar', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/calendar', defaultLayout); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 76d03218..eae863fa 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; import type { LayoutNode, SelectedSession } from 'officerdev'; import { WorkspaceView, chatListPath, cwdFromSplat } from 'officerdev'; @@ -11,28 +11,9 @@ import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; import { usePanelChannel } from 'hooks/usePanelChannel'; import { defaultLayout } from './defaultLayout'; -// Allowed panel app types for the /chat screen -const ALLOWED_APP_TYPES = new Set(['chat-session-list', 'chat-detail', null]); - // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. const CHAT_TAIL = 20; -/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */ -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - if (!ALLOWED_APP_TYPES.has(node.appType)) { - return { ...node, appType: 'chat-detail' }; - } - return node; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - type SessionListPageProps = { isNew?: boolean; }; @@ -45,25 +26,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { const client = useClient(); const selectedRef = useRef(selected); selectedRef.current = selected; - const rawWorkspace = useDashboardState('screens/chat', defaultLayout); + // A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no + // longer resolves; `appTypes` lands anything unknown on the detail panel. + const workspace = useDashboardState('screens/chat', defaultLayout); const isMobile = useIsMobile(); const navigate = useNavigate(); const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined; - // Normalize synchronously so the wrong panel never renders - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - // Persist the fix to the backend - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); - // Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests // the address bar verbatim — so one left over from before the path-based groups sits there forever, // looking like it means something. On a bare /chat it still says which group you wanted, so upgrade @@ -133,6 +102,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { { // Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL; diff --git a/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx index 393519ab..03ad1367 100644 --- a/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Contacts/ContactsScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -7,38 +6,16 @@ import { defaultLayout } from './defaultLayout'; // /contacts — the CardDAV half of the same sidecar as /calendar; see CalendarScreen for why the selected // collection is a query param rather than a route segment. -const ALLOWED_APP_TYPES = new Set(['contacts-nav', 'contacts-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'contacts-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const ContactsScreen = () => { - const rawWorkspace = useDashboardState('screens/contacts', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/contacts', defaultLayout); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx index 7dc9e895..07949de5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Gitea/GiteaScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection } from 'officerdev'; @@ -13,35 +12,9 @@ import { defaultLayout } from './defaultLayout'; // between themselves over a channel. This screen backs both /gitea and /gitea/:section and is the single // place that decides what an absent or bogus section means. -const ALLOWED_APP_TYPES = new Set(['gitea-nav', 'gitea-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'gitea-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const GiteaScreen = () => { const { section, owner, name } = useParams(); - const rawWorkspace = useDashboardState('screens/gitea', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/gitea', defaultLayout); // Bare /gitea, or a section that doesn't exist, resolves to a canonical URL rather than rendering a default // while the address bar says something else — the nav highlight is derived from the URL. @@ -55,7 +28,11 @@ export const GiteaScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx index 627dbca9..9ac2fd16 100644 --- a/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Headscale/HeadscaleScreen.tsx @@ -15,20 +15,6 @@ import { defaultLayout } from './defaultLayout'; // between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the // single place that decides what an absent or bogus section means. -const ALLOWED_APP_TYPES = new Set(['headscale-servers', 'headscale-nav', 'headscale-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'headscale-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - function hasAppType(node: LayoutNode, appType: string): boolean { if (node.type === 'panel') return node.appType === appType; return node.children.some((c) => hasAppType(c.node, appType)); @@ -38,15 +24,13 @@ export const HeadscaleScreen = () => { const { section } = useParams(); const rawWorkspace = useDashboardState('screens/headscale', defaultLayout); + // A layout saved before the server picker existed has no panel for it, and nothing else would ever add + // one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is + // cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is + // the part the framework can't do, because it is about a panel that is *missing* rather than wrong. const workspace = useMemo(() => { - // A layout saved before the server picker existed has no panel for it, and nothing else would ever add - // one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is - // cheaper than a screen permanently missing a panel. - const fixed = hasAppType(rawWorkspace.value, 'headscale-servers') - ? normalizeLayout(rawWorkspace.value) - : defaultLayout; - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; + if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace; + return { ...rawWorkspace, value: defaultLayout }; }, [rawWorkspace]); useEffect(() => { @@ -64,7 +48,14 @@ export const HeadscaleScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx index cc65cf82..f1f24aa0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Invoices/InvoicesScreen.tsx @@ -1,5 +1,4 @@ import type { LayoutNode } from 'officerdev'; -import { useEffect, useMemo } from 'react'; import { Navigate, useParams } from 'react-router'; import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -14,35 +13,9 @@ import { defaultLayout } from './defaultLayout'; // params. So the panels read the URL rather than passing state to each other, and this screen is the single // place that decides what an absent or bogus section means. -const ALLOWED_APP_TYPES = new Set(['invoices-nav', 'invoices-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'invoices-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const InvoicesScreen = () => { const { section } = useParams(); - const rawWorkspace = useDashboardState('screens/invoices', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/invoices', defaultLayout); // Bare /invoices, or a section that doesn't exist, resolves to a canonical URL rather than rendering a // default while the address bar says something else — the nav highlight is derived from the URL. @@ -52,7 +25,11 @@ export const InvoicesScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx index 6f39e483..21796454 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jellyfin/JellyfinScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView, DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from 'officerdev'; @@ -16,35 +15,9 @@ import { defaultLayout } from './defaultLayout'; // The open section is :section in the URL; which library, which item and what is playing are in the query // string. Nothing about "what is open" lives in a panel channel. -const ALLOWED_APP_TYPES = new Set(['jellyfin-nav', 'jellyfin-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'jellyfin-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const JellyfinScreen = () => { const { section } = useParams(); - const rawWorkspace = useDashboardState('screens/jellyfin', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/jellyfin', defaultLayout); // Bare /jellyfin, or a section that doesn't exist, canonicalises rather than rendering a default behind a // URL that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing. @@ -54,7 +27,11 @@ export const JellyfinScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx index 61e89c71..cfe4300e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Music/MusicScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -7,38 +6,16 @@ import { defaultLayout } from './defaultLayout'; // /music uses the Workspace/Panel system (like /chat): two vertical panels — the library browser // (music-browser) and the content/detail (music-detail) — coordinating via the 'music:cwd' channel. -const ALLOWED_APP_TYPES = new Set(['music-browser', 'music-detail', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'music-detail' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const MusicScreen = () => { - const rawWorkspace = useDashboardState('screens/music', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/music', defaultLayout); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx index 3b618fb5..9ca5731f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Photos/PhotosScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView, DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from 'officerdev'; @@ -12,35 +11,9 @@ import { defaultLayout } from './defaultLayout'; // The open section is :section in the URL; deeper selection (which asset, album, person, search) is in the // query string. Nothing about "what is open" lives in a panel channel. -const ALLOWED_APP_TYPES = new Set(['photos-nav', 'photos-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'photos-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const PhotosScreen = () => { const { section } = useParams(); - const rawWorkspace = useDashboardState('screens/photos', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/photos', defaultLayout); // Bare /photos, or a section that doesn't exist, canonicalises rather than rendering a default behind a URL // that names something else — the nav highlight comes from the router, so a bogus URL highlights nothing. @@ -50,7 +23,11 @@ export const PhotosScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/QrTransfer/QrTransferScreen.tsx b/src/apps/officer-web/Screens/Dashboard/QrTransfer/QrTransferScreen.tsx index 68112e88..de8d01aa 100644 --- a/src/apps/officer-web/Screens/Dashboard/QrTransfer/QrTransferScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/QrTransfer/QrTransferScreen.tsx @@ -1,4 +1,3 @@ -import { useMemo } from 'react'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -14,27 +13,14 @@ import { defaultLayout } from './defaultLayout'; // Both panels are pure client-side: nothing about a transfer reaches the server, which is the point. // The receiver needs a secure origin for camera access — over the tailnet with HTTPS that is satisfied. -const ALLOWED_APP_TYPES = new Set(['qr-send', 'qr-receive', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'qr-send' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - return children.some((c, i) => c !== node.children[i]) ? { ...node, children } : node; -} - export const QrTransferScreen = () => { - const rawWorkspace = useDashboardState('screens/qr-transfer', defaultLayout); + const workspace = useDashboardState('screens/qr-transfer', defaultLayout); - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - return ; + return ( + + ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx index a2c44c30..22748f88 100644 --- a/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Soulseek/SoulseekScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -8,40 +7,18 @@ import { defaultLayout } from './defaultLayout'; // the left and a section view (soulseek-view) on the right, coordinating via the 'soulseek:section' // channel. Both talk to slskd through the /api/slskd auth proxy. -const ALLOWED_APP_TYPES = new Set(['soulseek-nav', 'soulseek-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'soulseek-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const SoulseekScreen = () => { // v2: nav + view (replaced the earlier search + transfers split) — new key so the old persisted // layout doesn't resurrect as two mismatched panels. - const rawWorkspace = useDashboardState('screens/soulseek-v2', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/soulseek-v2', defaultLayout); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx index 0a367ce9..2df681f9 100644 --- a/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/SystemMonitor/SystemMonitorScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView } from 'officerdev'; import { useDashboardState } from 'state/useDashboardState'; @@ -7,38 +6,16 @@ import { defaultLayout } from './defaultLayout'; // /system-monitor uses the Workspace/Panel system (like /music): a horizontal split with an (empty for // now) left panel and the system snapshot on the right. -const ALLOWED_APP_TYPES = new Set(['monitor-side', 'monitor-main', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'monitor-main' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const SystemMonitorScreen = () => { - const rawWorkspace = useDashboardState('screens/system-monitor', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/system-monitor', defaultLayout); return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx index 3d34edf0..bf8b9c1d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Transmission/TransmissionScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import { Navigate, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; import { @@ -19,35 +18,9 @@ import { defaultLayout } from './defaultLayout'; // backs both /transmission and /transmission/:section and is the single place that decides what an absent or // bogus section means. -const ALLOWED_APP_TYPES = new Set(['transmission-nav', 'transmission-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'transmission-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const TransmissionScreen = () => { const { section } = useParams(); - const rawWorkspace = useDashboardState('screens/transmission', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/transmission', defaultLayout); // Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a // default while the address bar says something else — the nav highlight is derived from the URL. @@ -57,7 +30,11 @@ export const TransmissionScreen = () => { return (
- +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx index 2ebb9338..d8edc5ef 100644 --- a/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Wallet/WalletScreen.tsx @@ -1,4 +1,3 @@ -import { useEffect, useMemo } from 'react'; import { Navigate, useLocation, useParams } from 'react-router'; import type { LayoutNode } from 'officerdev'; import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSection } from 'officerdev'; @@ -15,36 +14,10 @@ import { defaultLayout } from './defaultLayout'; // themselves over a channel. This screen backs both /wallet and /wallet/:section and is the single place // that decides what an absent or bogus section means. -const ALLOWED_APP_TYPES = new Set(['wallet-nav', 'wallet-view', null]); - -function normalizeLayout(node: LayoutNode): LayoutNode { - if (node.type === 'panel') { - return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'wallet-view' }; - } - const children = node.children.map((c) => { - const fixed = normalizeLayout(c.node); - return fixed === c.node ? c : { ...c, node: fixed }; - }); - const changed = children.some((c, i) => c !== node.children[i]); - return changed ? { ...node, children } : node; -} - export const WalletScreen = () => { const { section } = useParams(); const { search } = useLocation(); - const rawWorkspace = useDashboardState('screens/wallet', defaultLayout); - - const workspace = useMemo(() => { - const fixed = normalizeLayout(rawWorkspace.value); - if (fixed === rawWorkspace.value) return rawWorkspace; - return { ...rawWorkspace, value: fixed }; - }, [rawWorkspace]); - - useEffect(() => { - if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) { - rawWorkspace.setValue(workspace.value); - } - }, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]); + const workspace = useDashboardState('screens/wallet', defaultLayout); // Bare /wallet, or a section that doesn't exist, resolves to a canonical URL rather than rendering a // default while the address bar says something else — the nav highlight is derived from the URL. The @@ -56,7 +29,11 @@ export const WalletScreen = () => { return (
- +
); }; diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx index 9425357a..d4838ae7 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx @@ -5,7 +5,7 @@ import { useIsMobile } from 'hooks/useIsMobile'; import { useSessionState } from 'hooks/useSessionState'; import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types'; import type { DropPosition } from './layout-utils'; -import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs, findPanelApp } from './layout-utils'; +import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs, findPanelApp, normalizeLayout } from './layout-utils'; import { WorkspaceProvider } from './WorkspaceContext'; import { parseWorkspaceKey } from './workspace-identity'; import { WorkspaceRenderer } from './WorkspaceRenderer'; @@ -14,6 +14,17 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry'; type WorkspaceViewProps = { workspace: DashboardState; locked?: boolean; + /** + * Pin every panel to one of these app types, replacing anything else with `fallback` and writing the + * repair back. A locked screen should always pass this: its layout is persisted user state that + * outlives the code, and an appType the screen no longer renders becomes an empty box the user cannot + * get out of, because a locked screen has no app picker. + * + * This used to be fourteen character-identical `normalizeLayout` functions in fourteen screens, each + * with its own `useMemo` and persist-back `useEffect` — which is how nine screens ended up with no + * guard at all and one with a guard that re-ran forever because it never wrote the correction back. + */ + appTypes?: { allowed: readonly string[]; fallback: string }; cwd?: string; root?: string; components?: PanelComponents; @@ -24,12 +35,24 @@ type WorkspaceViewProps = { const noop = () => {}; -export const WorkspaceView = ({ workspace, locked, cwd = '~', root, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { +export const WorkspaceView = ({ workspace, locked, appTypes, cwd = '~', root, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { const { registry } = useAppRegistry(); const isMobile = useIsMobile(); - const layout = workspace.value; + // Deliberately not memoised. `normalizeLayout` returns its input by reference when nothing needed + // fixing, so the identity everything downstream depends on is already stable; a `useMemo` here would + // only add a dependency array to get wrong, and the callers pass a fresh object literal for `appTypes` + // anyway, which would defeat it. The walk is a handful of nodes. + const layout = appTypes ? normalizeLayout(workspace.value, appTypes.allowed, appTypes.fallback) : workspace.value; const onLayoutChange = workspace.setValue; + + // Write the repair back, so a layout is fixed once rather than re-fixed on every mount for the rest of + // its life. Terminates because the normalised tree normalises to itself. + const rawLayout = workspace.value; + useEffect(() => { + if (!workspace.isLoaded || layout === rawLayout) return; + onLayoutChange(layout); + }, [workspace.isLoaded, layout, rawLayout, onLayoutChange]); const [swapSourceId, setSwapSourceId] = useState(null); const [dragSourceId, setDragSourceId] = useState(null); // Per tab and per dashboard: maximising the chat panel and refreshing should come back maximised, while diff --git a/src/workspaces/officerdev/src/components/Workspace/index.ts b/src/workspaces/officerdev/src/components/Workspace/index.ts index e29d1fe3..2693b69c 100644 --- a/src/workspaces/officerdev/src/components/Workspace/index.ts +++ b/src/workspaces/officerdev/src/components/Workspace/index.ts @@ -27,6 +27,7 @@ export { setPanelConfig, collectPanelConfigs, findPanelApp, + normalizeLayout, } from './layout-utils'; export type { WorkspaceIdentity } from './workspace-identity'; export { parseWorkspaceKey } from './workspace-identity'; diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts b/src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts index 69ced410..35d0b535 100644 --- a/src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts +++ b/src/workspaces/officerdev/src/components/Workspace/layout-utils.test.ts @@ -7,6 +7,7 @@ import { findPanelApp, hasAnyApp, movePanel, + normalizeLayout, pruneEmptyPanels, removePanel, setApp, @@ -548,3 +549,67 @@ describe('findPanelApp', () => { expect(findPanelApp(panel('only', 'chat'), 'only')).toBe('chat'); }); }); + +// The guard that keeps a locked screen renderable. It runs against persisted state that outlives the code +// that wrote it, so the reference-equality contract is the load-bearing part: it is what tells the caller +// whether there is a repair worth writing back, and a normaliser that always returned a new tree would +// PATCH the server on every mount of every screen. +describe('normalizeLayout', () => { + const ALLOWED = ['nav', 'view']; + + test('returns the same tree by reference when nothing needs fixing', () => { + const root = group('g', 'horizontal', [panel('p1', 'nav'), panel('p2', 'view')]); + expect(normalizeLayout(root, ALLOWED, 'view')).toBe(root); + }); + + test('leaves empty panels alone — null is always allowed', () => { + const root = group('g', 'horizontal', [panel('p1'), panel('p2', 'view')]); + expect(normalizeLayout(root, ALLOWED, 'view')).toBe(root); + }); + + test('replaces an app the screen does not know with the fallback', () => { + const root = group('g', 'horizontal', [panel('p1', 'renamed-away'), panel('p2', 'view')]); + + const next = normalizeLayout(root, ALLOWED, 'view'); + + expect(next).not.toBe(root); + expect(find(next, 'p1')!.appType).toBe('view'); + // The panel keeps its id and its position; only the app it names changes. + expect(ids(next)).toEqual(['p1', 'p2']); + }); + + test('drops the config with the app that owned it', () => { + // The same rule `setApp` follows: a config belongs to whichever app wrote it, and the fallback would + // read the previous occupant's settings as its own. + const root = group('g', 'horizontal', [panel('p1', 'gone', { config: { agentName: 'frontend' } })]); + + const fixed = find(normalizeLayout(root, ALLOWED, 'view'), 'p1')!; + + expect(fixed.appType).toBe('view'); + expect('config' in fixed).toBe(false); + }); + + test('fixes nested panels and rebuilds only the branches that changed', () => { + const clean = group('h', 'vertical', [panel('p1', 'nav'), panel('p2', 'view')]); + const root = group('g', 'horizontal', [clean, group('k', 'vertical', [panel('p3', 'stale')])]); + + const next = normalizeLayout(root, ALLOWED, 'view') as LayoutGroup; + + expect(find(next, 'p3')!.appType).toBe('view'); + // The untouched branch is carried through by reference, which is what keeps its panels mounted. + expect(next.children[0]!.node).toBe(clean); + }); + + test('a normalised tree normalises to itself — the persist-back cannot loop', () => { + const root = group('g', 'horizontal', [panel('p1', 'stale'), panel('p2', 'view')]); + + const once = normalizeLayout(root, ALLOWED, 'view'); + expect(normalizeLayout(once, ALLOWED, 'view')).toBe(once); + }); + + test('handles a bare panel as the whole tree', () => { + expect((normalizeLayout(panel('only', 'stale'), ALLOWED, 'view') as LayoutPanel).appType).toBe('view'); + const ok = panel('only', 'nav'); + expect(normalizeLayout(ok, ALLOWED, 'view')).toBe(ok); + }); +}); diff --git a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts index 40196c86..4fcfe517 100644 --- a/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts +++ b/src/workspaces/officerdev/src/components/Workspace/layout-utils.ts @@ -132,6 +132,36 @@ export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]): return { ...root, children: newChildren }; } +/** + * Pin every panel in a tree to an app the screen actually knows how to render. + * + * A locked screen's layout is persisted user state, so it outlives the code that wrote it: rename an + * appType, drop a panel from a screen, restore an old row, and the stored tree still names something the + * screen has no answer for. `PanelSlot` renders that as an empty bordered box with no picker — and on a + * locked screen there is no way for the user to get out of it. + * + * Returns the input by reference when nothing needed fixing, which is what lets the caller tell "this + * layout was already fine" from "this layout was repaired and should be written back". + * + * `null` — an empty panel — is always allowed. It is a state of the framework rather than an app, every + * hand-rolled copy of this allowed it, and a screen that forgot to would rewrite each of its empty panels + * to the fallback on first load. + */ +export function normalizeLayout(node: LayoutNode, allowed: readonly string[], fallback: string): LayoutNode { + if (node.type === 'panel') { + if (node.appType === null || allowed.includes(node.appType)) return node; + // The config goes with the app that owned it. Whatever is in there was written by the app this panel + // is no longer running, and handing it to the fallback is the same mistake `setApp` avoids. + const { config: _drop, ...rest } = node; + return { ...rest, appType: fallback }; + } + const children = node.children.map((child) => { + const fixed = normalizeLayout(child.node, allowed, fallback); + return fixed === child.node ? child : { ...child, node: fixed }; + }); + return children.some((c, i) => c !== node.children[i]) ? { ...node, children } : node; +} + export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null { if (root.type === 'panel') { return root.appType ? root : null;