2
0
Эх сурвалжийг харах

chore(app): refactor for better solidjs hygiene (#13344)

Adam 2 сар өмнө
parent
commit
da952135ca

+ 2 - 1
packages/app/src/components/prompt-input.tsx

@@ -345,6 +345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       .filter((agent) => !agent.hidden && agent.mode !== "primary")
       .filter((agent) => !agent.hidden && agent.mode !== "primary")
       .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
       .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
   )
   )
+  const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
 
 
   const handleAtSelect = (option: AtOption | undefined) => {
   const handleAtSelect = (option: AtOption | undefined) => {
     if (!option) return
     if (!option) return
@@ -1038,7 +1039,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   keybind={command.keybind("agent.cycle")}
                   keybind={command.keybind("agent.cycle")}
                 >
                 >
                   <Select
                   <Select
-                    options={local.agent.list().map((agent) => agent.name)}
+                    options={agentNames()}
                     current={local.agent.current()?.name ?? ""}
                     current={local.agent.current()?.name ?? ""}
                     onSelect={local.agent.set}
                     onSelect={local.agent.set}
                     class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
                     class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}

+ 11 - 31
packages/app/src/components/question-dock.tsx

@@ -7,32 +7,6 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
 
 
-const writeAt = <T,>(list: T[], index: number, value: T) => {
-  const next = [...list]
-  next[index] = value
-  return next
-}
-
-const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => {
-  return writeAt(list, index, [value])
-}
-
-const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => {
-  const current = list[index] ?? []
-  const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]
-  return writeAt(list, index, next)
-}
-
-const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => {
-  const current = list[index] ?? []
-  if (current.includes(value)) return list
-  return writeAt(list, index, [...current, value])
-}
-
-const writeCustom = (list: string[], index: number, value: string) => {
-  return writeAt(list, index, value)
-}
-
 export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
 export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
   const sdk = useSDK()
   const sdk = useSDK()
   const language = useLanguage()
   const language = useLanguage()
@@ -95,10 +69,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
   }
   }
 
 
   const pick = (answer: string, custom: boolean = false) => {
   const pick = (answer: string, custom: boolean = false) => {
-    setStore("answers", pickAnswer(store.answers, store.tab, answer))
+    setStore("answers", store.tab, [answer])
 
 
     if (custom) {
     if (custom) {
-      setStore("custom", writeCustom(store.custom, store.tab, answer))
+      setStore("custom", store.tab, answer)
     }
     }
 
 
     if (single()) {
     if (single()) {
@@ -110,7 +84,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
   }
   }
 
 
   const toggle = (answer: string) => {
   const toggle = (answer: string) => {
-    setStore("answers", toggleAnswer(store.answers, store.tab, answer))
+    setStore("answers", store.tab, (current = []) => {
+      if (current.includes(answer)) return current.filter((item) => item !== answer)
+      return [...current, answer]
+    })
   }
   }
 
 
   const selectTab = (index: number) => {
   const selectTab = (index: number) => {
@@ -146,7 +123,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
     }
     }
 
 
     if (multi()) {
     if (multi()) {
-      setStore("answers", appendAnswer(store.answers, store.tab, value))
+      setStore("answers", store.tab, (current = []) => {
+        if (current.includes(value)) return current
+        return [...current, value]
+      })
       setStore("editing", false)
       setStore("editing", false)
       return
       return
     }
     }
@@ -239,7 +219,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
                   value={input()}
                   value={input()}
                   disabled={store.sending}
                   disabled={store.sending}
                   onInput={(e) => {
                   onInput={(e) => {
-                    setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value))
+                    setStore("custom", store.tab, e.currentTarget.value)
                   }}
                   }}
                 />
                 />
                 <Button type="submit" variant="primary" size="small" disabled={store.sending}>
                 <Button type="submit" variant="primary" size="small" disabled={store.sending}>

+ 24 - 29
packages/app/src/components/session/session-context-tab.tsx

@@ -168,34 +168,27 @@ export function SessionContextTab(props: SessionContextTabProps) {
     return language.t("context.breakdown.other")
     return language.t("context.breakdown.other")
   }
   }
 
 
-  const stats = createMemo(() => {
-    const c = ctx()
-    const count = counts()
-    return [
-      { label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" },
-      { label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
-      { label: language.t("context.stats.provider"), value: providerLabel() },
-      { label: language.t("context.stats.model"), value: modelLabel() },
-      { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) },
-      { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) },
-      { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) },
-      { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) },
-      { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) },
-      { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) },
-      {
-        label: language.t("context.stats.cacheTokens"),
-        value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`,
-      },
-      { label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
-      {
-        label: language.t("context.stats.assistantMessages"),
-        value: count.assistant.toLocaleString(language.locale()),
-      },
-      { label: language.t("context.stats.totalCost"), value: cost() },
-      { label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) },
-      { label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) },
-    ] satisfies { label: string; value: JSX.Element }[]
-  })
+  const stats = [
+    { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
+    { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
+    { label: "context.stats.provider", value: providerLabel },
+    { label: "context.stats.model", value: modelLabel },
+    { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
+    { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
+    { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
+    { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
+    { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
+    { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
+    {
+      label: "context.stats.cacheTokens",
+      value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
+    },
+    { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
+    { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
+    { label: "context.stats.totalCost", value: cost },
+    { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
+    { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
+  ] satisfies { label: string; value: () => JSX.Element }[]
 
 
   let scroll: HTMLDivElement | undefined
   let scroll: HTMLDivElement | undefined
   let frame: number | undefined
   let frame: number | undefined
@@ -257,7 +250,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
     >
     >
       <div class="px-6 pt-4 flex flex-col gap-10">
       <div class="px-6 pt-4 flex flex-col gap-10">
         <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
         <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
-          <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
+          <For each={stats}>
+            {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
+          </For>
         </div>
         </div>
 
 
         <Show when={breakdown().length > 0}>
         <Show when={breakdown().length > 0}>

+ 20 - 18
packages/app/src/components/session/session-header.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, onCleanup, Show } from "solid-js"
+import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
 import { Portal } from "solid-js/web"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
@@ -404,23 +404,25 @@ export function SessionHeader() {
                                     setPrefs("app", value as OpenApp)
                                     setPrefs("app", value as OpenApp)
                                   }}
                                   }}
                                 >
                                 >
-                                  {options().map((o) => (
-                                    <DropdownMenu.RadioItem
-                                      value={o.id}
-                                      onSelect={() => {
-                                        setMenu("open", false)
-                                        openDir(o.id)
-                                      }}
-                                    >
-                                      <div class="flex size-5 shrink-0 items-center justify-center">
-                                        <AppIcon id={o.icon} class={openIconSize(o.icon)} />
-                                      </div>
-                                      <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
-                                      <DropdownMenu.ItemIndicator>
-                                        <Icon name="check-small" size="small" class="text-icon-weak" />
-                                      </DropdownMenu.ItemIndicator>
-                                    </DropdownMenu.RadioItem>
-                                  ))}
+                                  <For each={options()}>
+                                    {(o) => (
+                                      <DropdownMenu.RadioItem
+                                        value={o.id}
+                                        onSelect={() => {
+                                          setMenu("open", false)
+                                          openDir(o.id)
+                                        }}
+                                      >
+                                        <div class="flex size-5 shrink-0 items-center justify-center">
+                                          <AppIcon id={o.icon} class={openIconSize(o.icon)} />
+                                        </div>
+                                        <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
+                                        <DropdownMenu.ItemIndicator>
+                                          <Icon name="check-small" size="small" class="text-icon-weak" />
+                                        </DropdownMenu.ItemIndicator>
+                                      </DropdownMenu.RadioItem>
+                                    )}
+                                  </For>
                                 </DropdownMenu.RadioGroup>
                                 </DropdownMenu.RadioGroup>
                               </DropdownMenu.Group>
                               </DropdownMenu.Group>
                               <DropdownMenu.Separator />
                               <DropdownMenu.Separator />

+ 21 - 20
packages/app/src/components/status-popover.tsx

@@ -173,12 +173,9 @@ export function StatusPopover() {
   const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
   const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
   const mcp = useMcpToggle({ sync, sdk, language })
   const mcp = useMcpToggle({ sync, sdk, language })
   const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
   const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
-  const mcpItems = createMemo(() =>
-    Object.entries(sync.data.mcp ?? {})
-      .map(([name, status]) => ({ name, status: status.status }))
-      .sort((a, b) => a.name.localeCompare(b.name)),
-  )
-  const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length)
+  const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
+  const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
+  const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
   const lspItems = createMemo(() => sync.data.lsp ?? [])
   const lspItems = createMemo(() => sync.data.lsp ?? [])
   const lspCount = createMemo(() => lspItems().length)
   const lspCount = createMemo(() => lspItems().length)
   const plugins = createMemo(() => sync.data.config.plugin ?? [])
   const plugins = createMemo(() => sync.data.config.plugin ?? [])
@@ -186,7 +183,10 @@ export function StatusPopover() {
   const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
   const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
   const overallHealthy = createMemo(() => {
   const overallHealthy = createMemo(() => {
     const serverHealthy = server.healthy() === true
     const serverHealthy = server.healthy() === true
-    const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled")
+    const anyMcpIssue = mcpNames().some((name) => {
+      const status = mcpStatus(name)
+      return status !== "connected" && status !== "disabled"
+    })
     return serverHealthy && !anyMcpIssue
     return serverHealthy && !anyMcpIssue
   })
   })
 
 
@@ -306,39 +306,40 @@ export function StatusPopover() {
             <div class="flex flex-col px-2 pb-2">
             <div class="flex flex-col px-2 pb-2">
               <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
               <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
                 <Show
                 <Show
-                  when={mcpItems().length > 0}
+                  when={mcpNames().length > 0}
                   fallback={
                   fallback={
                     <div class="text-14-regular text-text-base text-center my-auto">
                     <div class="text-14-regular text-text-base text-center my-auto">
                       {language.t("dialog.mcp.empty")}
                       {language.t("dialog.mcp.empty")}
                     </div>
                     </div>
                   }
                   }
                 >
                 >
-                  <For each={mcpItems()}>
-                    {(item) => {
-                      const enabled = () => item.status === "connected"
+                  <For each={mcpNames()}>
+                    {(name) => {
+                      const status = () => mcpStatus(name)
+                      const enabled = () => status() === "connected"
                       return (
                       return (
                         <button
                         <button
                           type="button"
                           type="button"
                           class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
                           class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
-                          onClick={() => mcp.toggle(item.name)}
-                          disabled={mcp.loading() === item.name}
+                          onClick={() => mcp.toggle(name)}
+                          disabled={mcp.loading() === name}
                         >
                         >
                           <div
                           <div
                             classList={{
                             classList={{
                               "size-1.5 rounded-full shrink-0": true,
                               "size-1.5 rounded-full shrink-0": true,
-                              "bg-icon-success-base": item.status === "connected",
-                              "bg-icon-critical-base": item.status === "failed",
-                              "bg-border-weak-base": item.status === "disabled",
+                              "bg-icon-success-base": status() === "connected",
+                              "bg-icon-critical-base": status() === "failed",
+                              "bg-border-weak-base": status() === "disabled",
                               "bg-icon-warning-base":
                               "bg-icon-warning-base":
-                                item.status === "needs_auth" || item.status === "needs_client_registration",
+                                status() === "needs_auth" || status() === "needs_client_registration",
                             }}
                             }}
                           />
                           />
-                          <span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
+                          <span class="text-14-regular text-text-base truncate flex-1">{name}</span>
                           <div onClick={(event) => event.stopPropagation()}>
                           <div onClick={(event) => event.stopPropagation()}>
                             <Switch
                             <Switch
                               checked={enabled()}
                               checked={enabled()}
-                              disabled={mcp.loading() === item.name}
-                              onChange={() => mcp.toggle(item.name)}
+                              disabled={mcp.loading() === name}
+                              onChange={() => mcp.toggle(name)}
                             />
                             />
                           </div>
                           </div>
                         </button>
                         </button>

+ 31 - 21
packages/app/src/context/file/view-cache.ts

@@ -23,6 +23,16 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
   }
   }
 }
 }
 
 
+function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) {
+  if (!a && !b) return true
+  if (!a || !b) return false
+  const left = normalizeSelectedLines(a)
+  const right = normalizeSelectedLines(b)
+  return (
+    left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide
+  )
+}
+
 function createViewSession(dir: string, id: string | undefined) {
 function createViewSession(dir: string, id: string | undefined) {
   const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
   const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
 
 
@@ -65,36 +75,36 @@ function createViewSession(dir: string, id: string | undefined) {
   const selectedLines = (path: string) => view.file[path]?.selectedLines
   const selectedLines = (path: string) => view.file[path]?.selectedLines
 
 
   const setScrollTop = (path: string, top: number) => {
   const setScrollTop = (path: string, top: number) => {
-    setView("file", path, (current) => {
-      if (current?.scrollTop === top) return current
-      return {
-        ...(current ?? {}),
-        scrollTop: top,
-      }
-    })
+    setView(
+      produce((draft) => {
+        const file = draft.file[path] ?? (draft.file[path] = {})
+        if (file.scrollTop === top) return
+        file.scrollTop = top
+      }),
+    )
     pruneView(path)
     pruneView(path)
   }
   }
 
 
   const setScrollLeft = (path: string, left: number) => {
   const setScrollLeft = (path: string, left: number) => {
-    setView("file", path, (current) => {
-      if (current?.scrollLeft === left) return current
-      return {
-        ...(current ?? {}),
-        scrollLeft: left,
-      }
-    })
+    setView(
+      produce((draft) => {
+        const file = draft.file[path] ?? (draft.file[path] = {})
+        if (file.scrollLeft === left) return
+        file.scrollLeft = left
+      }),
+    )
     pruneView(path)
     pruneView(path)
   }
   }
 
 
   const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
   const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
     const next = range ? normalizeSelectedLines(range) : null
     const next = range ? normalizeSelectedLines(range) : null
-    setView("file", path, (current) => {
-      if (current?.selectedLines === next) return current
-      return {
-        ...(current ?? {}),
-        selectedLines: next,
-      }
-    })
+    setView(
+      produce((draft) => {
+        const file = draft.file[path] ?? (draft.file[path] = {})
+        if (equalSelectedLines(file.selectedLines, next)) return
+        file.selectedLines = next
+      }),
+    )
     pruneView(path)
     pruneView(path)
   }
   }
 
 

+ 1 - 0
packages/app/src/context/global-sync/event-reducer.ts

@@ -233,6 +233,7 @@ export function applyDirectoryEvent(input: {
     }
     }
     case "vcs.branch.updated": {
     case "vcs.branch.updated": {
       const props = event.properties as { branch: string }
       const props = event.properties as { branch: string }
+      if (input.store.vcs?.branch === props.branch) break
       const next = { branch: props.branch }
       const next = { branch: props.branch }
       input.setStore("vcs", next)
       input.setStore("vcs", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)
       if (input.vcsCache) input.vcsCache.setStore("value", next)

+ 168 - 17
packages/app/src/context/notification.tsx

@@ -1,5 +1,5 @@
-import { createStore } from "solid-js/store"
-import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { useParams } from "@solidjs/router"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSDK } from "./global-sdk"
@@ -13,7 +13,6 @@ import { decode64 } from "@/utils/base64"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
 import { Persist, persisted } from "@/utils/persist"
 import { playSound, soundSrc } from "@/utils/sound"
 import { playSound, soundSrc } from "@/utils/sound"
-import { buildNotificationIndex } from "./notification-index"
 
 
 type NotificationBase = {
 type NotificationBase = {
   directory?: string
   directory?: string
@@ -34,6 +33,21 @@ type ErrorNotification = NotificationBase & {
 
 
 export type Notification = TurnCompleteNotification | ErrorNotification
 export type Notification = TurnCompleteNotification | ErrorNotification
 
 
+type NotificationIndex = {
+  session: {
+    all: Record<string, Notification[]>
+    unseen: Record<string, Notification[]>
+    unseenCount: Record<string, number>
+    unseenHasError: Record<string, boolean>
+  }
+  project: {
+    all: Record<string, Notification[]>
+    unseen: Record<string, Notification[]>
+    unseenCount: Record<string, number>
+    unseenHasError: Record<string, boolean>
+  }
+}
+
 const MAX_NOTIFICATIONS = 500
 const MAX_NOTIFICATIONS = 500
 const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
 const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
 
 
@@ -44,6 +58,53 @@ function pruneNotifications(list: Notification[]) {
   return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
   return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
 }
 }
 
 
+function createNotificationIndex(): NotificationIndex {
+  return {
+    session: {
+      all: {},
+      unseen: {},
+      unseenCount: {},
+      unseenHasError: {},
+    },
+    project: {
+      all: {},
+      unseen: {},
+      unseenCount: {},
+      unseenHasError: {},
+    },
+  }
+}
+
+function buildNotificationIndex(list: Notification[]) {
+  const index = createNotificationIndex()
+
+  list.forEach((notification) => {
+    if (notification.session) {
+      const all = index.session.all[notification.session] ?? []
+      index.session.all[notification.session] = [...all, notification]
+      if (!notification.viewed) {
+        const unseen = index.session.unseen[notification.session] ?? []
+        index.session.unseen[notification.session] = [...unseen, notification]
+        index.session.unseenCount[notification.session] = unseen.length + 1
+        if (notification.type === "error") index.session.unseenHasError[notification.session] = true
+      }
+    }
+
+    if (notification.directory) {
+      const all = index.project.all[notification.directory] ?? []
+      index.project.all[notification.directory] = [...all, notification]
+      if (!notification.viewed) {
+        const unseen = index.project.unseen[notification.directory] ?? []
+        index.project.unseen[notification.directory] = [...unseen, notification]
+        index.project.unseenCount[notification.directory] = unseen.length + 1
+        if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
+      }
+    }
+  })
+
+  return index
+}
+
 export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
 export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
   name: "Notification",
   name: "Notification",
   init: () => {
   init: () => {
@@ -68,21 +129,81 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         list: [] as Notification[],
         list: [] as Notification[],
       }),
       }),
     )
     )
+    const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
 
 
     const meta = { pruned: false, disposed: false }
     const meta = { pruned: false, disposed: false }
 
 
+    const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
+      setIndex(scope, "unseen", key, unseen)
+      setIndex(scope, "unseenCount", key, unseen.length)
+      setIndex(
+        scope,
+        "unseenHasError",
+        key,
+        unseen.some((notification) => notification.type === "error"),
+      )
+    }
+
+    const appendToIndex = (notification: Notification) => {
+      if (notification.session) {
+        setIndex("session", "all", notification.session, (all = []) => [...all, notification])
+        if (!notification.viewed) {
+          setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
+          setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
+          if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
+        }
+      }
+
+      if (notification.directory) {
+        setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
+        if (!notification.viewed) {
+          setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
+          setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
+          if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
+        }
+      }
+    }
+
+    const removeFromIndex = (notification: Notification) => {
+      if (notification.session) {
+        setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
+        if (!notification.viewed) {
+          const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
+          updateUnseen("session", notification.session, unseen)
+        }
+      }
+
+      if (notification.directory) {
+        setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
+        if (!notification.viewed) {
+          const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
+          updateUnseen("project", notification.directory, unseen)
+        }
+      }
+    }
+
     createEffect(() => {
     createEffect(() => {
       if (!ready()) return
       if (!ready()) return
       if (meta.pruned) return
       if (meta.pruned) return
       meta.pruned = true
       meta.pruned = true
-      setStore("list", pruneNotifications(store.list))
+      const list = pruneNotifications(store.list)
+      batch(() => {
+        setStore("list", list)
+        setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
+      })
     })
     })
 
 
     const append = (notification: Notification) => {
     const append = (notification: Notification) => {
-      setStore("list", (list) => pruneNotifications([...list, notification]))
-    }
+      const list = pruneNotifications([...store.list, notification])
+      const keep = new Set(list)
+      const removed = store.list.filter((n) => !keep.has(n))
 
 
-    const index = createMemo(() => buildNotificationIndex(store.list))
+      batch(() => {
+        if (keep.has(notification)) appendToIndex(notification)
+        removed.forEach((n) => removeFromIndex(n))
+        setStore("list", list)
+      })
+    }
 
 
     const lookup = async (directory: string, sessionID?: string) => {
     const lookup = async (directory: string, sessionID?: string) => {
       if (!sessionID) return undefined
       if (!sessionID) return undefined
@@ -181,36 +302,66 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
       ready,
       ready,
       session: {
       session: {
         all(session: string) {
         all(session: string) {
-          return index().session.all.get(session) ?? empty
+          return index.session.all[session] ?? empty
         },
         },
         unseen(session: string) {
         unseen(session: string) {
-          return index().session.unseen.get(session) ?? empty
+          return index.session.unseen[session] ?? empty
         },
         },
         unseenCount(session: string) {
         unseenCount(session: string) {
-          return index().session.unseenCount.get(session) ?? 0
+          return index.session.unseenCount[session] ?? 0
         },
         },
         unseenHasError(session: string) {
         unseenHasError(session: string) {
-          return index().session.unseenHasError.get(session) ?? false
+          return index.session.unseenHasError[session] ?? false
         },
         },
         markViewed(session: string) {
         markViewed(session: string) {
-          setStore("list", (n) => n.session === session, "viewed", true)
+          const unseen = index.session.unseen[session] ?? empty
+          if (!unseen.length) return
+
+          const projects = [
+            ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
+          ]
+          batch(() => {
+            setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
+            updateUnseen("session", session, [])
+            projects.forEach((directory) => {
+              const next = (index.project.unseen[directory] ?? empty).filter(
+                (notification) => notification.session !== session,
+              )
+              updateUnseen("project", directory, next)
+            })
+          })
         },
         },
       },
       },
       project: {
       project: {
         all(directory: string) {
         all(directory: string) {
-          return index().project.all.get(directory) ?? empty
+          return index.project.all[directory] ?? empty
         },
         },
         unseen(directory: string) {
         unseen(directory: string) {
-          return index().project.unseen.get(directory) ?? empty
+          return index.project.unseen[directory] ?? empty
         },
         },
         unseenCount(directory: string) {
         unseenCount(directory: string) {
-          return index().project.unseenCount.get(directory) ?? 0
+          return index.project.unseenCount[directory] ?? 0
         },
         },
         unseenHasError(directory: string) {
         unseenHasError(directory: string) {
-          return index().project.unseenHasError.get(directory) ?? false
+          return index.project.unseenHasError[directory] ?? false
         },
         },
         markViewed(directory: string) {
         markViewed(directory: string) {
-          setStore("list", (n) => n.directory === directory, "viewed", true)
+          const unseen = index.project.unseen[directory] ?? empty
+          if (!unseen.length) return
+
+          const sessions = [
+            ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
+          ]
+          batch(() => {
+            setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
+            updateUnseen("project", directory, [])
+            sessions.forEach((session) => {
+              const next = (index.session.unseen[session] ?? empty).filter(
+                (notification) => notification.directory !== directory,
+              )
+              updateUnseen("session", session, next)
+            })
+          })
         },
         },
       },
       },
     }
     }

+ 30 - 19
packages/app/src/context/terminal.tsx

@@ -101,11 +101,15 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
     const all = store.all
     const all = store.all
     const index = all.findIndex((x) => x.id === id)
     const index = all.findIndex((x) => x.id === id)
     if (index === -1) return
     if (index === -1) return
-    const filtered = all.filter((x) => x.id !== id)
-    const active = store.active === id ? filtered[0]?.id : store.active
+    const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
     batch(() => {
     batch(() => {
-      setStore("all", filtered)
       setStore("active", active)
       setStore("active", active)
+      setStore(
+        "all",
+        produce((draft) => {
+          draft.splice(index, 1)
+        }),
+      )
     })
     })
   }
   }
 
 
@@ -157,10 +161,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
             title: pty.data?.title ?? "Terminal",
             title: pty.data?.title ?? "Terminal",
             titleNumber: nextNumber,
             titleNumber: nextNumber,
           }
           }
-          setStore("all", (all) => {
-            const newAll = [...all, newTerminal]
-            return newAll
-          })
+          setStore("all", store.all.length, newTerminal)
           setStore("active", id)
           setStore("active", id)
         })
         })
         .catch((error: unknown) => {
         .catch((error: unknown) => {
@@ -168,8 +169,11 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
         })
         })
     },
     },
     update(pty: Partial<LocalPTY> & { id: string }) {
     update(pty: Partial<LocalPTY> & { id: string }) {
-      const previous = store.all.find((x) => x.id === pty.id)
-      if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item)))
+      const index = store.all.findIndex((x) => x.id === pty.id)
+      const previous = index >= 0 ? store.all[index] : undefined
+      if (index >= 0) {
+        setStore("all", index, (item) => ({ ...item, ...pty }))
+      }
       sdk.client.pty
       sdk.client.pty
         .update({
         .update({
           ptyID: pty.id,
           ptyID: pty.id,
@@ -178,7 +182,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
         })
         })
         .catch((error: unknown) => {
         .catch((error: unknown) => {
           if (previous) {
           if (previous) {
-            setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item)))
+            const currentIndex = store.all.findIndex((item) => item.id === pty.id)
+            if (currentIndex >= 0) setStore("all", currentIndex, previous)
           }
           }
           console.error("Failed to update terminal", error)
           console.error("Failed to update terminal", error)
         })
         })
@@ -232,15 +237,21 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
       setStore("active", store.all[prevIndex]?.id)
       setStore("active", store.all[prevIndex]?.id)
     },
     },
     async close(id: string) {
     async close(id: string) {
-      batch(() => {
-        const filtered = store.all.filter((x) => x.id !== id)
-        if (store.active === id) {
-          const index = store.all.findIndex((f) => f.id === id)
-          const next = index > 0 ? index - 1 : 0
-          setStore("active", filtered[next]?.id)
-        }
-        setStore("all", filtered)
-      })
+      const index = store.all.findIndex((f) => f.id === id)
+      if (index !== -1) {
+        batch(() => {
+          if (store.active === id) {
+            const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
+            setStore("active", next)
+          }
+          setStore(
+            "all",
+            produce((all) => {
+              all.splice(index, 1)
+            }),
+          )
+        })
+      }
 
 
       await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
       await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
         console.error("Failed to close terminal", error)
         console.error("Failed to close terminal", error)

+ 4 - 2
packages/app/src/hooks/use-providers.ts

@@ -4,6 +4,7 @@ import { useParams } from "@solidjs/router"
 import { createMemo } from "solid-js"
 import { createMemo } from "solid-js"
 
 
 export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
 export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
+const popularProviderSet = new Set(popularProviders)
 
 
 export function useProviders() {
 export function useProviders() {
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
@@ -16,11 +17,12 @@ export function useProviders() {
     }
     }
     return globalSync.data.provider
     return globalSync.data.provider
   })
   })
-  const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id)))
+  const connectedIDs = createMemo(() => new Set(providers().connected))
+  const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
   const paid = createMemo(() =>
   const paid = createMemo(() =>
     connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
     connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
   )
   )
-  const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
+  const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
   return {
   return {
     all: createMemo(() => providers().all),
     all: createMemo(() => providers().all),
     default: createMemo(() => providers().default),
     default: createMemo(() => providers().default),

+ 65 - 18
packages/app/src/pages/layout.tsx

@@ -2,6 +2,7 @@ import {
   batch,
   batch,
   createEffect,
   createEffect,
   createMemo,
   createMemo,
+  createSignal,
   For,
   For,
   on,
   on,
   onCleanup,
   onCleanup,
@@ -124,7 +125,7 @@ export default function Layout(props: ParentProps) {
 
 
   const [state, setState] = createStore({
   const [state, setState] = createStore({
     autoselect: !initialDirectory,
     autoselect: !initialDirectory,
-    busyWorkspaces: new Set<string>(),
+    busyWorkspaces: {} as Record<string, boolean>,
     hoverSession: undefined as string | undefined,
     hoverSession: undefined as string | undefined,
     hoverProject: undefined as string | undefined,
     hoverProject: undefined as string | undefined,
     scrollSessionKey: undefined as string | undefined,
     scrollSessionKey: undefined as string | undefined,
@@ -134,15 +135,28 @@ export default function Layout(props: ParentProps) {
   const editor = createInlineEditorController()
   const editor = createInlineEditorController()
   const setBusy = (directory: string, value: boolean) => {
   const setBusy = (directory: string, value: boolean) => {
     const key = workspaceKey(directory)
     const key = workspaceKey(directory)
-    setState("busyWorkspaces", (prev) => {
-      const next = new Set(prev)
-      if (value) next.add(key)
-      else next.delete(key)
-      return next
-    })
+    if (value) {
+      setState("busyWorkspaces", key, true)
+      return
+    }
+    setState(
+      "busyWorkspaces",
+      produce((draft) => {
+        delete draft[key]
+      }),
+    )
   }
   }
-  const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory))
+  const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
   const navLeave = { current: undefined as number | undefined }
   const navLeave = { current: undefined as number | undefined }
+  const [sortNow, setSortNow] = createSignal(Date.now())
+  let sortNowInterval: ReturnType<typeof setInterval> | undefined
+  const sortNowTimeout = setTimeout(
+    () => {
+      setSortNow(Date.now())
+      sortNowInterval = setInterval(() => setSortNow(Date.now()), 60_000)
+    },
+    60_000 - (Date.now() % 60_000),
+  )
 
 
   const aim = createAim({
   const aim = createAim({
     enabled: () => !layout.sidebar.opened(),
     enabled: () => !layout.sidebar.opened(),
@@ -157,6 +171,8 @@ export default function Layout(props: ParentProps) {
 
 
   onCleanup(() => {
   onCleanup(() => {
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
     if (navLeave.current !== undefined) clearTimeout(navLeave.current)
+    clearTimeout(sortNowTimeout)
+    if (sortNowInterval) clearInterval(sortNowInterval)
     aim.reset()
     aim.reset()
   })
   })
 
 
@@ -518,10 +534,13 @@ export default function Layout(props: ParentProps) {
 
 
   const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
   const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
     const key = workspaceKey(directory)
     const key = workspaceKey(directory)
-    setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next }))
+    setStore("workspaceName", key, next)
     if (!projectId) return
     if (!projectId) return
     if (!branch) return
     if (!branch) return
-    setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next }))
+    if (!store.workspaceBranchName[projectId]) {
+      setStore("workspaceBranchName", projectId, {})
+    }
+    setStore("workspaceBranchName", projectId, branch, next)
   }
   }
 
 
   const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
   const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
@@ -1447,23 +1466,41 @@ export default function Layout(props: ParentProps) {
     document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
     document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
   })
   })
 
 
+  const loadedSessionDirs = new Set<string>()
+
   createEffect(() => {
   createEffect(() => {
     const project = currentProject()
     const project = currentProject()
-    if (!project) return
+    const workspaces = workspaceSetting()
+    const next = new Set<string>()
+    if (!project) {
+      loadedSessionDirs.clear()
+      return
+    }
 
 
-    if (workspaceSetting()) {
+    if (workspaces) {
       const activeDir = currentDir()
       const activeDir = currentDir()
       const dirs = [project.worktree, ...(project.sandboxes ?? [])]
       const dirs = [project.worktree, ...(project.sandboxes ?? [])]
       for (const directory of dirs) {
       for (const directory of dirs) {
         const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
         const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
         const active = directory === activeDir
         const active = directory === activeDir
         if (!expanded && !active) continue
         if (!expanded && !active) continue
-        globalSync.project.loadSessions(directory)
+        next.add(directory)
       }
       }
-      return
     }
     }
 
 
-    globalSync.project.loadSessions(project.worktree)
+    if (!workspaces) {
+      next.add(project.worktree)
+    }
+
+    for (const directory of next) {
+      if (loadedSessionDirs.has(directory)) continue
+      globalSync.project.loadSessions(directory)
+    }
+
+    loadedSessionDirs.clear()
+    for (const directory of next) {
+      loadedSessionDirs.add(directory)
+    }
   })
   })
 
 
   function handleDragStart(event: unknown) {
   function handleDragStart(event: unknown) {
@@ -1766,7 +1803,12 @@ export default function Layout(props: ParentProps) {
                         </TooltipKeybind>
                         </TooltipKeybind>
                       </div>
                       </div>
                       <div class="flex-1 min-h-0">
                       <div class="flex-1 min-h-0">
-                        <LocalWorkspace ctx={workspaceSidebarCtx} project={p()} mobile={panelProps.mobile} />
+                        <LocalWorkspace
+                          ctx={workspaceSidebarCtx}
+                          project={p()}
+                          sortNow={sortNow}
+                          mobile={panelProps.mobile}
+                        />
                       </div>
                       </div>
                     </>
                     </>
                   }
                   }
@@ -1805,6 +1847,7 @@ export default function Layout(props: ParentProps) {
                                   ctx={workspaceSidebarCtx}
                                   ctx={workspaceSidebarCtx}
                                   directory={directory}
                                   directory={directory}
                                   project={p()}
                                   project={p()}
+                                  sortNow={sortNow}
                                   mobile={panelProps.mobile}
                                   mobile={panelProps.mobile}
                                 />
                                 />
                               )}
                               )}
@@ -1890,7 +1933,9 @@ export default function Layout(props: ParentProps) {
               opened={() => layout.sidebar.opened()}
               opened={() => layout.sidebar.opened()}
               aimMove={aim.move}
               aimMove={aim.move}
               projects={() => layout.projects.list()}
               projects={() => layout.projects.list()}
-              renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} />}
+              renderProject={(project) => (
+                <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
+              )}
               handleDragStart={handleDragStart}
               handleDragStart={handleDragStart}
               handleDragEnd={handleDragEnd}
               handleDragEnd={handleDragEnd}
               handleDragOver={handleDragOver}
               handleDragOver={handleDragOver}
@@ -1953,7 +1998,9 @@ export default function Layout(props: ParentProps) {
               opened={() => layout.sidebar.opened()}
               opened={() => layout.sidebar.opened()}
               aimMove={aim.move}
               aimMove={aim.move}
               projects={() => layout.projects.list()}
               projects={() => layout.projects.list()}
-              renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} mobile />}
+              renderProject={(project) => (
+                <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
+              )}
               handleDragStart={handleDragStart}
               handleDragStart={handleDragStart}
               handleDragEnd={handleDragEnd}
               handleDragEnd={handleDragEnd}
               handleDragOver={handleDragOver}
               handleDragOver={handleDragOver}

+ 3 - 2
packages/app/src/pages/layout/sidebar-project.tsx

@@ -244,6 +244,7 @@ export const SortableProject = (props: {
   project: LocalProject
   project: LocalProject
   mobile?: boolean
   mobile?: boolean
   ctx: ProjectSidebarContext
   ctx: ProjectSidebarContext
+  sortNow: Accessor<number>
 }): JSX.Element => {
 }): JSX.Element => {
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
   const language = useLanguage()
   const language = useLanguage()
@@ -284,11 +285,11 @@ export const SortableProject = (props: {
   }
   }
 
 
   const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
   const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
-  const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2))
+  const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2))
   const projectChildren = createMemo(() => childMapByParent(projectStore().session))
   const projectChildren = createMemo(() => childMapByParent(projectStore().session))
   const workspaceSessions = (directory: string) => {
   const workspaceSessions = (directory: string) => {
     const [data] = globalSync.child(directory, { bootstrap: false })
     const [data] = globalSync.child(directory, { bootstrap: false })
-    return sortedRootSessions(data, Date.now()).slice(0, 2)
+    return sortedRootSessions(data, props.sortNow()).slice(0, 2)
   }
   }
   const workspaceChildren = (directory: string) => {
   const workspaceChildren = (directory: string) => {
     const [data] = globalSync.child(directory, { bootstrap: false })
     const [data] = globalSync.child(directory, { bootstrap: false })

+ 4 - 2
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -302,6 +302,7 @@ export const SortableWorkspace = (props: {
   ctx: WorkspaceSidebarContext
   ctx: WorkspaceSidebarContext
   directory: string
   directory: string
   project: LocalProject
   project: LocalProject
+  sortNow: Accessor<number>
   mobile?: boolean
   mobile?: boolean
 }): JSX.Element => {
 }): JSX.Element => {
   const navigate = useNavigate()
   const navigate = useNavigate()
@@ -315,7 +316,7 @@ export const SortableWorkspace = (props: {
     pendingRename: false,
     pendingRename: false,
   })
   })
   const slug = createMemo(() => base64Encode(props.directory))
   const slug = createMemo(() => base64Encode(props.directory))
-  const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now()))
+  const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
   const children = createMemo(() => childMapByParent(workspaceStore.session))
   const children = createMemo(() => childMapByParent(workspaceStore.session))
   const local = createMemo(() => props.directory === props.project.worktree)
   const local = createMemo(() => props.directory === props.project.worktree)
   const active = createMemo(() => props.ctx.currentDir() === props.directory)
   const active = createMemo(() => props.ctx.currentDir() === props.directory)
@@ -464,6 +465,7 @@ export const SortableWorkspace = (props: {
 export const LocalWorkspace = (props: {
 export const LocalWorkspace = (props: {
   ctx: WorkspaceSidebarContext
   ctx: WorkspaceSidebarContext
   project: LocalProject
   project: LocalProject
+  sortNow: Accessor<number>
   mobile?: boolean
   mobile?: boolean
 }): JSX.Element => {
 }): JSX.Element => {
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
@@ -473,7 +475,7 @@ export const LocalWorkspace = (props: {
     return { store, setStore }
     return { store, setStore }
   })
   })
   const slug = createMemo(() => base64Encode(props.project.worktree))
   const slug = createMemo(() => base64Encode(props.project.worktree))
-  const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now()))
+  const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
   const children = createMemo(() => childMapByParent(workspace().store.session))
   const children = createMemo(() => childMapByParent(workspace().store.session))
   const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
   const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
   const loading = createMemo(() => !booted() && sessions().length === 0)
   const loading = createMemo(() => !booted() && sessions().length === 0)

+ 24 - 3
packages/app/src/pages/session/file-tabs.tsx

@@ -1,5 +1,5 @@
 import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
 import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, produce } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { Dynamic } from "solid-js/web"
 import { checksum } from "@opencode-ai/util/encode"
 import { checksum } from "@opencode-ai/util/encode"
 import { decode64 } from "@/utils/base64"
 import { decode64 } from "@/utils/base64"
@@ -112,6 +112,12 @@ export function FileTabContent(props: {
     return props.comments.list(p)
     return props.comments.list(p)
   })
   })
 
 
+  const commentLayout = createMemo(() => {
+    return fileComments()
+      .map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
+      .join("|")
+  })
+
   const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
   const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
 
 
   const [note, setNote] = createStore({
   const [note, setNote] = createStore({
@@ -164,7 +170,22 @@ export function FileTabContent(props: {
       next[comment.id] = markerTop(el, marker)
       next[comment.id] = markerTop(el, marker)
     }
     }
 
 
-    setNote("positions", next)
+    const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
+    const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
+    if (removed.length > 0 || changed.length > 0) {
+      setNote(
+        "positions",
+        produce((draft) => {
+          for (const id of removed) {
+            delete draft[id]
+          }
+
+          for (const [id, top] of changed) {
+            draft[id] = top
+          }
+        }),
+      )
+    }
 
 
     const range = note.commenting
     const range = note.commenting
     if (!range) {
     if (!range) {
@@ -186,7 +207,7 @@ export function FileTabContent(props: {
   }
   }
 
 
   createEffect(() => {
   createEffect(() => {
-    fileComments()
+    commentLayout()
     scheduleComments()
     scheduleComments()
   })
   })
 
 

+ 6 - 3
packages/app/src/pages/session/helpers.ts

@@ -1,4 +1,5 @@
 import type { CommandOption } from "@/context/command"
 import type { CommandOption } from "@/context/command"
+import { batch } from "solid-js"
 
 
 export const focusTerminalById = (id: string) => {
 export const focusTerminalById = (id: string) => {
   const wrapper = document.getElementById(`terminal-wrapper-${id}`)
   const wrapper = document.getElementById(`terminal-wrapper-${id}`)
@@ -27,9 +28,11 @@ export const createOpenReviewFile = (input: {
   loadFile: (path: string) => void
   loadFile: (path: string) => void
 }) => {
 }) => {
   return (path: string) => {
   return (path: string) => {
-    input.showAllFiles()
-    input.openTab(input.tabForPath(path))
-    input.loadFile(path)
+    batch(() => {
+      input.showAllFiles()
+      input.openTab(input.tabForPath(path))
+      input.loadFile(path)
+    })
   }
   }
 }
 }
 
 

+ 4 - 2
packages/app/src/pages/session/session-side-panel.tsx

@@ -72,6 +72,8 @@ export function SessionSidePanel(props: {
   activeDiff?: string
   activeDiff?: string
   focusReviewDiff: (path: string) => void
   focusReviewDiff: (path: string) => void
 }) {
 }) {
+  const openedTabs = createMemo(() => props.openedTabs())
+
   return (
   return (
     <Show when={props.open}>
     <Show when={props.open}>
       <aside
       <aside
@@ -140,8 +142,8 @@ export function SessionSidePanel(props: {
                             </div>
                             </div>
                           </Tabs.Trigger>
                           </Tabs.Trigger>
                         </Show>
                         </Show>
-                        <SortableProvider ids={props.openedTabs()}>
-                          <For each={props.openedTabs()}>
+                        <SortableProvider ids={openedTabs()}>
+                          <For each={openedTabs()}>
                             {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
                             {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
                           </For>
                           </For>
                         </SortableProvider>
                         </SortableProvider>

+ 10 - 6
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,4 +1,4 @@
-import { For, Show } from "solid-js"
+import { For, Show, createMemo } from "solid-js"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -8,7 +8,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
 import { ConstrainDragYAxis } from "@/utils/solid-dnd"
 import { ConstrainDragYAxis } from "@/utils/solid-dnd"
 import { SortableTerminalTab } from "@/components/session"
 import { SortableTerminalTab } from "@/components/session"
 import { Terminal } from "@/components/terminal"
 import { Terminal } from "@/components/terminal"
-import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useTerminal } from "@/context/terminal"
 import { useLanguage } from "@/context/language"
 import { useLanguage } from "@/context/language"
 import { useCommand } from "@/context/command"
 import { useCommand } from "@/context/command"
 import { terminalTabLabel } from "@/pages/session/terminal-label"
 import { terminalTabLabel } from "@/pages/session/terminal-label"
@@ -28,6 +28,10 @@ export function TerminalPanel(props: {
   handleTerminalDragEnd: () => void
   handleTerminalDragEnd: () => void
   onCloseTab: () => void
   onCloseTab: () => void
 }) {
 }) {
+  const all = createMemo(() => props.terminal.all())
+  const ids = createMemo(() => all().map((pty) => pty.id))
+  const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
+
   return (
   return (
     <Show when={props.open}>
     <Show when={props.open}>
       <div
       <div
@@ -86,8 +90,8 @@ export function TerminalPanel(props: {
                 class="!h-auto !flex-none"
                 class="!h-auto !flex-none"
               >
               >
                 <Tabs.List class="h-10">
                 <Tabs.List class="h-10">
-                  <SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}>
-                    <For each={props.terminal.all()}>
+                  <SortableProvider ids={ids()}>
+                    <For each={all()}>
                       {(pty) => (
                       {(pty) => (
                         <SortableTerminalTab
                         <SortableTerminalTab
                           terminal={pty}
                           terminal={pty}
@@ -117,7 +121,7 @@ export function TerminalPanel(props: {
                 </Tabs.List>
                 </Tabs.List>
               </Tabs>
               </Tabs>
               <div class="flex-1 min-h-0 relative">
               <div class="flex-1 min-h-0 relative">
-                <For each={props.terminal.all()}>
+                <For each={all()}>
                   {(pty) => (
                   {(pty) => (
                     <div
                     <div
                       id={`terminal-wrapper-${pty.id}`}
                       id={`terminal-wrapper-${pty.id}`}
@@ -142,7 +146,7 @@ export function TerminalPanel(props: {
               <Show when={props.activeTerminalDraggable()}>
               <Show when={props.activeTerminalDraggable()}>
                 {(draggedId) => {
                 {(draggedId) => {
                   return (
                   return (
-                    <Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}>
+                    <Show when={byId().get(draggedId())}>
                       {(t) => (
                       {(t) => (
                         <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                         <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                           {terminalTabLabel({
                           {terminalTabLabel({

+ 10 - 7
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -1,4 +1,4 @@
-import { createEffect, on, onCleanup } from "solid-js"
+import { createEffect, createMemo, on, onCleanup } from "solid-js"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 
 
 export const messageIdFromHash = (hash: string) => {
 export const messageIdFromHash = (hash: string) => {
@@ -26,6 +26,10 @@ export const useSessionHashScroll = (input: {
   scheduleScrollState: (el: HTMLDivElement) => void
   scheduleScrollState: (el: HTMLDivElement) => void
   consumePendingMessage: (key: string) => string | undefined
   consumePendingMessage: (key: string) => string | undefined
 }) => {
 }) => {
+  const visibleUserMessages = createMemo(() => input.visibleUserMessages())
+  const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
+  const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
+
   const clearMessageHash = () => {
   const clearMessageHash = () => {
     if (!window.location.hash) return
     if (!window.location.hash) return
     window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
     window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
@@ -47,10 +51,9 @@ export const useSessionHashScroll = (input: {
   }
   }
 
 
   const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
   const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
-    input.setActiveMessage(message)
+    if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
 
 
-    const msgs = input.visibleUserMessages()
-    const index = msgs.findIndex((m) => m.id === message.id)
+    const index = messageIndex().get(message.id) ?? -1
     if (index !== -1 && index < input.turnStart()) {
     if (index !== -1 && index < input.turnStart()) {
       input.setTurnStart(index)
       input.setTurnStart(index)
       input.scheduleTurnBackfill()
       input.scheduleTurnBackfill()
@@ -107,7 +110,7 @@ export const useSessionHashScroll = (input: {
     const messageId = messageIdFromHash(hash)
     const messageId = messageIdFromHash(hash)
     if (messageId) {
     if (messageId) {
       input.autoScroll.pause()
       input.autoScroll.pause()
-      const msg = input.visibleUserMessages().find((m) => m.id === messageId)
+      const msg = messageById().get(messageId)
       if (msg) {
       if (msg) {
         scrollToMessage(msg, behavior)
         scrollToMessage(msg, behavior)
         return
         return
@@ -144,14 +147,14 @@ export const useSessionHashScroll = (input: {
   createEffect(() => {
   createEffect(() => {
     if (!input.sessionID() || !input.messagesReady()) return
     if (!input.sessionID() || !input.messagesReady()) return
 
 
-    input.visibleUserMessages().length
+    visibleUserMessages()
     input.turnStart()
     input.turnStart()
 
 
     const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
     const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
     if (!targetId) return
     if (!targetId) return
     if (input.currentMessageId() === targetId) return
     if (input.currentMessageId() === targetId) return
 
 
-    const msg = input.visibleUserMessages().find((m) => m.id === targetId)
+    const msg = messageById().get(targetId)
     if (!msg) return
     if (!msg) return
 
 
     if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
     if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)