Sfoglia il codice sorgente

fix(app): more startup efficiency (#19454)

Adam 2 settimane fa
parent
commit
f736116967

+ 24 - 24
packages/app/e2e/settings/settings.spec.ts

@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
   const dialog = await openSettings(page)
   const input = dialog.locator(settingsCodeFontSelector)
   await expect(input).toBeVisible()
-  await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
+  await expect(input).toHaveAttribute("placeholder", "System Mono")
 
   const initialFontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
   const initialUIFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
   )
-  expect(initialFontFamily).toContain("IBM Plex Mono")
+  expect(initialFontFamily).toContain("ui-monospace")
 
   const next = "Test Mono"
 
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
     })
     .toMatchObject({
       appearance: {
-        font: next,
+        mono: next,
       },
     })
 
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
   const dialog = await openSettings(page)
   const input = dialog.locator(settingsUIFontSelector)
   await expect(input).toBeVisible()
-  await expect(input).toHaveAttribute("placeholder", "Inter")
+  await expect(input).toHaveAttribute("placeholder", "System Sans")
 
   const initialFontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
   const initialCodeFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
   )
-  expect(initialFontFamily).toContain("Inter")
+  expect(initialFontFamily).toContain("ui-sans-serif")
 
   const next = "Test Sans"
 
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
     })
     .toMatchObject({
       appearance: {
-        uiFont: next,
+        sans: next,
       },
     })
 
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
     })
     .toMatchObject({
       appearance: {
-        font: "Reset Mono",
+        mono: "Reset Mono",
       },
     })
 
   await input.clear()
   await input.press("Space")
   await expect(input).toHaveValue("")
-  await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
+  await expect(input).toHaveAttribute("placeholder", "System Mono")
 
   await expect
     .poll(async () => {
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
     })
     .toMatchObject({
       appearance: {
-        font: "",
+        mono: "",
       },
     })
 
   const fontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
   )
-  expect(fontFamily).toContain("IBM Plex Mono")
+  expect(fontFamily).toContain("ui-monospace")
   expect(fontFamily).not.toContain("Reset Mono")
 })
 
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
     })
     .toMatchObject({
       appearance: {
-        uiFont: "Reset Sans",
+        sans: "Reset Sans",
       },
     })
 
   await input.clear()
   await input.press("Space")
   await expect(input).toHaveValue("")
-  await expect(input).toHaveAttribute("placeholder", "Inter")
+  await expect(input).toHaveAttribute("placeholder", "System Sans")
 
   await expect
     .poll(async () => {
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
     })
     .toMatchObject({
       appearance: {
-        uiFont: "",
+        sans: "",
       },
     })
 
   const fontFamily = await page.evaluate(() =>
     getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
   )
-  expect(fontFamily).toContain("Inter")
+  expect(fontFamily).toContain("ui-sans-serif")
   expect(fontFamily).not.toContain("Reset Sans")
 })
 
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
     return raw ? JSON.parse(raw) : null
   }, settingsKey)
 
-  const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
-  const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
+  const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
+  const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
 
   await code.click()
   await code.clear()
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
     })
     .toMatchObject({
       appearance: {
-        font: mono,
-        uiFont: sans,
+        mono,
+        sans,
       },
     })
 
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
   expect(updatedMono).not.toBe(initialMono)
   expect(updatedSans).toContain(sans)
   expect(updatedSans).not.toBe(initialSans)
-  expect(updatedSettings?.appearance?.font).toBe(mono)
-  expect(updatedSettings?.appearance?.uiFont).toBe(sans)
+  expect(updatedSettings?.appearance?.mono).toBe(mono)
+  expect(updatedSettings?.appearance?.sans).toBe(sans)
 
   await closeDialog(page, dialog)
   await page.reload()
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
     })
     .toMatchObject({
       appearance: {
-        font: mono,
-        uiFont: sans,
+        mono,
+        sans,
       },
     })
 
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
   expect(rehydratedMono).not.toBe(initialMono)
   expect(rehydratedSans).toContain(sans)
   expect(rehydratedSans).not.toBe(initialSans)
-  expect(rehydratedSettings?.appearance?.font).toBe(mono)
-  expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
+  expect(rehydratedSettings?.appearance?.mono).toBe(mono)
+  expect(rehydratedSettings?.appearance?.sans).toBe(sans)
 })
 
 test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

+ 11 - 2
packages/app/src/app.tsx

@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
 
 const HomeRoute = lazy(() => import("@/pages/home"))
-const Session = lazy(() => import("@/pages/session"))
+const loadSession = () => import("@/pages/session")
+const Session = lazy(loadSession)
 const Loading = () => <div class="size-full" />
 
+if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
+  void loadSession()
+}
+
 const SessionRoute = () => (
   <SessionProviders>
     <Session />
@@ -278,7 +283,11 @@ export function AppInterface(props: {
   disableHealthCheck?: boolean
 }) {
   return (
-    <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
+    <ServerProvider
+      defaultServer={props.defaultServer}
+      disableHealthCheck={props.disableHealthCheck}
+      servers={props.servers}
+    >
       <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
         <ServerKey>
           <GlobalSDKProvider>

+ 82 - 60
packages/app/src/context/global-sdk.tsx

@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     const aborted = (error: unknown) => abortError.safeParse(error).success
 
     let attempt: AbortController | undefined
+    let run: Promise<void> | undefined
+    let started = false
     const HEARTBEAT_TIMEOUT_MS = 15_000
     let lastEventAt = Date.now()
     let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       heartbeat = undefined
     }
 
-    void (async () => {
-      while (!abort.signal.aborted) {
-        attempt = new AbortController()
-        lastEventAt = Date.now()
-        const onAbort = () => {
-          attempt?.abort()
-        }
-        abort.signal.addEventListener("abort", onAbort)
-        try {
-          const events = await eventSdk.global.event({
-            signal: attempt.signal,
-            onSseError: (error) => {
-              if (aborted(error)) return
-              if (streamErrorLogged) return
+    const start = () => {
+      if (started) return run
+      started = true
+      run = (async () => {
+        while (!abort.signal.aborted && started) {
+          attempt = new AbortController()
+          lastEventAt = Date.now()
+          const onAbort = () => {
+            attempt?.abort()
+          }
+          abort.signal.addEventListener("abort", onAbort)
+          try {
+            const events = await eventSdk.global.event({
+              signal: attempt.signal,
+              onSseError: (error) => {
+                if (aborted(error)) return
+                if (streamErrorLogged) return
+                streamErrorLogged = true
+                console.error("[global-sdk] event stream error", {
+                  url: currentServer.http.url,
+                  fetch: eventFetch ? "platform" : "webview",
+                  error,
+                })
+              },
+            })
+            let yielded = Date.now()
+            resetHeartbeat()
+            for await (const event of events.stream) {
+              resetHeartbeat()
+              streamErrorLogged = false
+              const directory = event.directory ?? "global"
+              const payload = event.payload
+              const k = key(directory, payload)
+              if (k) {
+                const i = coalesced.get(k)
+                if (i !== undefined) {
+                  queue[i] = { directory, payload }
+                  if (payload.type === "message.part.updated") {
+                    const part = payload.properties.part
+                    staleDeltas.add(deltaKey(directory, part.messageID, part.id))
+                  }
+                  continue
+                }
+                coalesced.set(k, queue.length)
+              }
+              queue.push({ directory, payload })
+              schedule()
+
+              if (Date.now() - yielded < STREAM_YIELD_MS) continue
+              yielded = Date.now()
+              await wait(0)
+            }
+          } catch (error) {
+            if (!aborted(error) && !streamErrorLogged) {
               streamErrorLogged = true
-              console.error("[global-sdk] event stream error", {
+              console.error("[global-sdk] event stream failed", {
                 url: currentServer.http.url,
                 fetch: eventFetch ? "platform" : "webview",
                 error,
               })
-            },
-          })
-          let yielded = Date.now()
-          resetHeartbeat()
-          for await (const event of events.stream) {
-            resetHeartbeat()
-            streamErrorLogged = false
-            const directory = event.directory ?? "global"
-            const payload = event.payload
-            const k = key(directory, payload)
-            if (k) {
-              const i = coalesced.get(k)
-              if (i !== undefined) {
-                queue[i] = { directory, payload }
-                if (payload.type === "message.part.updated") {
-                  const part = payload.properties.part
-                  staleDeltas.add(deltaKey(directory, part.messageID, part.id))
-                }
-                continue
-              }
-              coalesced.set(k, queue.length)
             }
-            queue.push({ directory, payload })
-            schedule()
-
-            if (Date.now() - yielded < STREAM_YIELD_MS) continue
-            yielded = Date.now()
-            await wait(0)
-          }
-        } catch (error) {
-          if (!aborted(error) && !streamErrorLogged) {
-            streamErrorLogged = true
-            console.error("[global-sdk] event stream failed", {
-              url: currentServer.http.url,
-              fetch: eventFetch ? "platform" : "webview",
-              error,
-            })
+          } finally {
+            abort.signal.removeEventListener("abort", onAbort)
+            attempt = undefined
+            clearHeartbeat()
           }
-        } finally {
-          abort.signal.removeEventListener("abort", onAbort)
-          attempt = undefined
-          clearHeartbeat()
+
+          if (abort.signal.aborted || !started) return
+          await wait(RECONNECT_DELAY_MS)
         }
+      })().finally(() => {
+        run = undefined
+        flush()
+      })
+      return run
+    }
 
-        if (abort.signal.aborted) return
-        await wait(RECONNECT_DELAY_MS)
-      }
-    })().finally(flush)
+    const stop = () => {
+      started = false
+      attempt?.abort()
+      clearHeartbeat()
+    }
 
     const onVisibility = () => {
       if (typeof document === "undefined") return
       if (document.visibilityState !== "visible") return
+      if (!started) return
       if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
       attempt?.abort()
     }
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
       if (typeof document !== "undefined") {
         document.removeEventListener("visibilitychange", onVisibility)
       }
+      stop()
       abort.abort()
       flush()
     })
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
     return {
       url: currentServer.http.url,
       client: sdk,
-      event: emitter,
+      event: {
+        on: emitter.on.bind(emitter),
+        listen: emitter.listen.bind(emitter),
+        start,
+      },
       createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
         const s = server.current
         if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))

+ 20 - 0
packages/app/src/context/global-sync.tsx

@@ -72,10 +72,16 @@ function createGlobalSync() {
   let projectWritten = false
   let bootedAt = 0
   let bootingRoot = false
+  let eventFrame: number | undefined
+  let eventTimer: ReturnType<typeof setTimeout> | undefined
 
   onCleanup(() => {
     active = false
   })
+  onCleanup(() => {
+    if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
+    if (eventTimer !== undefined) clearTimeout(eventTimer)
+  })
 
   const cacheProjects = () => {
     setProjectCache(
@@ -348,6 +354,20 @@ function createGlobalSync() {
   }
 
   onMount(() => {
+    if (typeof requestAnimationFrame === "function") {
+      eventFrame = requestAnimationFrame(() => {
+        eventFrame = undefined
+        eventTimer = setTimeout(() => {
+          eventTimer = undefined
+          globalSDK.event.start()
+        }, 0)
+      })
+    } else {
+      eventTimer = setTimeout(() => {
+        eventTimer = undefined
+        globalSDK.event.start()
+      }, 0)
+    }
     void bootstrap()
   })
 

+ 16 - 14
packages/app/src/context/global-sync/bootstrap.ts

@@ -43,8 +43,10 @@ function waitForPaint() {
     const timer = setTimeout(finish, 50)
     if (typeof requestAnimationFrame !== "function") return
     requestAnimationFrame(() => {
-      clearTimeout(timer)
-      finish()
+      setTimeout(() => {
+        clearTimeout(timer)
+        finish()
+      }, 0)
     })
   })
 }
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
   setGlobalStore: SetStoreFunction<GlobalStore>
 }) {
   const fast = [
-    () =>
-      retry(() =>
-        input.globalSDK.path.get().then((x) => {
-          input.setGlobalStore("path", x.data!)
-        }),
-      ),
     () =>
       retry(() =>
         input.globalSDK.global.config.get().then((x) => {
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
   ]
 
   const slow = [
+    () =>
+      retry(() =>
+        input.globalSDK.path.get().then((x) => {
+          input.setGlobalStore("path", x.data!)
+        }),
+      ),
     () =>
       retry(() =>
         input.globalSDK.project.list().then((x) => {
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
   if (loading) input.setStore("status", "partial")
 
   const fast = [
+    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
+    () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+    () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
+  ]
+
+  const slow = [
     () =>
       seededProject
         ? Promise.resolve()
         : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
-    () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
-    () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
     () =>
       seededPath
         ? Promise.resolve()
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
               if (next) input.setStore("project", next)
             }),
           ),
-    () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
     () =>
       retry(() =>
         input.sdk.vcs.get().then((x) => {
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
           )
         }),
       ),
-  ]
-
-  const slow = [
     () => Promise.resolve(input.loadSessions(input.directory)),
     () =>
       retry(() =>

+ 19 - 5
packages/app/src/context/layout.tsx

@@ -544,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       }
     })
 
+    let sessionFrame: number | undefined
+    let sessionTimer: number | undefined
+
     onMount(() => {
-      Promise.all(
-        server.projects.list().map((project) => {
-          return globalSync.project.loadSessions(project.worktree)
-        }),
-      )
+      sessionFrame = requestAnimationFrame(() => {
+        sessionFrame = undefined
+        sessionTimer = window.setTimeout(() => {
+          sessionTimer = undefined
+          void Promise.all(
+            server.projects.list().map((project) => {
+              return globalSync.project.loadSessions(project.worktree)
+            }),
+          )
+        }, 0)
+      })
+    })
+
+    onCleanup(() => {
+      if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
+      if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
     })
 
     return {

+ 9 - 1
packages/app/src/context/server.tsx

@@ -94,7 +94,11 @@ export namespace ServerConnection {
 
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
-  init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
+  init: (props: {
+    defaultServer: ServerConnection.Key
+    disableHealthCheck?: boolean
+    servers?: Array<ServerConnection.Any>
+  }) => {
     const checkServerHealth = useCheckServerHealth()
 
     const [store, setStore, _, ready] = persisted(
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       const current_ = current()
       if (!current_) return
 
+      if (props.disableHealthCheck) {
+        setState("healthy", true)
+        return
+      }
       setState("healthy", undefined)
       onCleanup(startHealthPolling(current_))
     })

+ 20 - 22
packages/app/src/context/settings.tsx

@@ -32,8 +32,8 @@ export interface Settings {
   }
   appearance: {
     fontSize: number
-    font: string
-    uiFont: string
+    mono: string
+    sans: string
   }
   keybinds: Record<string, string>
   permissions: {
@@ -43,20 +43,18 @@ export interface Settings {
   sounds: SoundSettings
 }
 
-export const monoDefault = "IBM Plex Mono"
-export const sansDefault = "Inter"
+export const monoDefault = "System Mono"
+export const sansDefault = "System Sans"
 
 const monoFallback =
   'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
 const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
 
-const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
-const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
-const monoKey = "ibm-plex-mono"
+const monoBase = monoFallback
+const sansBase = sansFallback
 
-function input(font: string | undefined, key?: string) {
-  if (!font || font === key || !font.trim()) return ""
-  return font
+function input(font: string | undefined) {
+  return font ?? ""
 }
 
 function family(font: string) {
@@ -64,14 +62,14 @@ function family(font: string) {
   return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
 }
 
-function stack(font: string | undefined, base: string, key?: string) {
-  const value = input(font, key).trim()
+function stack(font: string | undefined, base: string) {
+  const value = font?.trim() ?? ""
   if (!value) return base
   return `${family(value)}, ${base}`
 }
 
 export function monoInput(font: string | undefined) {
-  return input(font, monoKey)
+  return input(font)
 }
 
 export function sansInput(font: string | undefined) {
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
 }
 
 export function monoFontFamily(font: string | undefined) {
-  return stack(font, monoBase, monoKey)
+  return stack(font, monoBase)
 }
 
 export function sansFontFamily(font: string | undefined) {
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
   },
   appearance: {
     fontSize: 14,
-    font: "",
-    uiFont: "",
+    mono: "",
+    sans: "",
   },
   keybinds: {},
   permissions: {
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
     createEffect(() => {
       if (typeof document === "undefined") return
       const root = document.documentElement
-      root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
-      root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
+      root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
+      root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
     })
 
     return {
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
         setFontSize(value: number) {
           setStore("appearance", "fontSize", value)
         },
-        font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
+        font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
         setFont(value: string) {
-          setStore("appearance", "font", value.trim() ? value : "")
+          setStore("appearance", "mono", value.trim() ? value : "")
         },
-        uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
+        uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
         setUIFont(value: string) {
-          setStore("appearance", "uiFont", value.trim() ? value : "")
+          setStore("appearance", "sans", value.trim() ? value : "")
         },
       },
       keybinds: {

+ 39 - 2
packages/app/src/pages/session.tsx

@@ -544,6 +544,8 @@ export default function Page() {
   let reviewFrame: number | undefined
   let refreshFrame: number | undefined
   let refreshTimer: number | undefined
+  let todoFrame: number | undefined
+  let todoTimer: number | undefined
   let diffFrame: number | undefined
   let diffTimer: number | undefined
 
@@ -718,7 +720,6 @@ export default function Page() {
             if (!info) return true
             return Date.now() - info.at > SESSION_PREFETCH_TTL
           })()
-      const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
       untrack(() => {
         void sync.session.sync(id)
       })
@@ -730,13 +731,47 @@ export default function Page() {
           if (params.id !== id) return
           untrack(() => {
             if (stale) void sync.session.sync(id, { force: true })
-            void sync.session.todo(id, todos ? { force: true } : undefined)
           })
         }, 0)
       })
     }),
   )
 
+  createEffect(
+    on(
+      () => {
+        const id = params.id
+        return [
+          sdk.directory,
+          id,
+          id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
+          id ? composer.blocked() : false,
+        ] as const
+      },
+      ([dir, id, status, blocked]) => {
+        if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
+        if (todoTimer !== undefined) window.clearTimeout(todoTimer)
+        todoFrame = undefined
+        todoTimer = undefined
+        if (!id) return
+        if (status === "idle" && !blocked) return
+        const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
+
+        todoFrame = requestAnimationFrame(() => {
+          todoFrame = undefined
+          todoTimer = window.setTimeout(() => {
+            todoTimer = undefined
+            if (sdk.directory !== dir || params.id !== id) return
+            untrack(() => {
+              void sync.session.todo(id, cached ? { force: true } : undefined)
+            })
+          }, 0)
+        })
+      },
+      { defer: true },
+    ),
+  )
+
   createEffect(
     on(
       () => visibleUserMessages().at(-1)?.id,
@@ -1658,6 +1693,8 @@ export default function Page() {
     if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
     if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
     if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
+    if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
+    if (todoTimer !== undefined) window.clearTimeout(todoTimer)
     if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
     if (diffTimer !== undefined) window.clearTimeout(diffTimer)
     if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)

BIN
packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Bold.woff2


BIN
packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Medium.woff2


BIN
packages/ui/src/assets/fonts/BlexMonoNerdFontMono-Regular.woff2


+ 0 - 1
packages/ui/src/assets/fonts/ibm-plex-mono-bold.woff2

@@ -1 +0,0 @@
-BlexMonoNerdFontMono-Bold.woff2

+ 0 - 1
packages/ui/src/assets/fonts/ibm-plex-mono-medium.woff2

@@ -1 +0,0 @@
-BlexMonoNerdFontMono-Medium.woff2

+ 0 - 1
packages/ui/src/assets/fonts/ibm-plex-mono.woff2

@@ -1 +0,0 @@
-BlexMonoNerdFontMono-Regular.woff2

BIN
packages/ui/src/assets/fonts/inter.woff2


+ 5 - 5
packages/ui/src/components/font.stories.tsx

@@ -2,24 +2,24 @@
 import * as mod from "./font"
 
 const docs = `### Overview
-Loads OpenCode typography assets and mono nerd fonts.
+Uses native system font stacks for sans and mono typography.
 
-Render once at the app root or Storybook preview.
+Optional compatibility component. Existing roots can keep rendering it, but it does nothing.
 
 ### API
 - No props.
 
 ### Variants and states
-- Fonts include sans and multiple mono families.
+- No variants.
 
 ### Behavior
-- Injects @font-face rules and preload links into the document head.
+- Compatibility wrapper only. No font assets are injected or preloaded.
 
 ### Accessibility
 - Not applicable.
 
 ### Theming/tokens
-- Provides font families used by theme tokens.
+- Theme tokens come from CSS variables, not this component.
 
 `
 

+ 1 - 63
packages/ui/src/components/font.tsx

@@ -1,63 +1 @@
-import { Link, Style } from "@solidjs/meta"
-import { Show } from "solid-js"
-import inter from "../assets/fonts/inter.woff2"
-import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2"
-import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2"
-import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2"
-
-export const Font = () => {
-  return (
-    <>
-      <Style>{`
-        @font-face {
-          font-family: "Inter";
-          src: url("${inter}") format("woff2-variations");
-          font-display: swap;
-          font-style: normal;
-          font-weight: 100 900;
-        }
-        @font-face {
-          font-family: "Inter Fallback";
-          src: local("Arial");
-          size-adjust: 100%;
-          ascent-override: 97%;
-          descent-override: 25%;
-          line-gap-override: 1%;
-        }
-        @font-face {
-          font-family: "IBM Plex Mono";
-          src: url("${ibmPlexMonoRegular}") format("woff2");
-          font-display: swap;
-          font-style: normal;
-          font-weight: 400;
-        }
-        @font-face {
-          font-family: "IBM Plex Mono";
-          src: url("${ibmPlexMonoMedium}") format("woff2");
-          font-display: swap;
-          font-style: normal;
-          font-weight: 500;
-        }
-        @font-face {
-          font-family: "IBM Plex Mono";
-          src: url("${ibmPlexMonoBold}") format("woff2");
-          font-display: swap;
-          font-style: normal;
-          font-weight: 700;
-        }
-        @font-face {
-          font-family: "IBM Plex Mono Fallback";
-          src: local("Courier New");
-          size-adjust: 100%;
-          ascent-override: 97%;
-          descent-override: 25%;
-          line-gap-override: 1%;
-        }
-      `}</Style>
-      <Show when={typeof location === "undefined" || location.protocol !== "file:"}>
-        <Link rel="preload" href={inter} as="font" type="font/woff2" crossorigin="anonymous" />
-        <Link rel="preload" href={ibmPlexMonoRegular} as="font" type="font/woff2" crossorigin="anonymous" />
-      </Show>
-    </>
-  )
-}
+export const Font = () => null

+ 5 - 4
packages/ui/src/styles/theme.css

@@ -1,8 +1,9 @@
 :root {
-  --font-family-sans: "Inter", "Inter Fallback";
-  --font-family-sans--font-feature-settings: "ss03" 1;
-  --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
-  --font-family-mono--font-feature-settings: "ss01" 1;
+  --font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  --font-family-sans--font-feature-settings: normal;
+  --font-family-mono:
+    ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  --font-family-mono--font-feature-settings: normal;
 
   --font-size-small: 13px;
   --font-size-base: 14px;