Browse Source

fix(app): session loading loop

Adam 1 month ago
parent
commit
8595dae1a4

+ 19 - 11
packages/app/src/components/session/session-header.tsx

@@ -45,6 +45,8 @@ export function SessionHeader() {
 
   const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
+  const showReview = createMemo(() => !!currentSession()?.summary?.files)
+  const showShare = createMemo(() => shareEnabled() && !!currentSession())
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey()))
 
@@ -172,12 +174,14 @@ export function SessionHeader() {
               {/*   <SessionMcpIndicator /> */}
               {/* </div> */}
               <div class="flex items-center gap-1">
-                <Show when={currentSession()?.summary?.files}>
-                  <TooltipKeybind
-                    class="hidden md:block shrink-0"
-                    title="Toggle review"
-                    keybind={command.keybind("review.toggle")}
-                  >
+                <div
+                  class="hidden md:block shrink-0"
+                  classList={{
+                    "opacity-0 pointer-events-none": !showReview(),
+                  }}
+                  aria-hidden={!showReview()}
+                >
+                  <TooltipKeybind title="Toggle review" keybind={command.keybind("review.toggle")}>
                     <Button
                       variant="ghost"
                       class="group/review-toggle size-6 p-0"
@@ -202,7 +206,7 @@ export function SessionHeader() {
                       </div>
                     </Button>
                   </TooltipKeybind>
-                </Show>
+                </div>
                 <TooltipKeybind
                   class="hidden md:block shrink-0"
                   title="Toggle terminal"
@@ -233,8 +237,13 @@ export function SessionHeader() {
                   </Button>
                 </TooltipKeybind>
               </div>
-              <Show when={shareEnabled() && currentSession()}>
-                <div class="flex items-center">
+              <div
+                class="flex items-center"
+                classList={{
+                  "opacity-0 pointer-events-none": !showShare(),
+                }}
+                aria-hidden={!showShare()}
+              >
                   <Popover
                     title="Publish on web"
                     description={
@@ -308,8 +317,7 @@ export function SessionHeader() {
                       />
                     </Tooltip>
                   </Show>
-                </div>
-              </Show>
+              </div>
             </div>
           </Portal>
         )}

+ 127 - 93
packages/app/src/context/global-sync.tsx

@@ -88,6 +88,10 @@ type VcsCache = {
   ready: Accessor<boolean>
 }
 
+type ChildOptions = {
+  bootstrap?: boolean
+}
+
 function createGlobalSync() {
   const globalSDK = useGlobalSDK()
   const platform = usePlatform()
@@ -127,8 +131,10 @@ function createGlobalSync() {
   })
 
   const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
+  const booting = new Map<string, Promise<void>>()
+  const sessionLoads = new Map<string, Promise<void>>()
 
-  function child(directory: string) {
+  function ensureChild(directory: string) {
     if (!directory) console.error("No directory provided")
     if (!children[directory]) {
       const cache = runWithOwner(owner, () =>
@@ -163,7 +169,6 @@ function createGlobalSync() {
           message: {},
           part: {},
         })
-        bootstrapInstance(directory)
       }
 
       runWithOwner(owner, init)
@@ -173,11 +178,23 @@ function createGlobalSync() {
     return childStore
   }
 
+  function child(directory: string, options: ChildOptions = {}) {
+    const childStore = ensureChild(directory)
+    const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap && childStore[0].status === "loading") {
+      void bootstrapInstance(directory)
+    }
+    return childStore
+  }
+
   async function loadSessions(directory: string) {
-    const [store, setStore] = child(directory)
+    const pending = sessionLoads.get(directory)
+    if (pending) return pending
+
+    const [store, setStore] = child(directory, { bootstrap: false })
     const limit = store.limit
 
-    return globalSDK.client.session
+    const promise = globalSDK.client.session
       .list({ directory, roots: true })
       .then((x) => {
         const nonArchived = (x.data ?? [])
@@ -208,13 +225,23 @@ function createGlobalSync() {
         const project = getFilename(directory)
         showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
       })
+
+    sessionLoads.set(directory, promise)
+    promise.finally(() => {
+      sessionLoads.delete(directory)
+    })
+    return promise
   }
 
   async function bootstrapInstance(directory: string) {
     if (!directory) return
-    const [store, setStore] = child(directory)
-    const cache = vcsCache.get(directory)
-    if (!cache) return
+    const pending = booting.get(directory)
+    if (pending) return pending
+
+    const promise = (async () => {
+      const [store, setStore] = ensureChild(directory)
+      const cache = vcsCache.get(directory)
+      if (!cache) return
     const sdk = createOpencodeClient({
       baseUrl: globalSDK.url,
       fetch: platform.fetch,
@@ -250,98 +277,105 @@ function createGlobalSync() {
       config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
     }
 
-    try {
-      await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
-    } catch (err) {
-      console.error("Failed to bootstrap instance", err)
-      const project = getFilename(directory)
-      const message = err instanceof Error ? err.message : String(err)
-      showToast({ title: `Failed to reload ${project}`, description: message })
-      setStore("status", "partial")
-      return
-    }
+      try {
+        await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+      } catch (err) {
+        console.error("Failed to bootstrap instance", err)
+        const project = getFilename(directory)
+        const message = err instanceof Error ? err.message : String(err)
+        showToast({ title: `Failed to reload ${project}`, description: message })
+        setStore("status", "partial")
+        return
+      }
 
-    if (store.status !== "complete") setStore("status", "partial")
-
-    Promise.all([
-      sdk.path.get().then((x) => setStore("path", x.data!)),
-      sdk.command.list().then((x) => setStore("command", x.data ?? [])),
-      sdk.session.status().then((x) => setStore("session_status", x.data!)),
-      loadSessions(directory),
-      sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
-      sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
-      sdk.vcs.get().then((x) => {
-        const next = x.data ?? store.vcs
-        setStore("vcs", next)
-        if (next?.branch) cache.setStore("value", next)
-      }),
-      sdk.permission.list().then((x) => {
-        const grouped: Record<string, PermissionRequest[]> = {}
-        for (const perm of x.data ?? []) {
-          if (!perm?.id || !perm.sessionID) continue
-          const existing = grouped[perm.sessionID]
-          if (existing) {
-            existing.push(perm)
-            continue
+      if (store.status !== "complete") setStore("status", "partial")
+
+      Promise.all([
+        sdk.path.get().then((x) => setStore("path", x.data!)),
+        sdk.command.list().then((x) => setStore("command", x.data ?? [])),
+        sdk.session.status().then((x) => setStore("session_status", x.data!)),
+        loadSessions(directory),
+        sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
+        sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
+        sdk.vcs.get().then((x) => {
+          const next = x.data ?? store.vcs
+          setStore("vcs", next)
+          if (next?.branch) cache.setStore("value", next)
+        }),
+        sdk.permission.list().then((x) => {
+          const grouped: Record<string, PermissionRequest[]> = {}
+          for (const perm of x.data ?? []) {
+            if (!perm?.id || !perm.sessionID) continue
+            const existing = grouped[perm.sessionID]
+            if (existing) {
+              existing.push(perm)
+              continue
+            }
+            grouped[perm.sessionID] = [perm]
           }
-          grouped[perm.sessionID] = [perm]
-        }
 
-        batch(() => {
-          for (const sessionID of Object.keys(store.permission)) {
-            if (grouped[sessionID]) continue
-            setStore("permission", sessionID, [])
-          }
-          for (const [sessionID, permissions] of Object.entries(grouped)) {
-            setStore(
-              "permission",
-              sessionID,
-              reconcile(
-                permissions
-                  .filter((p) => !!p?.id)
-                  .slice()
-                  .sort((a, b) => a.id.localeCompare(b.id)),
-                { key: "id" },
-              ),
-            )
-          }
-        })
-      }),
-      sdk.question.list().then((x) => {
-        const grouped: Record<string, QuestionRequest[]> = {}
-        for (const question of x.data ?? []) {
-          if (!question?.id || !question.sessionID) continue
-          const existing = grouped[question.sessionID]
-          if (existing) {
-            existing.push(question)
-            continue
+          batch(() => {
+            for (const sessionID of Object.keys(store.permission)) {
+              if (grouped[sessionID]) continue
+              setStore("permission", sessionID, [])
+            }
+            for (const [sessionID, permissions] of Object.entries(grouped)) {
+              setStore(
+                "permission",
+                sessionID,
+                reconcile(
+                  permissions
+                    .filter((p) => !!p?.id)
+                    .slice()
+                    .sort((a, b) => a.id.localeCompare(b.id)),
+                  { key: "id" },
+                ),
+              )
+            }
+          })
+        }),
+        sdk.question.list().then((x) => {
+          const grouped: Record<string, QuestionRequest[]> = {}
+          for (const question of x.data ?? []) {
+            if (!question?.id || !question.sessionID) continue
+            const existing = grouped[question.sessionID]
+            if (existing) {
+              existing.push(question)
+              continue
+            }
+            grouped[question.sessionID] = [question]
           }
-          grouped[question.sessionID] = [question]
-        }
 
-        batch(() => {
-          for (const sessionID of Object.keys(store.question)) {
-            if (grouped[sessionID]) continue
-            setStore("question", sessionID, [])
-          }
-          for (const [sessionID, questions] of Object.entries(grouped)) {
-            setStore(
-              "question",
-              sessionID,
-              reconcile(
-                questions
-                  .filter((q) => !!q?.id)
-                  .slice()
-                  .sort((a, b) => a.id.localeCompare(b.id)),
-                { key: "id" },
-              ),
-            )
-          }
-        })
-      }),
-    ]).then(() => {
-      setStore("status", "complete")
+          batch(() => {
+            for (const sessionID of Object.keys(store.question)) {
+              if (grouped[sessionID]) continue
+              setStore("question", sessionID, [])
+            }
+            for (const [sessionID, questions] of Object.entries(grouped)) {
+              setStore(
+                "question",
+                sessionID,
+                reconcile(
+                  questions
+                    .filter((q) => !!q?.id)
+                    .slice()
+                    .sort((a, b) => a.id.localeCompare(b.id)),
+                  { key: "id" },
+                ),
+              )
+            }
+          })
+        }),
+      ]).then(() => {
+        setStore("status", "complete")
+      })
+    })()
+
+    booting.set(directory, promise)
+    promise.finally(() => {
+      booting.delete(directory)
     })
+    return promise
   }
 
   const unsub = globalSDK.event.listen((e) => {

+ 21 - 3
packages/app/src/pages/layout.tsx

@@ -563,9 +563,13 @@ export default function Layout(props: ParentProps) {
     if (!project) return [] as Session[]
     if (workspaceSetting()) {
       const dirs = workspaceIds(project)
+      const activeDir = params.dir ? base64Decode(params.dir) : ""
       const result: Session[] = []
       for (const dir of dirs) {
-        const [dirStore] = globalSync.child(dir)
+        const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
+        const active = dir === activeDir
+        if (!expanded && !active) continue
+        const [dirStore] = globalSync.child(dir, { bootstrap: true })
         const dirSessions = dirStore.session
           .filter((session) => session.directory === dirStore.path.directory)
           .filter((session) => !session.parentID && !session.time?.archived)
@@ -1238,8 +1242,12 @@ export default function Layout(props: ParentProps) {
     if (!project) return
 
     if (workspaceSetting()) {
+      const activeDir = params.dir ? base64Decode(params.dir) : ""
       const dirs = [project.worktree, ...(project.sandboxes ?? [])]
       for (const directory of dirs) {
+        const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
+        const active = directory === activeDir
+        if (!expanded && !active) continue
         globalSync.project.loadSessions(directory)
       }
       return
@@ -1558,7 +1566,7 @@ export default function Layout(props: ParentProps) {
 
   const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.directory)
-    const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
+    const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
     const [menuOpen, setMenuOpen] = createSignal(false)
     const [pendingRename, setPendingRename] = createSignal(false)
     const slug = createMemo(() => base64Encode(props.directory))
@@ -1569,12 +1577,17 @@ export default function Layout(props: ParentProps) {
         .toSorted(sortSessions),
     )
     const local = createMemo(() => props.directory === props.project.worktree)
+    const active = createMemo(() => {
+      const current = params.dir ? base64Decode(params.dir) : ""
+      return current === props.directory
+    })
     const workspaceValue = createMemo(() => {
       const branch = workspaceStore.vcs?.branch
       const name = branch ?? getFilename(props.directory)
       return workspaceName(props.directory, props.project.id, branch) ?? name
     })
-    const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
+    const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local())
+    const boot = createMemo(() => open() || active())
     const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
     const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
     const loadMore = async () => {
@@ -1591,6 +1604,11 @@ export default function Layout(props: ParentProps) {
       if (editorOpen(`workspace:${props.directory}`)) closeEditor()
     }
 
+    createEffect(() => {
+      if (!boot()) return
+      globalSync.child(props.directory, { bootstrap: true })
+    })
+
     const header = () => (
       <div class="flex items-center gap-1 min-w-0 flex-1">
         <div class="flex items-center justify-center shrink-0 size-6">