diff --git a/apps/desktop/src/renderer/src/app.tsx b/apps/desktop/src/renderer/src/app.tsx index 09c2f6aff..d9cf11f69 100644 --- a/apps/desktop/src/renderer/src/app.tsx +++ b/apps/desktop/src/renderer/src/app.tsx @@ -12,6 +12,7 @@ import { } from '@linkcode/workbench'; import { useEffect } from 'foxact/use-abortable-effect'; import { useState } from 'react'; +import useSWRImmutable from 'swr/immutable'; import { DesktopAutomationsView } from './automations/automations-view'; import { cloudDataBridge } from './cloud-auth/bridges'; import { desktopDaemonConnectionSource } from './daemon-connection-source'; @@ -109,15 +110,12 @@ function DesktopConnectionFallback(): React.ReactNode { /** Whether main supervises the daemon (packaged, no override) — picks the failure copy. */ function useDaemonIsManaged(): boolean { + // Managed-ness only moves with the override, so the override is the cache key: changing it + // re-fetches, and everything else serves the cached answer. const daemonUrlOverride = useDesktopSettingsStore((state) => state.daemonUrlOverride); - const [managed, setManaged] = useState(false); - useEffect( - (signal) => { - void systemBridge.daemon.isManaged().then((value) => { - if (!signal.aborted) setManaged(value); - }); - }, - [daemonUrlOverride], + const { data: managed = false } = useSWRImmutable( + ['desktop:daemon-managed', daemonUrlOverride], + () => systemBridge.daemon.isManaged(), ); return managed; } diff --git a/apps/desktop/src/renderer/src/settings/about-tab.tsx b/apps/desktop/src/renderer/src/settings/about-tab.tsx index 33f692db9..503d3222b 100644 --- a/apps/desktop/src/renderer/src/settings/about-tab.tsx +++ b/apps/desktop/src/renderer/src/settings/about-tab.tsx @@ -2,8 +2,7 @@ import type { UpdaterStatus } from '@linkcode/ipc'; import { Button } from 'coss-ui/components/button'; import { Field, FieldLabel } from 'coss-ui/components/field'; import { Progress, ProgressIndicator, ProgressTrack } from 'coss-ui/components/progress'; -import { useEffect } from 'foxact/use-abortable-effect'; -import { useState } from 'react'; +import useSWRImmutable from 'swr/immutable'; import { useTranslations } from 'use-intl'; import { systemBridge } from '../ipc'; import { useUpdaterState } from '../updater'; @@ -19,15 +18,12 @@ const STATUS_KEYS = { export function AboutTab(): React.ReactNode { const t = useTranslations('settings.about'); - const [version, setVersion] = useState(''); + // The app version is constant for the process lifetime — fetch once, cache forever. + const { data: version } = useSWRImmutable('desktop:app-version', () => + systemBridge.app.version(), + ); const { progress, status } = useUpdaterState(); - useEffect((signal) => { - void systemBridge.app.version().then((value) => { - if (!signal.aborted) setVersion(value); - }); - }, []); - const statusKey = status === 'idle' ? null : STATUS_KEYS[status]; const progressPercent = progress === null ? null : Math.round(progress); diff --git a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx index 15348edff..68addd34a 100644 --- a/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx +++ b/apps/desktop/src/renderer/src/shell/browser/browser-webview-pane.tsx @@ -4,13 +4,13 @@ import type { BrowserFindState } from '@linkcode/ui/shell/browser'; import { BrowserPane } from '@linkcode/ui/shell/browser'; import type { WebviewTag } from 'electron'; import { useLayoutEffect } from 'foxact/use-isomorphic-layout-effect'; -import { useSingleton } from 'foxact/use-singleton'; import { noop } from 'foxts/noop'; -import { useEffectEvent, useRef, useState } from 'react'; +import { useCallback, useEffectEvent, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'use-intl'; import { useDesktopShellStore } from '../store/store'; import { advanceBrowserWebviewGeneration, + isBrowserWebviewReady, markBrowserWebviewReady, markBrowserWebviewUnready, registerBrowserWebview, @@ -19,21 +19,38 @@ import { /** All in-app pages share one persisted session (cookies/storage survive restarts). */ const BROWSER_PARTITION = 'persist:linkcode-browser'; -interface WebviewNavState { - isLoading: boolean; - canGoBack: boolean; - canGoForward: boolean; - failure: string | null; - guestReady: boolean; +/** Guest events after which the nav/readiness snapshots must be re-read. */ +const GUEST_NAV_EVENTS = [ + 'did-start-loading', + 'did-stop-loading', + 'dom-ready', + 'did-start-navigation', + 'did-navigate', + 'did-navigate-in-page', +] as const; + +/** An absent or still-attaching guest (methods throw until dom-ready) reads as idle. */ +function readGuest(webview: WebviewTag | null, read: (view: WebviewTag) => boolean): boolean { + if (webview === null) return false; + try { + return read(webview); + } catch { + return false; + } } -const IDLE_NAV: WebviewNavState = { - isLoading: false, - canGoBack: false, - canGoForward: false, - failure: null, - guestReady: false, -}; +/** Forward every nav-affecting guest event to `onStoreChange`; returns the detach. */ +function subscribeGuestNav(webview: WebviewTag | null, onStoreChange: () => void): () => void { + if (webview === null) return noop; + for (let i = 0, len = GUEST_NAV_EVENTS.length; i < len; i++) { + webview.addEventListener(GUEST_NAV_EVENTS[i], onStoreChange); + } + return () => { + for (let i = 0, len = GUEST_NAV_EVENTS.length; i < len; i++) { + webview.removeEventListener(GUEST_NAV_EVENTS[i], onStoreChange); + } + }; +} function whenNotLocal(event: KeyboardEvent): boolean { return !isKeyboardShortcutLocalTarget(event.target); @@ -92,15 +109,12 @@ export function BrowserWebviewPane({ ); const rootRef = useRef(null); const [webview, setWebview] = useState(null); - const [nav, setNav] = useState(IDLE_NAV); - const guestReady = webview !== null && nav.guestReady; - const [find, setFind] = useState(null); // React's built-in `webview` intrinsic types the element as a bare HTMLWebViewElement; // in Electron (webviewTag enabled) the live element is always the full WebviewTag. - const { current: captureWebview } = useSingleton(() => (element: HTMLWebViewElement | null) => { - setWebview(element as WebviewTag | null); - setNav((prev) => (prev.guestReady ? { ...prev, guestReady: false } : prev)); - }); + const captureWebview = useCallback( + (element: HTMLWebViewElement | null) => setWebview(element as WebviewTag | null), + [], + ); useLayoutEffect(() => { if (webview === null) return; @@ -108,52 +122,30 @@ export function BrowserWebviewPane({ return () => registerBrowserWebview(tabId, null); }, [tabId, webview]); + const [failureError, setFailureError] = useState(null); + const [find, setFind] = useState(null); + const syncDocumentState = useEffectEvent((currentUrl: string, currentTitle: string) => { if (currentUrl.length > 0) setBrowserTabUrl(tabId, currentUrl); if (currentTitle.length > 0) setBrowserTabTitle(tabId, currentTitle); }); + // Command side: guest events drive the registry marks, the shell store's url/title, and the + // event-payload-only states (load failure, find matches) that no webview getter can serve. useLayoutEffect(() => { if (webview === null) return; - let ready = false; - const sync = (): void => { - if (!ready) return; - setNav((prev) => ({ - ...prev, - isLoading: webview.isLoading(), - canGoBack: webview.canGoBack(), - canGoForward: webview.canGoForward(), - })); - }; const syncDocument = (): void => { - ready = true; markBrowserWebviewReady(tabId); - /* `nav` mirrors an external event target (the guest webview). This setter normally runs - * from webview events, but the effect also invokes it once synchronously via the - * post-subscribe probe below, closing the race where a cached page finished loading - * before the listeners attached. There is no earlier call site: the webview element - * itself arrives via state, so the probe must live in the effect that subscribes. */ - // eslint-disable-next-line vibe-proof/react-no-use-effect-watching -- see above - setNav((prev) => ({ - ...prev, - isLoading: webview.isLoading(), - canGoBack: webview.canGoBack(), - canGoForward: webview.canGoForward(), - guestReady: true, - })); syncDocumentState(webview.getURL(), webview.getTitle()); }; const onNavigate = (event: Electron.DidNavigateEvent): void => { advanceBrowserWebviewGeneration(tabId); syncDocumentState(event.url, ''); - setNav((prev) => ({ ...prev, failure: null })); - sync(); + setFailureError(null); }; const onStartNavigation = (event: Electron.DidStartNavigationEvent): void => { if (!event.isMainFrame || event.isInPlace) return; - ready = false; markBrowserWebviewUnready(tabId); - setNav((prev) => (prev.guestReady ? { ...prev, guestReady: false } : prev)); }; const onTitleUpdated = (event: Electron.PageTitleUpdatedEvent): void => { syncDocumentState('', event.title); @@ -161,10 +153,7 @@ export function BrowserWebviewPane({ const onFail = (event: Electron.DidFailLoadEvent): void => { // -3 = ERR_ABORTED: fired for cancelled loads (e.g. quick re-navigation), not real failures. if (event.errorCode === -3 || !event.isMainFrame) return; - setNav((prev) => ({ - ...prev, - failure: t('loadFailed', { error: event.errorDescription }), - })); + setFailureError(event.errorDescription); }; const onFoundInPage = (event: Electron.FoundInPageEvent): void => { setFind((prev) => @@ -176,8 +165,7 @@ export function BrowserWebviewPane({ }, ); }; - webview.addEventListener('did-start-loading', sync); - // `dom-ready` can fire before React's layout effects subscribe on very fast pages. The later + // `dom-ready` can fire before this effect subscribes on very fast pages. The later // `did-stop-loading` is an equivalent safe point for guest methods and closes that race. webview.addEventListener('did-stop-loading', syncDocument); webview.addEventListener('dom-ready', syncDocument); @@ -195,7 +183,6 @@ export function BrowserWebviewPane({ noop(); } return () => { - webview.removeEventListener('did-start-loading', sync); webview.removeEventListener('did-stop-loading', syncDocument); webview.removeEventListener('dom-ready', syncDocument); webview.removeEventListener('did-start-navigation', onStartNavigation); @@ -205,7 +192,39 @@ export function BrowserWebviewPane({ webview.removeEventListener('did-fail-load', onFail); webview.removeEventListener('found-in-page', onFoundInPage); }; - }, [webview, t, tabId]); + }, [webview, tabId]); + + // Query side: the webview itself is the store. Subscribing just forwards guest events to + // React, and each snapshot reads the element (or the registry's readiness mark) directly — + // the post-subscribe snapshot re-read makes a pre-subscription load impossible to miss. + const isLoading = useSyncExternalStore( + useCallback( + (onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange), + [webview], + ), + () => readGuest(webview, (view) => view.isLoading()), + ); + const canGoBack = useSyncExternalStore( + useCallback( + (onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange), + [webview], + ), + () => readGuest(webview, (view) => view.canGoBack()), + ); + const canGoForward = useSyncExternalStore( + useCallback( + (onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange), + [webview], + ), + () => readGuest(webview, (view) => view.canGoForward()), + ); + const guestReady = useSyncExternalStore( + useCallback( + (onStoreChange: () => void) => subscribeGuestNav(webview, onStoreChange), + [webview], + ), + () => webview !== null && isBrowserWebviewReady(tabId), + ); // Pause playing media when the pane is hidden; gated on dom-ready to avoid pre-attachment throws. useLayoutEffect(() => { @@ -318,10 +337,10 @@ export function BrowserWebviewPane({
setBrowserTabUrl(tabId, next)} onBack={() => guestReady && webview?.goBack()} diff --git a/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts b/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts index aed68f1cd..1a14e57c5 100644 --- a/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts +++ b/apps/desktop/src/renderer/src/shell/browser/webview-registry.ts @@ -9,6 +9,7 @@ import { noop } from 'foxts/noop'; interface WebviewEntry { webview: WebviewTag; generation: number; + readyNow: boolean; ready: Promise; resolveReady: () => void; } @@ -21,7 +22,7 @@ function unreadyEntry(webview: WebviewTag, generation: number): WebviewEntry { const ready = new Promise((resolve) => { resolveReady = resolve; }); - return { webview, generation, ready, resolveReady }; + return { webview, generation, readyNow: false, ready, resolveReady }; } export function registerBrowserWebview(tabId: string, webview: WebviewTag | null): void { @@ -48,7 +49,16 @@ export function markBrowserWebviewUnready(tabId: string): void { } export function markBrowserWebviewReady(tabId: string): void { - webviews.get(tabId)?.resolveReady(); + const entry = webviews.get(tabId); + if (entry) { + entry.readyNow = true; + entry.resolveReady(); + } +} + +/** Synchronous readiness read, for `useSyncExternalStore` snapshots. */ +export function isBrowserWebviewReady(tabId: string): boolean { + return webviews.get(tabId)?.readyNow ?? false; } export function advanceBrowserWebviewGeneration(tabId: string): void { diff --git a/apps/desktop/src/renderer/src/shell/chrome/window-controls.tsx b/apps/desktop/src/renderer/src/shell/chrome/window-controls.tsx index 2fb97a8e3..6bc64439e 100644 --- a/apps/desktop/src/renderer/src/shell/chrome/window-controls.tsx +++ b/apps/desktop/src/renderer/src/shell/chrome/window-controls.tsx @@ -1,8 +1,8 @@ import { ShellIconButton } from '@linkcode/ui'; import { systemBridge } from '@renderer/ipc'; -import { useEffect } from 'foxact/use-abortable-effect'; import { CopyIcon, MinusIcon, SquareIcon, XIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect } from 'react'; +import useSWRImmutable from 'swr/immutable'; import type { DesktopChromeMetricsStyle } from './metrics'; import { DESKTOP_CHROME_METRICS_STYLE } from './metrics'; @@ -34,13 +34,17 @@ export function DesktopWindowControls(): React.ReactNode { * `systemBridge.window` IPC; maximize state comes from the main-pushed `onMaximizedChange`. */ function WindowControls(): React.ReactNode { - const [maximized, setMaximized] = useState(false); - useEffect((signal) => { - void systemBridge.window.isMaximized().then((value) => { - if (!signal.aborted) setMaximized(value); - }); - return systemBridge.window.onMaximizedChange(setMaximized); - }, []); + const { data: maximized = false, mutate } = useSWRImmutable('desktop:window-maximized', () => + systemBridge.window.isMaximized(), + ); + useEffect( + () => + systemBridge.window.onMaximizedChange((value) => { + // update value directly and avoid re-fetch + mutate(value, { revalidate: false }); + }), + [mutate], + ); return (
diff --git a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx index 083bcf390..a15d80afa 100644 --- a/apps/desktop/src/renderer/src/shell/desktop-shell.tsx +++ b/apps/desktop/src/renderer/src/shell/desktop-shell.tsx @@ -40,6 +40,7 @@ import { useEffect } from 'foxact/use-abortable-effect'; import { useSingleton } from 'foxact/use-singleton'; import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import useSWRImmutable from 'swr/immutable'; import { useFormatter, useTranslations } from 'use-intl'; import { useShallow } from 'zustand/react/shallow'; import { DesktopThreadImMenu } from '../cloud-auth/thread-im-menu'; @@ -62,6 +63,9 @@ import { UpdateNotice } from './update-notice'; import { useDesktopPaletteCommands } from './use-desktop-palette-commands'; import { useDesktopShellShortcuts } from './use-desktop-shell-shortcuts'; +/** Stable fallback while the one-shot editor probe is in flight. */ +const EMPTY_EDITORS: SessionTitleMenuEditor[] = []; + export function DesktopShell({ systemBridge, header, @@ -186,8 +190,15 @@ export function DesktopShell({ createDesktopShellStyle(shellState), ); const desktopPlatform = systemBridge.app.platform; - const [appVersion, setAppVersion] = useState(''); - const [editors, setEditors] = useState([]); + // Both are constant for the process lifetime — fetched once, cached forever. + const { data: appVersionValue } = useSWRImmutable('desktop:app-version', () => + systemBridge.app.version(), + ); + const appVersion = appVersionValue === undefined ? '' : `v${appVersionValue}`; + // Probed once per window: main caches the detection for its whole process lifetime anyway. + const { data: editors = EMPTY_EDITORS } = useSWRImmutable('desktop:shell-editors', () => + systemBridge.shell.listEditors(), + ); const sidebarShortcut = useKeyboardShortcutLabel('desktop.toggle-sidebar'); const bottomPanelShortcut = useKeyboardShortcutLabel('desktop.toggle-bottom-panel'); const rightPanelShortcut = useKeyboardShortcutLabel('desktop.toggle-right-panel'); @@ -259,25 +270,6 @@ export function DesktopShell({ const expandedPanel = getExpandedPanel(expansionStack, rightPanel.open, bottomPanel.open); const chromeSurface = getChromeSurface(expandedPanel); - useEffect( - (signal) => { - void systemBridge.app.version().then((value) => { - if (!signal.aborted) setAppVersion(`v${value}`); - }); - }, - [systemBridge], - ); - - // Probed once per window: main caches the detection for its whole process lifetime anyway. - useEffect( - (signal) => { - void systemBridge.shell.listEditors().then((value) => { - if (!signal.aborted) setEditors(value); - }); - }, - [systemBridge], - ); - const openBrowserTab = useDesktopShellStore((state) => state.openBrowserTab); useEffect(() => systemBridge.browser.onOpenTab(openBrowserTab), [systemBridge, openBrowserTab]); diff --git a/apps/mobile/src/app/session/[sessionId].tsx b/apps/mobile/src/app/session/[sessionId].tsx index 17d7b3043..3398a48af 100644 --- a/apps/mobile/src/app/session/[sessionId].tsx +++ b/apps/mobile/src/app/session/[sessionId].tsx @@ -55,10 +55,9 @@ function SessionScreen(): React.ReactNode { const autoResumeSuppressed = autoResume === 'false'; const parsed = SessionIdSchema.safeParse(rawSessionId); const sessionId: SessionId | null = parsed.success ? parsed.data : null; - const { sessions, refresh } = useSessions(); + const { sessionsById, refresh } = useSessions(); - // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- one lookup against the thread list; the Map the rule asks for costs the same walk to build each render - const session = sessions.find((entry) => entry.sessionId === sessionId); + const session = sessionId === null ? undefined : sessionsById.get(sessionId); // A deep link or a notification can open a thread the snapshot has never listed. Without its // record there is no `kind`/`historyId`, so the seed reads nothing and the past renders as empty // rather than as loading. Deduped per id so a genuinely gone session doesn't spin. diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index 540a29003..4ddfd02a8 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -16,7 +16,7 @@ import { useState } from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router'; import { useTranslations } from 'use-intl'; -const SETTINGS_ROUTES: Record = { +const SETTINGS_ROUTES = { general: '/settings', appearance: '/settings/appearance', terminal: '/settings/terminal', @@ -28,7 +28,31 @@ const SETTINGS_ROUTES: Record = { messaging: '/settings/messaging', developer: '/settings/developer', }; +/** The routes table is the one place the tab-key set is written down. */ +type SettingsTabKey = keyof typeof SETTINGS_ROUTES; +// One table for the sidebar items and the page header — a tab whose message key differs +// from its route key (messaging → imChannel) stays consistent in both by construction, +// and the `SettingsTabKey` bound makes drift against the routes a typecheck error. +const SETTINGS_TAB_LABEL_KEYS: Record = { + general: 'tabs.general', + appearance: 'tabs.appearance', + terminal: 'tabs.terminal', + notifications: 'tabs.notifications', + billing: 'tabs.billing', + agents: 'tabs.agents', + providers: 'tabs.providers', + plugins: 'tabs.plugins', + messaging: 'tabs.imChannel', + developer: 'tabs.developer', +}; const RE_TRAILING_SLASH = /\/$/; +// Inverted once at module scope: the active tab is a single O(1) lookup per navigation. +// Object.entries widens keys to string, so restore the key type for typed lookups below. +const SETTINGS_TAB_BY_PATH = new Map( + (Object.entries(SETTINGS_ROUTES) as Array<[SettingsTabKey, string]>).map( + ([key, route]) => [route, key] as const, + ), +); export function SettingsLayout(): React.ReactNode { const t = useTranslations('settings'); @@ -36,6 +60,7 @@ export function SettingsLayout(): React.ReactNode { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const searchKeywords = useSettingsSearchKeywords(); + const activeKey = SETTINGS_TAB_BY_PATH.get(pathname.replace(RE_TRAILING_SLASH, '')); const navGroups = [ { @@ -45,41 +70,41 @@ export function SettingsLayout(): React.ReactNode { { key: 'general', icon: , - label: t('tabs.general'), + label: t(SETTINGS_TAB_LABEL_KEYS.general), keywords: searchKeywords.general, - active: isActive(pathname, ''), + active: activeKey === 'general', render: , }, { key: 'appearance', icon: , - label: t('tabs.appearance'), + label: t(SETTINGS_TAB_LABEL_KEYS.appearance), keywords: searchKeywords.appearance, - active: isActive(pathname, 'appearance'), + active: activeKey === 'appearance', render: , }, { key: 'terminal', icon: , - label: t('tabs.terminal'), + label: t(SETTINGS_TAB_LABEL_KEYS.terminal), keywords: searchKeywords.terminal, - active: isActive(pathname, 'terminal'), + active: activeKey === 'terminal', render: , }, { key: 'notifications', icon: , - label: t('tabs.notifications'), + label: t(SETTINGS_TAB_LABEL_KEYS.notifications), keywords: searchKeywords.notifications, - active: isActive(pathname, 'notifications'), + active: activeKey === 'notifications', render: , }, { key: 'billing', icon: , - label: t('tabs.billing'), + label: t(SETTINGS_TAB_LABEL_KEYS.billing), keywords: searchKeywords.billing, - active: isActive(pathname, 'billing'), + active: activeKey === 'billing', render: , }, ], @@ -91,33 +116,33 @@ export function SettingsLayout(): React.ReactNode { { key: 'agents', icon: , - label: t('tabs.agents'), + label: t(SETTINGS_TAB_LABEL_KEYS.agents), keywords: searchKeywords.agents, - active: isActive(pathname, 'agents'), + active: activeKey === 'agents', render: , }, { key: 'providers', icon: , - label: t('tabs.providers'), + label: t(SETTINGS_TAB_LABEL_KEYS.providers), keywords: searchKeywords.providers, - active: isActive(pathname, 'providers'), + active: activeKey === 'providers', render: , }, { key: 'plugins', icon: , - label: t('tabs.plugins'), + label: t(SETTINGS_TAB_LABEL_KEYS.plugins), keywords: searchKeywords.plugins, - active: isActive(pathname, 'plugins'), + active: activeKey === 'plugins', render: , }, { key: 'messaging', icon: , - label: t('tabs.imChannel'), + label: t(SETTINGS_TAB_LABEL_KEYS.messaging), keywords: searchKeywords.imChannel, - active: isActive(pathname, 'messaging'), + active: activeKey === 'messaging', render: , }, ], @@ -129,17 +154,16 @@ export function SettingsLayout(): React.ReactNode { { key: 'developer', icon: , - label: t('tabs.developer'), + label: t(SETTINGS_TAB_LABEL_KEYS.developer), keywords: searchKeywords.developer, - active: isActive(pathname, 'developer'), + active: activeKey === 'developer', render: , }, ], }, ]; const visibleGroups = filterSettingsNavGroups(navGroups, searchQuery); - // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- a handful of static nav items scanned once per render; a Map would be needless ceremony - const activeLabel = navGroups.flatMap((group) => group.items).find((item) => item.active)?.label; + const activeLabel = activeKey === undefined ? undefined : t(SETTINGS_TAB_LABEL_KEYS[activeKey]); return (
@@ -153,7 +177,8 @@ export function SettingsLayout(): React.ReactNode { onSearchChange={setSearchQuery} onSearchSubmit={() => { const first = visibleGroups.flatMap((group) => group.items).at(0); - if (first !== undefined) void navigate(SETTINGS_ROUTES[first.key]); + // The shared nav type widens item keys to string; ours are authored from the table. + if (first !== undefined) void navigate(SETTINGS_ROUTES[first.key as SettingsTabKey]); }} searchEmptyLabel={t('searchNoResults')} groups={visibleGroups} @@ -173,20 +198,3 @@ export function SettingsLayout(): React.ReactNode {
); } - -function isActive( - pathname: string, - section: - | '' - | 'appearance' - | 'terminal' - | 'developer' - | 'notifications' - | 'billing' - | 'providers' - | 'plugins' - | 'agents' - | 'messaging', -): boolean { - return pathname.replace(RE_TRAILING_SLASH, '') === `/settings${section ? `/${section}` : ''}`; -} diff --git a/packages/client/core/src/react.tsx b/packages/client/core/src/react.tsx index e667fff3b..76cce9669 100644 --- a/packages/client/core/src/react.tsx +++ b/packages/client/core/src/react.tsx @@ -117,6 +117,8 @@ export function useConversation( export interface SessionsApi { /** Known sessions (daemon snapshot merged with ones created in this client), newest last. */ sessions: SessionInfo[]; + /** The same sessions keyed by id: O(1) lookups for screens that re-render per stream delta. */ + sessionsById: ReadonlyMap; /** The currently focused session, or null. */ activeId: SessionId | null; /** Focus a session (or clear the selection). */ @@ -217,5 +219,14 @@ export function useSessions(): SessionsApi { [client], ); - return { sessions, activeId, select: setActiveId, create, stop, refresh, loading }; + const sessionsById = useMemo(() => { + const byId = new Map(); + for (let i = 0, len = sessions.length; i < len; i++) { + const session = sessions[i]; + byId.set(session.sessionId, session); + } + return byId; + }, [sessions]); + + return { sessions, sessionsById, activeId, select: setActiveId, create, stop, refresh, loading }; } diff --git a/packages/client/workbench/src/settings/simulator-access.tsx b/packages/client/workbench/src/settings/simulator-access.tsx index 9c7a5559d..ca9c72fd3 100644 --- a/packages/client/workbench/src/settings/simulator-access.tsx +++ b/packages/client/workbench/src/settings/simulator-access.tsx @@ -1,11 +1,9 @@ import { useLinkCodeClient } from '@linkcode/client-core'; import { SettingsCard } from '@linkcode/ui'; import { Switch } from 'coss-ui/components/switch'; -import { useEffect } from 'foxact/use-abortable-effect'; -import { noop } from 'foxts/noop'; -import { useState } from 'react'; import { useTranslations } from 'use-intl'; import { useSimulatorConsent } from '../simulator/consent'; +import { useSimulatorStatus } from '../simulator/status'; /** * The global simulator kill switch (CODE-420): one bit that refuses every simulator MCP tool, @@ -18,22 +16,10 @@ export function SimulatorAgentAccessCard(): React.ReactNode { const t = useTranslations('settings.agents'); const client = useLinkCodeClient(); const consent = useSimulatorConsent(client); - const [available, setAvailable] = useState(false); + // No simulator surface at all (the probe rejects) reads as unavailable — leave the card hidden. + const { data: status } = useSimulatorStatus(client); - useEffect( - (signal) => { - void client - .simulatorStatus() - .then((status) => { - if (!signal.aborted) setAvailable(status.available); - }) - // No simulator surface at all — leave the card hidden. - .catch(noop); - }, - [client], - ); - - if (!available) return null; + if (status?.available !== true) return null; return ( diff --git a/packages/client/workbench/src/simulator/consent.ts b/packages/client/workbench/src/simulator/consent.ts index 25bb6b37b..8d3625668 100644 --- a/packages/client/workbench/src/simulator/consent.ts +++ b/packages/client/workbench/src/simulator/consent.ts @@ -1,8 +1,8 @@ import type { LinkCodeClient } from '@linkcode/client-core'; import type { SimulatorConsentDecision, SimulatorConsentState } from '@linkcode/schema'; -import { useEffect } from 'foxact/use-abortable-effect'; import { noop } from 'foxts/noop'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import useSWRImmutable from 'swr/immutable'; /** The slice of `LinkCodeClient` the consent hooks need. */ export type SimulatorConsentClient = Pick< @@ -31,20 +31,21 @@ export interface SimulatorConsent { * showing the same device agrees rather than drifting on optimistic local edits. */ export function useSimulatorConsent(client: SimulatorConsentClient): SimulatorConsent { - const [state, setState] = useState(EMPTY); - + // One host read seeds the state; the change broadcast keeps it live through `mutate`, which + // discards any still-in-flight read — a stale reply can never overwrite a fresher broadcast. + // A host with no simulator surface rejects the read; "never asked" (EMPTY) is the right default. + const { data: state = EMPTY, mutate } = useSWRImmutable( + 'simulator-consent', + () => client.simulatorConsentGet(), + { shouldRetryOnError: false }, + ); useEffect( - (signal) => { - void client - .simulatorConsentGet() - .then((next) => { - if (!signal.aborted) setState(next); - }) - // A host with no simulator surface answers nothing; "never asked" is the right default. - .catch(noop); - return client.subscribeSimulatorConsentChanged(setState); - }, - [client], + () => + client.subscribeSimulatorConsentChanged((next) => { + // update value directly and avoid re-fetch + mutate(next, { revalidate: false }); + }), + [client, mutate], ); return { diff --git a/packages/client/workbench/src/simulator/panel.tsx b/packages/client/workbench/src/simulator/panel.tsx index a6e4b9382..60bb55105 100644 --- a/packages/client/workbench/src/simulator/panel.tsx +++ b/packages/client/workbench/src/simulator/panel.tsx @@ -4,7 +4,6 @@ import type { SimulatorButton, SimulatorDevice, SimulatorOrientation, - SimulatorStatus, } from '@linkcode/schema'; import type { SimulatorKeyPress, @@ -31,7 +30,9 @@ import { UnplugIcon, VideoIcon, } from 'lucide-react'; -import { useCallback, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import useSWR from 'swr'; +import useSWRImmutable from 'swr/immutable'; import { useTranslations } from 'use-intl'; import { useSimulatorAgentActivity } from './agent-activity'; import { useBackgroundSimulatorStreams } from './background-streams'; @@ -41,6 +42,7 @@ import { SimulatorDeviceTabs } from './device-tabs'; import { selectDeviceTabs, simulatorSessionKey, useSimulatorPanelStore } from './panel-store'; import { SimulatorSetupChecklist } from './setup-checklist'; import { useSimulatorShortcuts } from './shortcuts'; +import { useSimulatorStatus } from './status'; import type { SimulatorStreamLease } from './stream-registry'; import { acquireSimulatorStream, @@ -56,6 +58,8 @@ import { const BUSY_BANNER_MS = 3000; +const SCREEN_MASK_KEY = 'simulator-screen-mask'; + /** How often to re-probe while the host is still missing a provisioning step. Slow on purpose: the * thing being waited on is a multi-gigabyte download or a human in Xcode, not a fast operation. */ const SETUP_REPROBE_MS = 5000; @@ -167,17 +171,22 @@ function QuietSelect({ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): React.ReactNode { const t = useTranslations('workbench.panel'); const client = useLinkCodeClient(); - const [status, setStatus] = useState(null); + // While the host is still being provisioned, keep asking: a step finished in Xcode (or by the + // download this panel started) should tick itself off without the user restarting anything. + // The engine only caches a fully-ready probe, so this really does re-read the host. + const { data: status = null } = useSimulatorStatus(client, { + refreshInterval: SETUP_REPROBE_MS, + }); /** True between clicking "download the runtime" and the runtime appearing in a later probe. */ const [installingRuntime, setInstallingRuntime] = useState(false); - const [devices, setDevices] = useState(null); + const { data: devices = null, mutate: mutateDevices } = useSWR('simulator-devices', () => + client.simulatorList().catch((): SimulatorDevice[] => []), + ); const sessionKey = simulatorSessionKey(sessionId); const tabs = useSimulatorPanelStore((state) => selectDeviceTabs(state, sessionKey)); const openDevice = useSimulatorPanelStore((state) => state.openDevice); const closeDevice = useSimulatorPanelStore((state) => state.closeDevice); const selectDevice = useSimulatorPanelStore((state) => state.selectDevice); - /** Screen-outline masks by udid as base64 PNGs; `null` = the host has none (generic rounding). */ - const [masks, setMasks] = useState>>({}); const [busy, setBusy] = useState(false); const busyTimerRef = useRef | undefined>(undefined); /** Shortcut owner: the chords below only fire while this panel is on screen. */ @@ -197,39 +206,14 @@ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): const [detached, setDetached] = useState>>({}); useEffect( - (signal) => { - const probe = (): void => { - void client - .simulatorStatus() - .then((value) => { - if (!signal.aborted) setStatus(value); - }) - .catch(() => { - if (!signal.aborted) setStatus({ available: false }); - }); - }; - probe(); - // While the host is still being provisioned, keep asking: a step finished in Xcode (or by the - // download this panel started) should tick itself off without the user restarting anything. - // The engine only caches a fully-ready probe, so this really does re-read the host. - const reprobe = setInterval(probe, SETUP_REPROBE_MS); - void client - .simulatorList() - .then((value) => { - if (!signal.aborted) setDevices(value); - }) - .catch(() => { - if (!signal.aborted) setDevices([]); - }); - const unsubscribe = client.subscribeSimulatorDevicesChanged(setDevices); - return () => { - clearInterval(reprobe); - unsubscribe(); - clearTimeout(busyTimerRef.current); - }; - }, - [client], + () => + client.subscribeSimulatorDevicesChanged((next) => { + // update value directly and avoid re-fetch + mutateDevices(next, { revalidate: false }); + }), + [client, mutateDevices], ); + useEffect(() => () => clearTimeout(busyTimerRef.current), []); // Until the user opens a second device the panel shows one implicitly, so a fresh thread needs no // setup click. Opening another materializes that implicit tab first (see `addDevice`). @@ -237,8 +221,18 @@ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): const openUdids = tabs.udids.length > 0 ? tabs.udids : defaultUdid === null ? EMPTY_UDIDS : [defaultUdid]; const activeUdid = tabs.activeUdid ?? defaultUdid; - // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- a host exposes a handful of simulators at most; a lookup Map would outweigh the scan - const device = devices?.find((item) => item.udid === activeUdid) ?? null; + // Keyed once per device-list change: the panel re-renders per stream frame, the list rarely moves. + const devicesByUdid = useMemo(() => { + const byUdid = new Map(); + if (devices !== null) { + for (let i = 0, len = devices.length; i < len; i++) { + const item = devices[i]; + byUdid.set(item.udid, item); + } + } + return byUdid; + }, [devices]); + const device = (activeUdid === null ? undefined : devicesByUdid.get(activeUdid)) ?? null; const udid = device?.udid ?? null; const booted = device?.state === 'Booted'; // Optimistic until the probe resolves: assume interactive so a capable host streams immediately. @@ -248,22 +242,14 @@ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): const isDetached = udid !== null && (detached[udid] ?? false); const canStream = sessionId !== null && udid !== null && booted && interactive && !isDetached; - // Fetch bookkeeping lives in a ref (not `masks`) so the effect never loops on its own writes; - // the cache write itself is deliberately not abort-gated — a udid switch mid-fetch must still - // land the result for the next switch back. No stale-overwrite race exists: writes are keyed - // by the udid captured at fetch time (never clobbering another udid's entry), and the ref - // guarantees at most one fetch per udid, so no two writes ever target the same key. - const maskFetchedRef = useRef(new Set()); - useEffect(() => { - if (udid === null || maskFetchedRef.current.has(udid)) return; - maskFetchedRef.current.add(udid); - void client - .simulatorScreenMask(udid) - // eslint-disable-next-line vibe-proof/react-detect-potential-race-condition -- see above - .then((data) => setMasks((prev) => ({ ...prev, [udid]: data }))) - // eslint-disable-next-line vibe-proof/react-detect-potential-race-condition -- see above - .catch(() => setMasks((prev) => ({ ...prev, [udid]: null }))); - }, [client, udid]); + // The screen-outline mask (a base64 PNG) is static per device, so it is a keyed fetch-and-cache + // that must never revalidate — switching back to a seen device serves the cache, full stop. + // A fetch failure means the host has none and the screen falls back to generic rounding. + const { data: maskPng } = useSWRImmutable( + udid === null ? null : [SCREEN_MASK_KEY, udid], + ([, maskUdid]: [string, string]) => client.simulatorScreenMask(maskUdid), + { shouldRetryOnError: false }, + ); const subscribe = useCallback( (onStoreChange: () => void) => { @@ -587,7 +573,7 @@ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): onPinch={handlePinch} onKey={handleKey} onText={handleText} - maskPng={masks[udid] ?? null} + maskPng={maskPng ?? null} onScreenCanvas={setScreenCanvas} agentPointer={agentActivity.point} placeholder={ diff --git a/packages/client/workbench/src/simulator/status.ts b/packages/client/workbench/src/simulator/status.ts new file mode 100644 index 000000000..ee2ea7f6e --- /dev/null +++ b/packages/client/workbench/src/simulator/status.ts @@ -0,0 +1,22 @@ +import type { LinkCodeClient } from '@linkcode/client-core'; +import type { SimulatorStatus } from '@linkcode/schema'; +import type { SWRResponse } from 'swr'; +import useSWR from 'swr'; + +/** The slice of `LinkCodeClient` the status hook needs. */ +export type SimulatorStatusClient = Pick; + +/** + * Host simulator provisioning status, shared by every consumer through one cache key. + * A host with no simulator surface rejects the probe and reads as unavailable. + */ +export function useSimulatorStatus( + client: SimulatorStatusClient, + options?: { refreshInterval?: number }, +): SWRResponse { + return useSWR( + 'simulator-status', + () => client.simulatorStatus().catch((): SimulatorStatus => ({ available: false })), + { refreshInterval: options?.refreshInterval }, + ); +} diff --git a/packages/presentation/ui/src/chat/subagent-viewer.tsx b/packages/presentation/ui/src/chat/subagent-viewer.tsx index 5f5900284..815cf749e 100644 --- a/packages/presentation/ui/src/chat/subagent-viewer.tsx +++ b/packages/presentation/ui/src/chat/subagent-viewer.tsx @@ -2,6 +2,7 @@ import { Badge } from 'coss-ui/components/badge'; import { Dialog, DialogPopup, DialogTitle } from 'coss-ui/components/dialog'; import { Spinner } from 'coss-ui/components/spinner'; import { BotIcon } from 'lucide-react'; +import { useMemo } from 'react'; import { useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; import type { ToolTimelineItem } from './activity-groups'; @@ -42,8 +43,16 @@ export function SubagentViewer({ }: SubagentViewerProps): React.ReactNode { const t = useTranslations('workbench.subagent'); - // eslint-disable-next-line vibe-proof/react-no-performance-impacting-array-find -- a conversation holds a handful of subagents at most; a lookup Map would outweigh the scan - const selected = tasks.find((task) => task.toolCall.toolCallId === selectedId) ?? tasks.at(0); + // Keyed once per task-list change: selection changes re-render the dialog far more often. + const tasksById = useMemo(() => { + const byId = new Map(); + for (let i = 0, len = tasks.length; i < len; i++) { + const task = tasks[i]; + byId.set(task.toolCall.toolCallId, task); + } + return byId; + }, [tasks]); + const selected = (selectedId === null ? undefined : tasksById.get(selectedId)) ?? tasks.at(0); return (