Adam 3 месяцев назад
Родитель
Сommit
fc5fc2c570

+ 1 - 1
packages/desktop/src/components/prompt-input.tsx

@@ -266,7 +266,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!existing) {
       const created = await sdk.client.session.create()
       existing = created.data ?? undefined
-      if (existing) navigate(`/session/${existing.id}`)
+      if (existing) navigate(`${local.slug()}/session/${existing.id}`)
     }
     if (!existing) return
 

+ 3 - 3
packages/desktop/src/components/session-review.tsx

@@ -1,4 +1,3 @@
-import { useLocal } from "@/context/local"
 import { useSession } from "@/context/session"
 import { FileIcon } from "@/ui"
 import { getDirectory, getFilename } from "@/utils"
@@ -6,9 +5,10 @@ import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from
 import { For, Match, Show, Switch } from "solid-js"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { createStore } from "solid-js/store"
+import { useLayout } from "@/context/layout"
 
 export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
-  const local = useLocal()
+  const layout = useLayout()
   const session = useSession()
   const [store, setStore] = createStore({
     open: session.diffs().map((d) => d.file),
@@ -51,7 +51,7 @@ export const SessionReview = (props: { split?: boolean; class?: string; hideExpa
                 icon="expand"
                 variant="ghost"
                 onClick={() => {
-                  local.layout.review.tab()
+                  layout.review.tab()
                   session.layout.setActiveTab("review")
                 }}
               />

+ 33 - 0
packages/desktop/src/context/global-sdk.tsx

@@ -0,0 +1,33 @@
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
+import { createSimpleContext } from "./helper"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
+
+export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
+  name: "GlobalSDK",
+  init: (props: { url: string }) => {
+    const abort = new AbortController()
+    const sdk = createOpencodeClient({
+      baseUrl: props.url,
+      signal: abort.signal,
+    })
+
+    const emitter = createGlobalEmitter<{
+      [key: string]: Event
+    }>()
+
+    sdk.global.event().then(async (events) => {
+      for await (const event of events.stream) {
+        console.log("event", event)
+        // console.log("event", event.payload.type)
+        emitter.emit(event.directory, event.payload)
+      }
+    })
+
+    onCleanup(() => {
+      abort.abort()
+    })
+
+    return { url: props.url, client: sdk, event: emitter }
+  },
+})

+ 174 - 0
packages/desktop/src/context/global-sync.tsx

@@ -0,0 +1,174 @@
+import type {
+  Message,
+  Agent,
+  Provider,
+  Session,
+  Part,
+  Config,
+  Path,
+  File,
+  FileNode,
+  Project,
+  FileDiff,
+  Todo,
+} from "@opencode-ai/sdk"
+import { createStore, produce, reconcile } from "solid-js/store"
+import { Binary } from "@/utils/binary"
+import { createSimpleContext } from "./helper"
+import { useGlobalSDK } from "./global-sdk"
+
+type State = {
+  ready: boolean
+  provider: Provider[]
+  agent: Agent[]
+  project: Project
+  config: Config
+  path: Path
+  session: Session[]
+  session_diff: {
+    [sessionID: string]: FileDiff[]
+  }
+  todo: {
+    [sessionID: string]: Todo[]
+  }
+  limit: number
+  message: {
+    [sessionID: string]: Message[]
+  }
+  part: {
+    [messageID: string]: Part[]
+  }
+  node: FileNode[]
+  changes: File[]
+}
+
+export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
+  name: "GlobalSync",
+  init: () => {
+    const [globalStore, setGlobalStore] = createStore<{
+      ready: boolean
+      defaultProject?: Project // TODO: remove this when we can select projects
+      projects: Project[]
+      children: Record<string, State>
+    }>({
+      ready: false,
+      projects: [],
+      children: {},
+    })
+
+    const children: Record<string, ReturnType<typeof createStore<State>>> = {}
+
+    function child(directory: string) {
+      if (!children[directory]) {
+        setGlobalStore("children", directory, {
+          project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
+          config: {},
+          path: { state: "", config: "", worktree: "", directory: "" },
+          ready: false,
+          agent: [],
+          provider: [],
+          session: [],
+          session_diff: {},
+          todo: {},
+          limit: 10,
+          message: {},
+          part: {},
+          node: [],
+          changes: [],
+        })
+        children[directory] = createStore(globalStore.children[directory])
+      }
+      return children[directory]
+    }
+
+    const sdk = useGlobalSDK()
+    sdk.event.listen((e) => {
+      const directory = e.name
+      const [store, setStore] = child(directory)
+
+      const event = e.details
+      switch (event.type) {
+        case "session.updated": {
+          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+          if (result.found) {
+            setStore("session", result.index, reconcile(event.properties.info))
+            break
+          }
+          setStore(
+            "session",
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.info)
+            }),
+          )
+          break
+        }
+        case "session.diff":
+          setStore("session_diff", event.properties.sessionID, event.properties.diff)
+          break
+        case "todo.updated":
+          setStore("todo", event.properties.sessionID, event.properties.todos)
+          break
+        case "message.updated": {
+          const messages = store.message[event.properties.info.sessionID]
+          if (!messages) {
+            setStore("message", event.properties.info.sessionID, [event.properties.info])
+            break
+          }
+          const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
+          if (result.found) {
+            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+            break
+          }
+          setStore(
+            "message",
+            event.properties.info.sessionID,
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.info)
+            }),
+          )
+          break
+        }
+        case "message.part.updated": {
+          const part = event.properties.part
+          const parts = store.part[part.messageID]
+          if (!parts) {
+            setStore("part", part.messageID, [part])
+            break
+          }
+          const result = Binary.search(parts, part.id, (p) => p.id)
+          if (result.found) {
+            setStore("part", part.messageID, result.index, reconcile(part))
+            break
+          }
+          setStore(
+            "part",
+            part.messageID,
+            produce((draft) => {
+              draft.splice(result.index, 0, part)
+            }),
+          )
+          break
+        }
+      }
+    })
+
+    Promise.all([
+      sdk.client.project.list().then((x) =>
+        setGlobalStore(
+          "projects",
+          x.data!.filter((x) => !x.worktree.includes("opencode-test")),
+        ),
+      ),
+      // TODO: remove this when we can select projects
+      sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
+    ]).then(() => setGlobalStore("ready", true))
+
+    return {
+      data: globalStore,
+      get ready() {
+        return globalStore.ready
+      },
+      child,
+    }
+  },
+})

+ 24 - 1
packages/desktop/src/context/layout.tsx

@@ -2,12 +2,15 @@ import { createStore } from "solid-js/store"
 import { createMemo } from "solid-js"
 import { createSimpleContext } from "./helper"
 import { makePersisted } from "@solid-primitives/storage"
+import { useGlobalSync } from "./global-sync"
 
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
+    const globalSync = useGlobalSync()
     const [store, setStore] = makePersisted(
       createStore({
+        projects: [] as { directory: string; expanded: boolean }[],
         sidebar: {
           opened: true,
           width: 280,
@@ -17,11 +20,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
       }),
       {
-        name: "__default-layout",
+        name: "___default-layout",
       },
     )
 
     return {
+      projects: {
+        list: createMemo(() =>
+          globalSync.data.defaultProject
+            ? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
+            : store.projects,
+        ),
+        open(directory: string) {
+          if (store.projects.find((x) => x.directory === directory)) return
+          setStore("projects", (x) => [...x, { directory, expanded: true }])
+        },
+        close(directory: string) {
+          setStore("projects", (x) => x.filter((x) => x.directory !== directory))
+        },
+        expand(directory: string) {
+          setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
+        },
+        collapse(directory: string) {
+          setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
+        },
+      },
       sidebar: {
         opened: createMemo(() => store.sidebar.opened),
         open() {

+ 2 - 0
packages/desktop/src/context/local.tsx

@@ -5,6 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from
 import { createSimpleContext } from "./helper"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
+import { base64Encode } from "@/utils"
 
 export type LocalFile = FileNode &
   Partial<{
@@ -457,6 +458,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     })()
 
     const result = {
+      slug: createMemo(() => base64Encode(sdk.directory)),
       model,
       agent,
       file,

+ 11 - 14
packages/desktop/src/context/sdk.tsx

@@ -2,34 +2,31 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
 import { createSimpleContext } from "./helper"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { onCleanup } from "solid-js"
+import { useGlobalSDK } from "./global-sdk"
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
-  init: (props: { url: string; directory?: string }) => {
+  init: (props: { directory: string }) => {
+    const globalSDK = useGlobalSDK()
     const abort = new AbortController()
-    const sdk = createOpencodeClient(
-      {
-        baseUrl: props.url,
-        signal: abort.signal,
-      },
-      { directory: props.directory },
-    )
+    const sdk = createOpencodeClient({
+      baseUrl: globalSDK.url,
+      signal: abort.signal,
+      directory: props.directory,
+    })
 
     const emitter = createGlobalEmitter<{
       [key in Event["type"]]: Extract<Event, { type: key }>
     }>()
 
-    sdk.event.subscribe().then(async (events) => {
-      for await (const event of events.stream) {
-        console.log("event", event.type)
-        emitter.emit(event.type, event)
-      }
+    globalSDK.event.on(props.directory, async (event) => {
+      emitter.emit(event.type, event)
     })
 
     onCleanup(() => {
       abort.abort()
     })
 
-    return { url: props.url, directory: props.directory, client: sdk, event: emitter }
+    return { directory: props.directory, client: sdk, event: emitter }
   },
 })

+ 16 - 10
packages/desktop/src/context/session.tsx

@@ -6,11 +6,17 @@ import { makePersisted } from "@solid-primitives/storage"
 import { TextSelection } from "./local"
 import { pipe, sumBy } from "remeda"
 import { AssistantMessage } from "@opencode-ai/sdk"
+import { useParams } from "@solidjs/router"
+import { base64Encode } from "@/utils"
 
 export const { use: useSession, provider: SessionProvider } = createSimpleContext({
   name: "Session",
-  init: (props: { sessionId?: string }) => {
+  init: () => {
+    const params = useParams()
     const sync = useSync()
+    const name = createMemo(
+      () => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
+    )
 
     const [store, setStore] = makePersisted(
       createStore<{
@@ -29,17 +35,17 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
         cursor: undefined,
       }),
       {
-        name: props.sessionId ?? "new-session",
+        name: name(),
       },
     )
 
     createEffect(() => {
-      if (!props.sessionId) return
-      sync.session.sync(props.sessionId)
+      if (!params.id) return
+      sync.session.sync(params.id)
     })
 
-    const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
-    const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
+    const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+    const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
     const userMessages = createMemo(() =>
       messages()
         .filter((m) => m.role === "user")
@@ -53,10 +59,10 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
       return userMessages()?.find((m) => m.id === store.messageId)
     })
     const working = createMemo(() => {
-      if (!props.sessionId) return false
+      if (!params.id) return false
       const last = lastUserMessage()
       if (!last) return false
-      const assistantMessages = sync.data.message[props.sessionId]?.filter(
+      const assistantMessages = sync.data.message[params.id]?.filter(
         (m) => m.role === "assistant" && m.parentID == last?.id,
       ) as AssistantMessage[]
       const error = assistantMessages?.find((m) => m?.error)?.error
@@ -80,7 +86,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
     const model = createMemo(() =>
       last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
     )
-    const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
+    const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 
     const tokens = createMemo(() => {
       if (!last()) return
@@ -96,7 +102,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
     })
 
     return {
-      id: props.sessionId,
+      id: params.id,
       info,
       working,
       diffs,

+ 5 - 123
packages/desktop/src/context/sync.tsx

@@ -1,135 +1,17 @@
-import type {
-  Message,
-  Agent,
-  Provider,
-  Session,
-  Part,
-  Config,
-  Path,
-  File,
-  FileNode,
-  Project,
-  FileDiff,
-  Todo,
-} from "@opencode-ai/sdk"
-import { createStore, produce, reconcile } from "solid-js/store"
+import type { Part } from "@opencode-ai/sdk"
+import { produce } from "solid-js/store"
 import { createMemo } from "solid-js"
 import { Binary } from "@/utils/binary"
 import { createSimpleContext } from "./helper"
+import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
   init: () => {
-    const [store, setStore] = createStore<{
-      ready: boolean
-      provider: Provider[]
-      agent: Agent[]
-      project: Project
-      config: Config
-      path: Path
-      session: Session[]
-      session_diff: {
-        [sessionID: string]: FileDiff[]
-      }
-      todo: {
-        [sessionID: string]: Todo[]
-      }
-      limit: number
-      message: {
-        [sessionID: string]: Message[]
-      }
-      part: {
-        [messageID: string]: Part[]
-      }
-      node: FileNode[]
-      changes: File[]
-    }>({
-      project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
-      config: {},
-      path: { state: "", config: "", worktree: "", directory: "" },
-      ready: false,
-      agent: [],
-      provider: [],
-      session: [],
-      session_diff: {},
-      todo: {},
-      limit: 10,
-      message: {},
-      part: {},
-      node: [],
-      changes: [],
-    })
-
+    const globalSync = useGlobalSync()
     const sdk = useSDK()
-    sdk.event.listen((e) => {
-      // fetch the child store
-      // make a set store function that always rights to the child store
-      const event = e.details
-      switch (event.type) {
-        case "session.updated": {
-          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
-          if (result.found) {
-            setStore("session", result.index, reconcile(event.properties.info))
-            break
-          }
-          setStore(
-            "session",
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
-            }),
-          )
-          break
-        }
-        case "session.diff":
-          setStore("session_diff", event.properties.sessionID, event.properties.diff)
-          break
-        case "todo.updated":
-          setStore("todo", event.properties.sessionID, event.properties.todos)
-          break
-        case "message.updated": {
-          const messages = store.message[event.properties.info.sessionID]
-          if (!messages) {
-            setStore("message", event.properties.info.sessionID, [event.properties.info])
-            break
-          }
-          const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
-          if (result.found) {
-            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
-            break
-          }
-          setStore(
-            "message",
-            event.properties.info.sessionID,
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
-            }),
-          )
-          break
-        }
-        case "message.part.updated": {
-          const part = sanitizePart(event.properties.part)
-          const parts = store.part[part.messageID]
-          if (!parts) {
-            setStore("part", part.messageID, [part])
-            break
-          }
-          const result = Binary.search(parts, part.id, (p) => p.id)
-          if (result.found) {
-            setStore("part", part.messageID, result.index, reconcile(part))
-            break
-          }
-          setStore(
-            "part",
-            part.messageID,
-            produce((draft) => {
-              draft.splice(result.index, 0, part)
-            }),
-          )
-          break
-        }
-      }
-    })
+    const [store, setStore] = globalSync.child(sdk.directory)
 
     const load = {
       project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),

+ 6 - 0
packages/desktop/src/index.css

@@ -1 +1,7 @@
 @import "@opencode-ai/ui/styles/tailwind";
+
+:root {
+  a {
+    cursor: default;
+  }
+}

+ 29 - 10
packages/desktop/src/index.tsx

@@ -1,15 +1,18 @@
 /* @refresh reload */
 import "@/index.css"
 import { render } from "solid-js/web"
-import { Router, Route } from "@solidjs/router"
+import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Fonts, MarkedProvider } from "@opencode-ai/ui"
-import { SDKProvider } from "./context/sdk"
-import { SyncProvider } from "./context/sync"
+import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
 import Layout from "@/pages/layout"
-import SessionLayout from "@/pages/session-layout"
+import DirectoryLayout from "@/pages/directory-layout"
 import Session from "@/pages/session"
 import { LayoutProvider } from "./context/layout"
+import { GlobalSDKProvider } from "./context/global-sdk"
+import { SessionProvider } from "./context/session"
+import { base64Encode } from "./utils"
+import { createMemo } from "solid-js"
 
 const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
 const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -30,20 +33,36 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
 render(
   () => (
     <MarkedProvider>
-      <SDKProvider url={url}>
-        <SyncProvider>
+      <GlobalSDKProvider url={url}>
+        <GlobalSyncProvider>
           <LayoutProvider>
             <MetaProvider>
               <Fonts />
               <Router root={Layout}>
-                <Route path={["/", "/session"]} component={SessionLayout}>
-                  <Route path="/:id?" component={Session} />
+                <Route
+                  path="/"
+                  component={() => {
+                    const globalSync = useGlobalSync()
+                    const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
+                    return <Navigate href={`${slug()}/session`} />
+                  }}
+                />
+                <Route path="/:dir" component={DirectoryLayout}>
+                  <Route path="/" component={() => <Navigate href="session" />} />
+                  <Route
+                    path="/session/:id?"
+                    component={() => (
+                      <SessionProvider>
+                        <Session />
+                      </SessionProvider>
+                    )}
+                  />
                 </Route>
               </Router>
             </MetaProvider>
           </LayoutProvider>
-        </SyncProvider>
-      </SDKProvider>
+        </GlobalSyncProvider>
+      </GlobalSDKProvider>
     </MarkedProvider>
   ),
   root!,

+ 25 - 0
packages/desktop/src/pages/directory-layout.tsx

@@ -0,0 +1,25 @@
+import { createMemo, Show, type ParentProps } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { SDKProvider } from "@/context/sdk"
+import { SyncProvider } from "@/context/sync"
+import { LocalProvider } from "@/context/local"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@/utils"
+
+export default function Layout(props: ParentProps) {
+  const params = useParams()
+  const sync = useGlobalSync()
+  const directory = createMemo(() => {
+    const decoded = base64Decode(params.dir)
+    return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
+  })
+  return (
+    <Show when={params.id || true} keyed>
+      <SDKProvider directory={directory()}>
+        <SyncProvider>
+          <LocalProvider>{props.children}</LocalProvider>
+        </SyncProvider>
+      </SDKProvider>
+    </Show>
+  )
+}

+ 20 - 0
packages/desktop/src/pages/home.tsx

@@ -0,0 +1,20 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Encode, getFilename } from "@/utils"
+import { For } from "solid-js"
+import { A } from "@solidjs/router"
+import { Button } from "@opencode-ai/ui"
+
+export default function Home() {
+  const sync = useGlobalSync()
+  return (
+    <div class="flex flex-col gap-3">
+      <For each={sync.data.projects}>
+        {(project) => (
+          <Button as={A} href={base64Encode(project.worktree)}>
+            {getFilename(project.worktree)}
+          </Button>
+        )}
+      </For>
+    </div>
+  )
+}

+ 145 - 121
packages/desktop/src/pages/layout.tsx

@@ -1,19 +1,25 @@
-import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon } from "@opencode-ai/ui"
-import { createMemo, For, Match, ParentProps, Show, Switch } from "solid-js"
+import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon, Collapsible } from "@opencode-ai/ui"
+import { createMemo, For, ParentProps, Show } from "solid-js"
 import { DateTime } from "luxon"
-import { useSync } from "@/context/sync"
 import { A, useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Encode, getFilename } from "@/utils"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
-  const sync = useSync()
+  const globalSync = useGlobalSync()
   const layout = useLayout()
 
+  const handleOpenProject = async () => {
+    // layout.projects.open(dir.)
+  }
+
   return (
     <div class="relative h-screen flex flex-col">
       <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
-        <div
+        <A
+          href="/"
           classList={{
             "w-12 shrink-0 px-4 py-3.5": true,
             "flex items-center justify-start self-stretch": true,
@@ -22,7 +28,7 @@ export default function Layout(props: ParentProps) {
           style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
         >
           <Mark class="shrink-0" />
-        </div>
+        </A>
       </header>
       <div class="h-[calc(100vh-3rem)] flex">
         <div
@@ -33,136 +39,154 @@ export default function Layout(props: ParentProps) {
           }}
           style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
         >
-          <div class="flex flex-col justify-center items-start gap-4 self-stretch p-2 overflow-hidden mx-auto @[4rem]:mx-0">
-            <Switch>
-              <Match when={layout.sidebar.opened()}>
-                <Button
-                  variant="ghost"
-                  size="large"
-                  class="group/sidebar-toggle w-full text-left justify-start"
-                  onClick={layout.sidebar.toggle}
-                >
-                  <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                    <Icon name="layout-left" size="small" class="group-hover/sidebar-toggle:hidden" />
-                    <Icon
-                      name="layout-left-partial"
-                      size="small"
-                      class="hidden group-hover/sidebar-toggle:inline-block"
-                    />
-                    <Icon
-                      name="layout-left-full"
-                      size="small"
-                      class="hidden group-active/sidebar-toggle:inline-block"
-                    />
-                  </div>
+          <div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
+            <Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
+              <Button
+                variant="ghost"
+                size="large"
+                class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
+                onClick={layout.sidebar.toggle}
+              >
+                <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                  <Icon
+                    name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
+                    size="small"
+                    class="group-hover/sidebar-toggle:hidden"
+                  />
+                  <Icon
+                    name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
+                    size="small"
+                    class="hidden group-hover/sidebar-toggle:inline-block"
+                  />
+                  <Icon
+                    name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
+                    size="small"
+                    class="hidden group-active/sidebar-toggle:inline-block"
+                  />
+                </div>
+                <Show when={layout.sidebar.opened()}>
                   <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
                     Toggle sidebar
                   </div>
-                </Button>
-              </Match>
-              <Match when={!layout.sidebar.opened()}>
-                <Tooltip placement="right" value="Toggle sidebar">
-                  <Button variant="ghost" size="large" class="group/sidebar-toggle" onClick={layout.sidebar.toggle}>
-                    <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                      <Icon name="layout-right" size="small" class="group-hover/sidebar-toggle:hidden" />
-                      <Icon
-                        name="layout-right-partial"
-                        size="small"
-                        class="hidden group-hover/sidebar-toggle:inline-block"
-                      />
-                      <Icon
-                        name="layout-right-full"
-                        size="small"
-                        class="hidden group-active/sidebar-toggle:inline-block"
-                      />
-                    </div>
-                  </Button>
-                </Tooltip>
-              </Match>
-            </Switch>
-            <div class="w-full px-3">
-              <Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
-                New Session
+                </Show>
               </Button>
-              <Tooltip placement="right" value="New session">
-                <IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
-              </Tooltip>
-            </div>
-            <div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
-              <nav class="w-full">
-                <For each={sync.data.session}>
-                  {(session) => {
-                    const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+            </Tooltip>
+            <div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
+              <div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
+                <For each={layout.projects.list()}>
+                  {(project) => {
+                    const [store] = globalSync.child(project.directory)
+                    const slug = createMemo(() => base64Encode(project.directory))
                     return (
-                      <A
-                        data-active={session.id === params.id}
-                        href={`/session/${session.id}`}
-                        class="group/session focus:outline-none cursor-default"
-                      >
-                        <Tooltip placement="right" value={session.title}>
-                          <div
-                            class="w-full mb-1.5 px-3 py-1 rounded-md 
-                               group-data-[active=true]/session:bg-surface-raised-base-hover
-                               group-hover/session:bg-surface-raised-base-hover
-                               group-focus/session:bg-surface-raised-base-hover"
-                          >
-                            <div class="flex items-center self-stretch gap-6 justify-between">
-                              <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
-                                {session.title}
-                              </span>
-                              <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
-                                {Math.abs(updated().diffNow().as("seconds")) < 60
-                                  ? "Now"
-                                  : updated()
-                                      .toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
-                                      ?.replace(" ago", "")
-                                      ?.replace(/ days?/, "d")
-                                      ?.replace(" min.", "m")
-                                      ?.replace(" hr.", "h")}
-                              </span>
-                            </div>
-                            <div class="flex justify-between items-center self-stretch">
-                              <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
-                              <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
-                            </div>
-                          </div>
-                        </Tooltip>
-                      </A>
+                      <Collapsible variant="ghost" defaultOpen class="gap-2">
+                        <Button
+                          as={"div"}
+                          variant="ghost"
+                          class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
+                        >
+                          <Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
+                            {getFilename(project.directory)}
+                          </Collapsible.Trigger>
+                          <IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
+                        </Button>
+                        <Collapsible.Content>
+                          <nav class="w-full flex flex-col gap-1.5">
+                            <For each={store.session}>
+                              {(session) => {
+                                const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+                                return (
+                                  <A
+                                    data-active={session.id === params.id}
+                                    href={`${slug()}/session/${session.id}`}
+                                    class="group/session focus:outline-none cursor-default"
+                                  >
+                                    <Tooltip placement="right" value={session.title}>
+                                      <div
+                                        class="w-full px-2 py-1 rounded-md 
+                                               group-data-[active=true]/session:bg-surface-raised-base-hover
+                                               group-hover/session:bg-surface-raised-base-hover
+                                               group-focus/session:bg-surface-raised-base-hover"
+                                      >
+                                        <div class="flex items-center self-stretch gap-6 justify-between">
+                                          <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+                                            {session.title}
+                                          </span>
+                                          <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+                                            {Math.abs(updated().diffNow().as("seconds")) < 60
+                                              ? "Now"
+                                              : updated()
+                                                  .toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
+                                                  ?.replace(" ago", "")
+                                                  ?.replace(/ days?/, "d")
+                                                  ?.replace(" min.", "m")
+                                                  ?.replace(" hr.", "h")}
+                                          </span>
+                                        </div>
+                                        <div class="hidden flex justify-between items-center self-stretch">
+                                          <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+                                          <Show when={session.summary}>
+                                            {(summary) => <DiffChanges changes={summary()} />}
+                                          </Show>
+                                        </div>
+                                      </div>
+                                    </Tooltip>
+                                  </A>
+                                )
+                              }}
+                            </For>
+                          </nav>
+                          {/* <Show when={sync.session.more()}> */}
+                          {/*   <button */}
+                          {/*     class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
+                          {/*     onClick={() => sync.session.fetch()} */}
+                          {/*   > */}
+                          {/*     Show more */}
+                          {/*   </button> */}
+                          {/* </Show> */}
+                        </Collapsible.Content>
+                      </Collapsible>
                     )
                   }}
                 </For>
-              </nav>
-              <Show when={sync.session.more()}>
-                <button
-                  class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
-                  onClick={() => sync.session.fetch()}
-                >
-                  Show more
-                </button>
-              </Show>
+              </div>
             </div>
           </div>
-          <div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
-            <Button
-              as={"a"}
-              href="https://opencode.ai/desktop-feedback"
-              target="_blank"
-              class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
-              variant="ghost"
-              icon="speech-bubble"
-            >
-              Share feedback
-            </Button>
-            <Tooltip placement="right" value="Share feedback">
-              <IconButton
+          <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+            <Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
+              <Button
+                disabled
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
+                variant="ghost"
+                size="large"
+                icon="folder-add-left"
+                onClick={handleOpenProject}
+              >
+                <Show when={layout.sidebar.opened()}>Open project</Show>
+              </Button>
+            </Tooltip>
+            <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
+              <Button
+                disabled
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
+                variant="ghost"
+                size="large"
+                icon="settings-gear"
+              >
+                <Show when={layout.sidebar.opened()}>Settings</Show>
+              </Button>
+            </Tooltip>
+            <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
+              <Button
                 as={"a"}
                 href="https://opencode.ai/desktop-feedback"
                 target="_blank"
-                icon="speech-bubble"
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
                 variant="ghost"
                 size="large"
-                class="@[4rem]:hidden stroke-[1.5px]"
-              />
+                icon="bubble-5"
+              >
+                <Show when={layout.sidebar.opened()}>Share feedback</Show>
+              </Button>
             </Tooltip>
           </div>
         </div>

+ 0 - 24
packages/desktop/src/pages/session-layout.tsx

@@ -1,24 +0,0 @@
-import { Show, type ParentProps } from "solid-js"
-import { SessionProvider, useSession } from "@/context/session"
-import { useParams } from "@solidjs/router"
-import { SDKProvider, useSDK } from "@/context/sdk"
-import { LocalProvider } from "@/context/local"
-
-export default function Layout(props: ParentProps) {
-  const params = useParams()
-  const root = useSDK()
-  return (
-    <Show when={params.id || true} keyed>
-      <SessionProvider sessionId={params.id}>
-        {(() => {
-          const session = useSession()
-          return (
-            <SDKProvider url={root.url} directory={session.info()?.directory}>
-              <LocalProvider>{props.children}</LocalProvider>
-            </SDKProvider>
-          )
-        })()}
-      </SessionProvider>
-    </Show>
-  )
-}

+ 84 - 78
packages/desktop/src/pages/session.tsx

@@ -294,7 +294,7 @@ export default function Page() {
             <Tabs.List>
               <Tabs.Trigger value="chat">
                 <div class="flex gap-x-[17px] items-center">
-                  <div>Chat</div>
+                  <div>Session</div>
                   <Tooltip
                     value={`${new Intl.NumberFormat("en-US", {
                       notation: "compact",
@@ -364,88 +364,94 @@ export default function Page() {
                       }}
                     >
                       <Show when={session.messages.user().length > 1}>
-                        <ul
-                          role="list"
-                          classList={{
-                            "mr-8 shrink-0 flex flex-col items-start": true,
-                            "absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": layout.review.state() === "tab",
-                            "mt-3": layout.review.state() === "pane",
-                          }}
-                        >
-                          <For each={session.messages.user()}>
-                            {(message) => {
-                              const assistantMessages = createMemo(() => {
-                                if (!session.id) return []
-                                return sync.data.message[session.id]?.filter(
-                                  (m) => m.role === "assistant" && m.parentID == message.id,
-                                ) as AssistantMessageType[]
-                              })
-                              const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
-                              const working = createMemo(() => !message.summary?.body && !error())
+                        {(_) => {
+                          const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
 
-                              const handleClick = () => session.messages.setActive(message.id)
+                          return (
+                            <ul
+                              role="list"
+                              classList={{
+                                "mr-8 shrink-0 flex flex-col items-start": true,
+                                "absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": expanded(),
+                                "mt-3": !expanded(),
+                              }}
+                            >
+                              <For each={session.messages.user()}>
+                                {(message) => {
+                                  const assistantMessages = createMemo(() => {
+                                    if (!session.id) return []
+                                    return sync.data.message[session.id]?.filter(
+                                      (m) => m.role === "assistant" && m.parentID == message.id,
+                                    ) as AssistantMessageType[]
+                                  })
+                                  const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+                                  const working = createMemo(() => !message.summary?.body && !error())
 
-                              return (
-                                <li
-                                  classList={{
-                                    "group/li flex items-center self-stretch justify-end": true,
-                                    "@7xl:justify-start": layout.review.state() === "tab",
-                                  }}
-                                >
-                                  <Tooltip
-                                    placement="right"
-                                    gutter={8}
-                                    value={
-                                      <div class="flex items-center gap-2">
-                                        <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
-                                        {message.summary?.title}
-                                      </div>
-                                    }
-                                  >
-                                    <button
-                                      data-active={session.messages.active()?.id === message.id}
-                                      onClick={handleClick}
-                                      classList={{
-                                        "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
-                                        "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
-                                        "@7xl:hidden": layout.review.state() === "tab",
-                                      }}
-                                    >
-                                      <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
-                                    </button>
-                                  </Tooltip>
-                                  <button
-                                    classList={{
-                                      "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
-                                      "@7xl:flex": layout.review.state() === "tab",
-                                    }}
-                                    onClick={handleClick}
-                                  >
-                                    <Switch>
-                                      <Match when={working()}>
-                                        <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
-                                      </Match>
-                                      <Match when={true}>
-                                        <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
-                                      </Match>
-                                    </Switch>
-                                    <div
-                                      data-active={session.messages.active()?.id === message.id}
+                                  const handleClick = () => session.messages.setActive(message.id)
+
+                                  return (
+                                    <li
                                       classList={{
-                                        "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
-                                        "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+                                        "group/li flex items-center self-stretch justify-end": true,
+                                        "@7xl:justify-start": expanded(),
                                       }}
                                     >
-                                      <Show when={message.summary?.title} fallback="New message">
-                                        {message.summary?.title}
-                                      </Show>
-                                    </div>
-                                  </button>
-                                </li>
-                              )
-                            }}
-                          </For>
-                        </ul>
+                                      <Tooltip
+                                        placement="right"
+                                        gutter={8}
+                                        value={
+                                          <div class="flex items-center gap-2">
+                                            <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+                                            {message.summary?.title}
+                                          </div>
+                                        }
+                                      >
+                                        <button
+                                          data-active={session.messages.active()?.id === message.id}
+                                          onClick={handleClick}
+                                          classList={{
+                                            "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
+                                            "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
+                                            "@7xl:hidden": expanded(),
+                                          }}
+                                        >
+                                          <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
+                                        </button>
+                                      </Tooltip>
+                                      <button
+                                        classList={{
+                                          "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
+                                          "@7xl:flex": expanded(),
+                                        }}
+                                        onClick={handleClick}
+                                      >
+                                        <Switch>
+                                          <Match when={working()}>
+                                            <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
+                                          </Match>
+                                          <Match when={true}>
+                                            <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+                                          </Match>
+                                        </Switch>
+                                        <div
+                                          data-active={session.messages.active()?.id === message.id}
+                                          classList={{
+                                            "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+                                            "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+                                          }}
+                                        >
+                                          <Show when={message.summary?.title} fallback="New message">
+                                            {message.summary?.title}
+                                          </Show>
+                                        </div>
+                                      </button>
+                                    </li>
+                                  )
+                                }}
+                              </For>
+                            </ul>
+                          )
+                        }}
                       </Show>
                       <div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
                         <For each={session.messages.user()}>

+ 7 - 0
packages/desktop/src/utils/encode.ts

@@ -0,0 +1,7 @@
+export function base64Encode(value: string) {
+  return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}
+
+export function base64Decode(value: string) {
+  return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
+}

+ 1 - 0
packages/desktop/src/utils/index.ts

@@ -1,2 +1,3 @@
 export * from "./path"
 export * from "./dom"
+export * from "./encode"

+ 2 - 8
packages/opencode/src/server/server.ts

@@ -118,6 +118,7 @@ export namespace Server {
           timer.stop()
         }
       })
+      .use(cors())
       .get(
         "/global/event",
         describeRoute({
@@ -146,12 +147,6 @@ export namespace Server {
         async (c) => {
           log.info("global event connected")
           return streamSSE(c, async (stream) => {
-            stream.writeSSE({
-              data: JSON.stringify({
-                type: "server.connected",
-                properties: {},
-              }),
-            })
             async function handler(event: any) {
               await stream.writeSSE({
                 data: JSON.stringify(event),
@@ -169,7 +164,7 @@ export namespace Server {
         },
       )
       .use(async (c, next) => {
-        const directory = c.req.query("directory") ?? process.cwd()
+        const directory = c.req.query("directory") ?? c.req.header("x-opencode-directory") ?? process.cwd()
         return Instance.provide({
           directory,
           init: InstanceBootstrap,
@@ -178,7 +173,6 @@ export namespace Server {
           },
         })
       })
-      .use(cors())
       .get(
         "/doc",
         openAPIRouteHandler(app, {

+ 1 - 1
packages/opencode/src/session/summary.ts

@@ -145,7 +145,7 @@ export namespace SessionSummary {
       messageID: Identifier.schema("message").optional(),
     }),
     async (input) => {
-      return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]) ?? []
+      return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
     },
   )
 

+ 6 - 9
packages/sdk/js/src/client.ts

@@ -5,7 +5,7 @@ import { createClient } from "./gen/client/client.gen.js"
 import { type Config } from "./gen/client/types.gen.js"
 import { OpencodeClient } from "./gen/sdk.gen.js"
 
-export function createOpencodeClient(config?: Config, options?: { directory?: string }) {
+export function createOpencodeClient(config?: Config & { directory?: string }) {
   if (!config?.fetch) {
     config = {
       ...config,
@@ -17,16 +17,13 @@ export function createOpencodeClient(config?: Config, options?: { directory?: st
     }
   }
 
-  const client = createClient(config)
-
-  if (options?.directory) {
-    async function middleware(request: Request) {
-      const url = new URL(request.url)
-      url.searchParams.set("directory", options!.directory!)
-      return new Request(url.toString(), request)
+  if (config?.directory) {
+    config.headers = {
+      ...config.headers,
+      "x-opencode-directory": config.directory,
     }
-    client.interceptors.request.use(middleware)
   }
 
+  const client = createClient(config)
   return new OpencodeClient({ client })
 }

+ 17 - 7
packages/ui/src/components/button.css

@@ -27,6 +27,12 @@
       border-color: var(--border-active);
       background-color: var(--surface-brand-active);
     }
+    &:disabled {
+      border-color: var(--border-disabled);
+      background-color: var(--surface-disabled);
+      color: var(--text-weak);
+      cursor: not-allowed;
+    }
   }
 
   &[data-variant="ghost"] {
@@ -43,6 +49,11 @@
     &:active:not(:disabled) {
       background-color: var(--surface-raised-base-active);
     }
+    &:disabled {
+      color: var(--text-weak);
+      opacity: 0.7;
+      cursor: not-allowed;
+    }
   }
 
   &[data-variant="secondary"] {
@@ -69,6 +80,12 @@
       scale: 0.99;
       transition: all 150ms ease-out;
     }
+    &:disabled {
+      border-color: var(--border-disabled);
+      background-color: var(--surface-disabled);
+      color: var(--text-weak);
+      cursor: not-allowed;
+    }
 
     [data-slot="icon"] {
       color: var(--icon-strong-base);
@@ -106,13 +123,6 @@
     letter-spacing: var(--letter-spacing-normal);
   }
 
-  &:disabled {
-    border-color: var(--border-disabled);
-    background-color: var(--surface-disabled);
-    color: var(--text-weak);
-    cursor: not-allowed;
-  }
-
   &:focus {
     outline: none;
   }

+ 3 - 0
packages/ui/src/components/icon.tsx

@@ -162,6 +162,9 @@ const newIcons = {
   "align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
   expand: `<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="currentColor" stroke-linecap="square"/>`,
   collapse: `<path d="M16.666 8.33398H11.666V3.33398" stroke="currentColor" stroke-linecap="square"/><path d="M8.33398 16.666V11.666H3.33398" stroke="currentColor" stroke-linecap="square"/>`,
+  "folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
+  "settings-gear": `<path d="M9.99935 2.08398L17.0827 6.04227L17.0827 13.9589L9.99934 17.9172L2.91602 13.9592L2.91602 6.04225L9.99935 2.08398Z" stroke="currentColor" stroke-linecap="square"/><path d="M12.916 10.0006C12.916 11.6115 11.6102 12.9173 9.99937 12.9173C8.38854 12.9173 7.0827 11.6115 7.0827 10.0006C7.0827 8.38982 8.38854 7.08398 9.99937 7.08398C11.6102 7.08398 12.916 8.38982 12.916 10.0006Z" stroke="currentColor" stroke-linecap="square"/>`,
+  "bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
 }
 
 export interface IconProps extends ComponentProps<"svg"> {

+ 19 - 13
packages/ui/src/components/tooltip.tsx

@@ -1,15 +1,16 @@
 import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
-import { children, createEffect, createSignal, splitProps, type JSX } from "solid-js"
+import { children, createEffect, createSignal, Match, splitProps, Switch, type JSX } from "solid-js"
 import type { ComponentProps } from "solid-js"
 
 export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
   value: JSX.Element
   class?: string
+  inactive?: boolean
 }
 
 export function Tooltip(props: TooltipProps) {
   const [open, setOpen] = createSignal(false)
-  const [local, others] = splitProps(props, ["children", "class"])
+  const [local, others] = splitProps(props, ["children", "class", "inactive"])
 
   const c = children(() => local.children)
 
@@ -29,16 +30,21 @@ export function Tooltip(props: TooltipProps) {
   })
 
   return (
-    <KobalteTooltip forceMount gutter={4} {...others} open={open()} onOpenChange={setOpen}>
-      <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
-        {c()}
-      </KobalteTooltip.Trigger>
-      <KobalteTooltip.Portal>
-        <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
-          {others.value}
-          {/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
-        </KobalteTooltip.Content>
-      </KobalteTooltip.Portal>
-    </KobalteTooltip>
+    <Switch>
+      <Match when={local.inactive}>{local.children}</Match>
+      <Match when={true}>
+        <KobalteTooltip forceMount gutter={4} {...others} open={open()} onOpenChange={setOpen}>
+          <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
+            {c()}
+          </KobalteTooltip.Trigger>
+          <KobalteTooltip.Portal>
+            <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
+              {others.value}
+              {/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
+            </KobalteTooltip.Content>
+          </KobalteTooltip.Portal>
+        </KobalteTooltip>
+      </Match>
+    </Switch>
   )
 }