From ed3e8c661b98fe0b5507e6cfc9cbf4beffbad5d5 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sat, 29 Aug 2026 12:15:39 +0800 Subject: [PATCH 1/9] perf(client): replace render-phase session/device/task finds with keyed maps --- apps/mobile/src/app/session/[sessionId].tsx | 5 +- .../src/routes/settings/settings-layout.tsx | 48 ++++++++----------- packages/client/core/src/react.tsx | 13 ++++- .../client/workbench/src/simulator/panel.tsx | 16 +++++-- .../ui/src/chat/subagent-viewer.tsx | 13 ++++- 5 files changed, 57 insertions(+), 38 deletions(-) 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..2a877d7b5 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -29,6 +29,10 @@ const SETTINGS_ROUTES: Record = { developer: '/settings/developer', }; const RE_TRAILING_SLASH = /\/$/; +// Inverted once at module scope: the active tab is a single O(1) lookup per navigation. +const SETTINGS_TAB_BY_PATH = new Map( + Object.entries(SETTINGS_ROUTES).map(([key, route]) => [route, key]), +); export function SettingsLayout(): React.ReactNode { const t = useTranslations('settings'); @@ -36,6 +40,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 = [ { @@ -47,7 +52,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.general'), keywords: searchKeywords.general, - active: isActive(pathname, ''), + active: activeKey === 'general', render: , }, { @@ -55,7 +60,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.appearance'), keywords: searchKeywords.appearance, - active: isActive(pathname, 'appearance'), + active: activeKey === 'appearance', render: , }, { @@ -63,7 +68,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.terminal'), keywords: searchKeywords.terminal, - active: isActive(pathname, 'terminal'), + active: activeKey === 'terminal', render: , }, { @@ -71,7 +76,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.notifications'), keywords: searchKeywords.notifications, - active: isActive(pathname, 'notifications'), + active: activeKey === 'notifications', render: , }, { @@ -79,7 +84,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.billing'), keywords: searchKeywords.billing, - active: isActive(pathname, 'billing'), + active: activeKey === 'billing', render: , }, ], @@ -93,7 +98,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.agents'), keywords: searchKeywords.agents, - active: isActive(pathname, 'agents'), + active: activeKey === 'agents', render: , }, { @@ -101,7 +106,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.providers'), keywords: searchKeywords.providers, - active: isActive(pathname, 'providers'), + active: activeKey === 'providers', render: , }, { @@ -109,7 +114,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.plugins'), keywords: searchKeywords.plugins, - active: isActive(pathname, 'plugins'), + active: activeKey === 'plugins', render: , }, { @@ -117,7 +122,7 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.imChannel'), keywords: searchKeywords.imChannel, - active: isActive(pathname, 'messaging'), + active: activeKey === 'messaging', render: , }, ], @@ -131,15 +136,17 @@ export function SettingsLayout(): React.ReactNode { icon: , label: t('tabs.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(`tabs.${activeKey === 'messaging' ? 'imChannel' : activeKey}`); return (
@@ -173,20 +180,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/simulator/panel.tsx b/packages/client/workbench/src/simulator/panel.tsx index a6e4b9382..feac0dca9 100644 --- a/packages/client/workbench/src/simulator/panel.tsx +++ b/packages/client/workbench/src/simulator/panel.tsx @@ -31,7 +31,7 @@ import { UnplugIcon, VideoIcon, } from 'lucide-react'; -import { useCallback, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { useTranslations } from 'use-intl'; import { useSimulatorAgentActivity } from './agent-activity'; import { useBackgroundSimulatorStreams } from './background-streams'; @@ -237,8 +237,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. 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 ( From 413656c828641f30bf2a0ae6a2c0810cc47b1702 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 00:43:47 +0800 Subject: [PATCH 2/9] refactor(desktop): drive webview guest state through useSyncExternalStore --- .../shell/browser/browser-webview-pane.tsx | 143 ++++++++++-------- .../src/shell/browser/webview-registry.ts | 14 +- 2 files changed, 93 insertions(+), 64 deletions(-) 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 { From 5bc4bce1b422184c4b0251767adb93ba4b20885f Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 01:09:55 +0800 Subject: [PATCH 3/9] refactor(workbench): fetch simulator screen masks through keyed swr --- .../client/workbench/src/simulator/panel.tsx | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/client/workbench/src/simulator/panel.tsx b/packages/client/workbench/src/simulator/panel.tsx index feac0dca9..ea50b6c3f 100644 --- a/packages/client/workbench/src/simulator/panel.tsx +++ b/packages/client/workbench/src/simulator/panel.tsx @@ -32,6 +32,7 @@ import { VideoIcon, } from 'lucide-react'; import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import useSWR from 'swr'; import { useTranslations } from 'use-intl'; import { useSimulatorAgentActivity } from './agent-activity'; import { useBackgroundSimulatorStreams } from './background-streams'; @@ -56,6 +57,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; @@ -176,8 +179,6 @@ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): 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. */ @@ -258,22 +259,13 @@ 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; + // a fetch failure means the host has none and the screen falls back to generic rounding. + const { data: maskPng } = useSWR( + udid === null ? null : [SCREEN_MASK_KEY, udid], + ([, maskUdid]: [string, string]) => client.simulatorScreenMask(maskUdid), + { revalidateOnFocus: false, revalidateOnReconnect: false, shouldRetryOnError: false }, + ); const subscribe = useCallback( (onStoreChange: () => void) => { @@ -597,7 +589,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={ From 78b83007813b9aee36051d92cb1b7c42ef011062 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 01:21:01 +0800 Subject: [PATCH 4/9] refactor(desktop): fetch process-constant version and editors via swr-immutable --- .../src/renderer/src/settings/about-tab.tsx | 14 +++----- .../src/renderer/src/shell/desktop-shell.tsx | 34 +++++++------------ 2 files changed, 18 insertions(+), 30 deletions(-) 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/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]); From 9008851b801da11254a503e47f6d35f4e69d78c2 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 01:26:20 +0800 Subject: [PATCH 5/9] fix(desktop): route window maximize state through swr so pushes outrank stale fetches --- .../src/shell/chrome/window-controls.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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 (
From 12cb2d1a34abd68d39f87eef487027da70ea1eb0 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 01:36:13 +0800 Subject: [PATCH 6/9] refactor(workbench): seed simulator consent through swr with broadcast-driven mutate --- .../client/workbench/src/simulator/consent.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) 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 { From df6bfd319fed38d9796ee93bcb08c3937e49da05 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 01:42:47 +0800 Subject: [PATCH 7/9] refactor(client): move remaining fetch-into-state effects onto swr --- apps/desktop/src/renderer/src/app.tsx | 14 +++-- .../src/settings/simulator-access.tsx | 22 ++------ .../client/workbench/src/simulator/panel.tsx | 52 ++++++------------- .../client/workbench/src/simulator/status.ts | 22 ++++++++ 4 files changed, 49 insertions(+), 61 deletions(-) create mode 100644 packages/client/workbench/src/simulator/status.ts 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/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/panel.tsx b/packages/client/workbench/src/simulator/panel.tsx index ea50b6c3f..92b9d2a3d 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, @@ -42,6 +41,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, @@ -170,10 +170,17 @@ 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); @@ -198,39 +205,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`). 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 }, + ); +} From d98e9c639310a547189b80df17c2845bd4910eee Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 07:07:33 +0800 Subject: [PATCH 8/9] chore(client): address review: immutable mask cache, single settings label table --- .../src/routes/settings/settings-layout.tsx | 39 ++++++++++++------- .../client/workbench/src/simulator/panel.tsx | 10 +++-- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index 2a877d7b5..fbf3e45b0 100644 --- a/apps/webview/src/routes/settings/settings-layout.tsx +++ b/apps/webview/src/routes/settings/settings-layout.tsx @@ -28,6 +28,20 @@ const SETTINGS_ROUTES: Record = { messaging: '/settings/messaging', developer: '/settings/developer', }; +// 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. +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. const SETTINGS_TAB_BY_PATH = new Map( @@ -50,7 +64,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'general', icon: , - label: t('tabs.general'), + label: t(SETTINGS_TAB_LABEL_KEYS.general), keywords: searchKeywords.general, active: activeKey === 'general', render: , @@ -58,7 +72,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'appearance', icon: , - label: t('tabs.appearance'), + label: t(SETTINGS_TAB_LABEL_KEYS.appearance), keywords: searchKeywords.appearance, active: activeKey === 'appearance', render: , @@ -66,7 +80,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'terminal', icon: , - label: t('tabs.terminal'), + label: t(SETTINGS_TAB_LABEL_KEYS.terminal), keywords: searchKeywords.terminal, active: activeKey === 'terminal', render: , @@ -74,7 +88,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'notifications', icon: , - label: t('tabs.notifications'), + label: t(SETTINGS_TAB_LABEL_KEYS.notifications), keywords: searchKeywords.notifications, active: activeKey === 'notifications', render: , @@ -82,7 +96,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'billing', icon: , - label: t('tabs.billing'), + label: t(SETTINGS_TAB_LABEL_KEYS.billing), keywords: searchKeywords.billing, active: activeKey === 'billing', render: , @@ -96,7 +110,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'agents', icon: , - label: t('tabs.agents'), + label: t(SETTINGS_TAB_LABEL_KEYS.agents), keywords: searchKeywords.agents, active: activeKey === 'agents', render: , @@ -104,7 +118,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'providers', icon: , - label: t('tabs.providers'), + label: t(SETTINGS_TAB_LABEL_KEYS.providers), keywords: searchKeywords.providers, active: activeKey === 'providers', render: , @@ -112,7 +126,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'plugins', icon: , - label: t('tabs.plugins'), + label: t(SETTINGS_TAB_LABEL_KEYS.plugins), keywords: searchKeywords.plugins, active: activeKey === 'plugins', render: , @@ -120,7 +134,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'messaging', icon: , - label: t('tabs.imChannel'), + label: t(SETTINGS_TAB_LABEL_KEYS.messaging), keywords: searchKeywords.imChannel, active: activeKey === 'messaging', render: , @@ -134,7 +148,7 @@ export function SettingsLayout(): React.ReactNode { { key: 'developer', icon: , - label: t('tabs.developer'), + label: t(SETTINGS_TAB_LABEL_KEYS.developer), keywords: searchKeywords.developer, active: activeKey === 'developer', render: , @@ -143,10 +157,7 @@ export function SettingsLayout(): React.ReactNode { }, ]; const visibleGroups = filterSettingsNavGroups(navGroups, searchQuery); - const activeLabel = - activeKey === undefined - ? undefined - : t(`tabs.${activeKey === 'messaging' ? 'imChannel' : activeKey}`); + const activeLabel = activeKey === undefined ? undefined : t(SETTINGS_TAB_LABEL_KEYS[activeKey]); return (
diff --git a/packages/client/workbench/src/simulator/panel.tsx b/packages/client/workbench/src/simulator/panel.tsx index 92b9d2a3d..60bb55105 100644 --- a/packages/client/workbench/src/simulator/panel.tsx +++ b/packages/client/workbench/src/simulator/panel.tsx @@ -32,6 +32,7 @@ import { } from 'lucide-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'; @@ -241,12 +242,13 @@ export function SimulatorPanel({ sessionId }: { sessionId: SessionId | null }): const isDetached = udid !== null && (detached[udid] ?? false); const canStream = sessionId !== null && udid !== null && booted && interactive && !isDetached; - // The screen-outline mask (a base64 PNG) is static per device, so it is a keyed fetch-and-cache; - // a fetch failure means the host has none and the screen falls back to generic rounding. - const { data: maskPng } = useSWR( + // 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), - { revalidateOnFocus: false, revalidateOnReconnect: false, shouldRetryOnError: false }, + { shouldRetryOnError: false }, ); const subscribe = useCallback( From e863c4b8a69052dc6b1b6c0ecae114363173e4e5 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 30 Aug 2026 10:21:22 +0800 Subject: [PATCH 9/9] chore(webview): bind settings tables to one SettingsTabKey union --- .../src/routes/settings/settings-layout.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/webview/src/routes/settings/settings-layout.tsx b/apps/webview/src/routes/settings/settings-layout.tsx index fbf3e45b0..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,9 +28,12 @@ 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. -const SETTINGS_TAB_LABEL_KEYS: Record = { +// 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', @@ -44,8 +47,11 @@ const SETTINGS_TAB_LABEL_KEYS: Record = { }; 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).map(([key, route]) => [route, key]), + (Object.entries(SETTINGS_ROUTES) as Array<[SettingsTabKey, string]>).map( + ([key, route]) => [route, key] as const, + ), ); export function SettingsLayout(): React.ReactNode { @@ -171,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}