Просмотр исходного кода

enterprise (#4617)

Co-authored-by: GitHub Action <[email protected]>
Co-authored-by: Adam <[email protected]>
Dax 2 месяцев назад
Родитель
Сommit
49408c00e9
100 измененных файлов с 1163 добавлено и 572 удалено
  1. 3 0
      .opencode/opencode.jsonc
  2. 49 12
      bun.lock
  3. 1 1
      nix/hashes.json
  4. 11 5
      package.json
  5. 5 5
      packages/console/app/package.json
  6. 3 2
      packages/desktop/package.json
  7. 3 2
      packages/desktop/src/components/file-tree.tsx
  8. 10 5
      packages/desktop/src/components/prompt-input.tsx
  9. 0 104
      packages/desktop/src/components/session-review.tsx
  10. 0 17
      packages/desktop/src/components/sticky-accordion-header.tsx
  11. 1 1
      packages/desktop/src/context/global-sdk.tsx
  12. 2 2
      packages/desktop/src/context/global-sync.tsx
  13. 0 25
      packages/desktop/src/context/helper.tsx
  14. 1 1
      packages/desktop/src/context/layout.tsx
  15. 1 1
      packages/desktop/src/context/local.tsx
  16. 1 1
      packages/desktop/src/context/sdk.tsx
  17. 2 2
      packages/desktop/src/context/session.tsx
  18. 2 2
      packages/desktop/src/context/sync.tsx
  19. 2 1
      packages/desktop/src/index.tsx
  20. 12 3
      packages/desktop/src/pages/directory-layout.tsx
  21. 1 1
      packages/desktop/src/pages/home.tsx
  22. 7 1
      packages/desktop/src/pages/layout.tsx
  23. 33 317
      packages/desktop/src/pages/session.tsx
  24. 1 1
      packages/desktop/src/ui/collapsible.tsx
  25. 0 1
      packages/desktop/src/ui/index.ts
  26. 2 11
      packages/desktop/vite.config.ts
  27. 28 0
      packages/enterprise/.gitignore
  28. 32 0
      packages/enterprise/README.md
  29. 12 0
      packages/enterprise/app.config.ts
  30. 35 0
      packages/enterprise/package.json
  31. BIN
      packages/enterprise/public/favicon.ico
  32. 18 0
      packages/enterprise/src/app.css
  33. 28 0
      packages/enterprise/src/app.tsx
  34. 139 0
      packages/enterprise/src/core/share.ts
  35. 134 0
      packages/enterprise/src/core/storage.ts
  36. 4 0
      packages/enterprise/src/entry-client.tsx
  37. 22 0
      packages/enterprise/src/entry-server.tsx
  38. 1 0
      packages/enterprise/src/global.d.ts
  39. 25 0
      packages/enterprise/src/routes/[...404].tsx
  40. 152 0
      packages/enterprise/src/routes/api/[...path].ts
  41. 5 0
      packages/enterprise/src/routes/share.tsx
  42. 172 0
      packages/enterprise/src/routes/share/[sessionID].tsx
  43. 20 0
      packages/enterprise/tsconfig.json
  44. 2 1
      packages/opencode/package.json
  45. 1 1
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  46. 5 0
      packages/opencode/src/config/config.ts
  47. 2 0
      packages/opencode/src/project/bootstrap.ts
  48. 1 0
      packages/opencode/src/server/server.ts
  49. 17 0
      packages/opencode/src/session/index.ts
  50. 0 2
      packages/opencode/src/session/processor.ts
  51. 148 0
      packages/opencode/src/share/share-next.ts
  52. 0 41
      packages/opencode/src/util/binary.ts
  53. 1 1
      packages/plugin/package.json
  54. 1 1
      packages/sdk/js/package.json
  55. 5 1
      packages/ui/package.json
  56. 0 0
      packages/ui/src/assets/file-icons/3d.svg
  57. 0 0
      packages/ui/src/assets/file-icons/abap.svg
  58. 0 0
      packages/ui/src/assets/file-icons/abc.svg
  59. 0 0
      packages/ui/src/assets/file-icons/actionscript.svg
  60. 0 0
      packages/ui/src/assets/file-icons/ada.svg
  61. 0 0
      packages/ui/src/assets/file-icons/adobe-illustrator.svg
  62. 0 0
      packages/ui/src/assets/file-icons/adobe-illustrator_light.svg
  63. 0 0
      packages/ui/src/assets/file-icons/adobe-photoshop.svg
  64. 0 0
      packages/ui/src/assets/file-icons/adobe-photoshop_light.svg
  65. 0 0
      packages/ui/src/assets/file-icons/adobe-swc.svg
  66. 0 0
      packages/ui/src/assets/file-icons/adonis.svg
  67. 0 0
      packages/ui/src/assets/file-icons/advpl.svg
  68. 0 0
      packages/ui/src/assets/file-icons/amplify.svg
  69. 0 0
      packages/ui/src/assets/file-icons/android.svg
  70. 0 0
      packages/ui/src/assets/file-icons/angular.svg
  71. 0 0
      packages/ui/src/assets/file-icons/antlr.svg
  72. 0 0
      packages/ui/src/assets/file-icons/apiblueprint.svg
  73. 0 0
      packages/ui/src/assets/file-icons/apollo.svg
  74. 0 0
      packages/ui/src/assets/file-icons/applescript.svg
  75. 0 0
      packages/ui/src/assets/file-icons/apps-script.svg
  76. 0 0
      packages/ui/src/assets/file-icons/appveyor.svg
  77. 0 0
      packages/ui/src/assets/file-icons/architecture.svg
  78. 0 0
      packages/ui/src/assets/file-icons/arduino.svg
  79. 0 0
      packages/ui/src/assets/file-icons/asciidoc.svg
  80. 0 0
      packages/ui/src/assets/file-icons/assembly.svg
  81. 0 0
      packages/ui/src/assets/file-icons/astro-config.svg
  82. 0 0
      packages/ui/src/assets/file-icons/astro.svg
  83. 0 0
      packages/ui/src/assets/file-icons/astyle.svg
  84. 0 0
      packages/ui/src/assets/file-icons/audio.svg
  85. 0 0
      packages/ui/src/assets/file-icons/aurelia.svg
  86. 0 0
      packages/ui/src/assets/file-icons/authors.svg
  87. 0 0
      packages/ui/src/assets/file-icons/auto.svg
  88. 0 0
      packages/ui/src/assets/file-icons/auto_light.svg
  89. 0 0
      packages/ui/src/assets/file-icons/autohotkey.svg
  90. 0 0
      packages/ui/src/assets/file-icons/autoit.svg
  91. 0 0
      packages/ui/src/assets/file-icons/azure-pipelines.svg
  92. 0 0
      packages/ui/src/assets/file-icons/azure.svg
  93. 0 0
      packages/ui/src/assets/file-icons/babel.svg
  94. 0 0
      packages/ui/src/assets/file-icons/ballerina.svg
  95. 0 0
      packages/ui/src/assets/file-icons/bazel.svg
  96. 0 0
      packages/ui/src/assets/file-icons/bbx.svg
  97. 0 0
      packages/ui/src/assets/file-icons/beancount.svg
  98. 0 0
      packages/ui/src/assets/file-icons/bench-js.svg
  99. 0 0
      packages/ui/src/assets/file-icons/bench-jsx.svg
  100. 0 0
      packages/ui/src/assets/file-icons/bench-ts.svg

+ 3 - 0
.opencode/opencode.jsonc

@@ -1,6 +1,9 @@
 {
   "$schema": "https://opencode.ai/config.json",
   "plugin": ["opencode-openai-codex-auth"],
+  // "enterprise": {
+  //   "url": "http://localhost:3000",
+  // },
   "provider": {
     "opencode": {
       "options": {

Разница между файлами не показана из-за своего большого размера
+ 49 - 12
bun.lock


+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-bPiUpHGtgwVxHQHXBprpc6fFeJqW6/x7dwtQZBq29oU="
+  "nodeModules": "sha256-LOB0tUZGbysz9FGMiBn0u60UicBr8AE+xauwlYlxkD0="
 }

+ 11 - 5
package.json

@@ -9,7 +9,8 @@
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
-    "random": "echo 'Random script'"
+    "random": "echo 'Random script'",
+    "hello": "echo 'Hello World!'"
   },
   "workspaces": {
     "packages": [
@@ -23,29 +24,33 @@
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
+      "@types/luxon": "3.7.1",
       "@types/node": "22.13.9",
       "@tsconfig/node22": "22.0.2",
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
-      "@pierre/precision-diffs": "0.4.4",
-      "@solidjs/meta": "0.29.4",
+      "@pierre/precision-diffs": "0.5.4",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "ai": "5.0.97",
       "hono": "4.7.10",
+      "hono-openapi": "1.1.1",
       "fuzzysort": "3.1.0",
       "luxon": "3.6.1",
       "typescript": "5.8.2",
       "@typescript/native-preview": "7.0.0-dev.20251014.1",
       "zod": "4.1.8",
       "remeda": "2.26.0",
-      "solid-js": "1.9.9",
       "solid-list": "0.3.0",
       "tailwindcss": "4.1.11",
       "virtua": "0.42.3",
       "vite": "7.1.4",
-      "vite-plugin-solid": "2.11.8"
+      "@solidjs/meta": "0.29.4",
+      "@solidjs/router": "0.15.4",
+      "@solidjs/start": "1.2.0",
+      "solid-js": "1.9.10",
+      "vite-plugin-solid": "2.11.10"
     }
   },
   "devDependencies": {
@@ -56,6 +61,7 @@
     "turbo": "2.5.6"
   },
   "dependencies": {
+    "@aws-sdk/client-s3": "3.933.0",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*"
   },

+ 5 - 5
packages/console/app/package.json

@@ -1,13 +1,13 @@
 {
   "name": "@opencode-ai/console-app",
+  "version": "1.0.90",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo --noEmit",
     "dev": "vinxi dev --host 0.0.0.0",
     "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
     "build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
-    "start": "vinxi start",
-    "version": "1.0.90"
+    "start": "vinxi start"
   },
   "dependencies": {
     "@ibm/plex": "6.4.1",
@@ -17,9 +17,9 @@
     "@opencode-ai/console-core": "workspace:*",
     "@opencode-ai/console-mail": "workspace:*",
     "@opencode-ai/console-resource": "workspace:*",
-    "@solidjs/meta": "^0.29.4",
-    "@solidjs/router": "^0.15.0",
-    "@solidjs/start": "^1.1.0",
+    "@solidjs/meta": "catalog:",
+    "@solidjs/router": "catalog:",
+    "@solidjs/start": "catalog:",
     "chart.js": "4.5.1",
     "solid-js": "catalog:",
     "vinxi": "^0.5.7",

+ 3 - 2
packages/desktop/package.json

@@ -14,7 +14,7 @@
   "devDependencies": {
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/bun": "1.0.9",
-    "@types/luxon": "3.7.1",
+    "@types/luxon": "catalog:",
     "@types/node": "catalog:",
     "@typescript/native-preview": "catalog:",
     "typescript": "catalog:",
@@ -26,6 +26,7 @@
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/event-bus": "1.1.2",
@@ -33,7 +34,7 @@
     "@solid-primitives/scroll": "2.1.3",
     "@solid-primitives/storage": "4.3.3",
     "@solidjs/meta": "catalog:",
-    "@solidjs/router": "0.15.3",
+    "@solidjs/router": "catalog:",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
     "fuzzysort": "catalog:",

+ 3 - 2
packages/desktop/src/components/file-tree.tsx

@@ -1,6 +1,7 @@
 import { useLocal, type LocalFile } from "@/context/local"
-import { Tooltip } from "@opencode-ai/ui"
-import { Collapsible, FileIcon } from "@/ui"
+import { Collapsible } from "@/ui"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
 import { Dynamic } from "solid-js/web"
 

+ 10 - 5
packages/desktop/src/components/prompt-input.tsx

@@ -1,8 +1,6 @@
-import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
 import { createStore } from "solid-js/store"
-import { FileIcon } from "@/ui"
 import { getDirectory, getFilename } from "@/utils"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
@@ -11,6 +9,13 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
 import { useSDK } from "@/context/sdk"
 import { useNavigate } from "@solidjs/router"
 import { useSync } from "@/context/sync"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { SelectDialog } from "@opencode-ai/ui/select-dialog"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Select } from "@opencode-ai/ui/select"
 
 interface PromptInputProps {
   class?: string
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const range = selection.getRangeAt(0)
 
       if (atMatch) {
-        let node: Node | null = range.startContainer
-        let offset = range.startOffset
+        // let node: Node | null = range.startContainer
+        // let offset = range.startOffset
         let runningLength = 0
 
         const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -448,7 +453,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               {(i) => (
                 <div class="w-full flex items-center justify-between gap-x-3">
                   <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
-                    <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
+                    <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
                     <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
                       <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
                       <Show when={i.release_date}>

+ 0 - 104
packages/desktop/src/components/session-review.tsx

@@ -1,104 +0,0 @@
-import { useSession } from "@/context/session"
-import { FileIcon } from "@/ui"
-import { getDirectory, getFilename } from "@/utils"
-import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from "@opencode-ai/ui"
-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 layout = useLayout()
-  const session = useSession()
-  const [store, setStore] = createStore({
-    open: session.diffs().map((d) => d.file),
-  })
-
-  const handleChange = (open: string[]) => {
-    setStore("open", open)
-  }
-
-  const handleExpandOrCollapseAll = () => {
-    if (store.open.length > 0) {
-      setStore("open", [])
-    } else {
-      setStore(
-        "open",
-        session.diffs().map((d) => d.file),
-      )
-    }
-  }
-
-  return (
-    <div
-      classList={{
-        "flex flex-col gap-3 h-full overflow-y-auto no-scrollbar": true,
-        [props.class ?? ""]: !!props.class,
-      }}
-    >
-      <div class="sticky top-0 z-20 bg-background-stronger h-8 shrink-0 flex justify-between items-center self-stretch">
-        <div class="text-14-medium text-text-strong">Session changes</div>
-        <div class="flex items-center gap-x-4 pr-px">
-          <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
-            <Switch>
-              <Match when={store.open.length > 0}>Collapse all</Match>
-              <Match when={true}>Expand all</Match>
-            </Switch>
-          </Button>
-          <Show when={!props.hideExpand}>
-            <Tooltip value="Open in tab">
-              <IconButton
-                icon="expand"
-                variant="ghost"
-                onClick={() => {
-                  layout.review.tab()
-                  session.layout.setActiveTab("review")
-                }}
-              />
-            </Tooltip>
-          </Show>
-        </div>
-      </div>
-      <Accordion multiple value={store.open} onChange={handleChange}>
-        <For each={session.diffs()}>
-          {(diff) => (
-            <Accordion.Item value={diff.file}>
-              <StickyAccordionHeader class="top-11 data-expanded:before:-top-11">
-                <Accordion.Trigger class="bg-background-stronger">
-                  <div class="flex items-center justify-between w-full gap-5">
-                    <div class="grow flex items-center gap-5 min-w-0">
-                      <FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
-                      <div class="flex grow min-w-0">
-                        <Show when={diff.file.includes("/")}>
-                          <span class="text-text-base truncate-start">{getDirectory(diff.file)}&lrm;</span>
-                        </Show>
-                        <span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
-                      </div>
-                    </div>
-                    <div class="shrink-0 flex gap-4 items-center justify-end">
-                      <DiffChanges changes={diff} />
-                      <Icon name="chevron-grabber-vertical" size="small" />
-                    </div>
-                  </div>
-                </Accordion.Trigger>
-              </StickyAccordionHeader>
-              <Accordion.Content>
-                <Diff
-                  diffStyle={props.split ? "split" : "unified"}
-                  before={{
-                    name: diff.file!,
-                    contents: diff.before!,
-                  }}
-                  after={{
-                    name: diff.file!,
-                    contents: diff.after!,
-                  }}
-                />
-              </Accordion.Content>
-            </Accordion.Item>
-          )}
-        </For>
-      </Accordion>
-    </div>
-  )
-}

+ 0 - 17
packages/desktop/src/components/sticky-accordion-header.tsx

@@ -1,17 +0,0 @@
-import { Accordion } from "@opencode-ai/ui"
-import { ParentProps } from "solid-js"
-
-export function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
-  return (
-    <Accordion.Header
-      classList={{
-        "sticky top-0 data-expanded:z-10": true,
-        "data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
-        "data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
-        [props.class ?? ""]: !!props.class,
-      }}
-    >
-      {props.children}
-    </Accordion.Header>
-  )
-}

+ 1 - 1
packages/desktop/src/context/global-sdk.tsx

@@ -1,5 +1,5 @@
 import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { onCleanup } from "solid-js"
 

+ 2 - 2
packages/desktop/src/context/global-sync.tsx

@@ -14,8 +14,8 @@ import type {
   SessionStatus,
 } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
-import { Binary } from "@/utils/binary"
-import { createSimpleContext } from "./helper"
+import { Binary } from "@opencode-ai/util/binary"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 
 type State = {

+ 0 - 25
packages/desktop/src/context/helper.tsx

@@ -1,25 +0,0 @@
-import { createContext, Show, useContext, type ParentProps } from "solid-js"
-
-export function createSimpleContext<T, Props extends Record<string, any>>(input: {
-  name: string
-  init: ((input: Props) => T) | (() => T)
-}) {
-  const ctx = createContext<T>()
-
-  return {
-    provider: (props: ParentProps<Props>) => {
-      const init = input.init(props)
-      return (
-        // @ts-expect-error
-        <Show when={init.ready === undefined || init.ready === true}>
-          <ctx.Provider value={init}>{props.children}</ctx.Provider>
-        </Show>
-      )
-    },
-    use() {
-      const value = useContext(ctx)
-      if (!value) throw new Error(`${input.name} context must be used within a context provider`)
-      return value
-    },
-  }
-}

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

@@ -1,6 +1,6 @@
 import { createStore } from "solid-js/store"
 import { createMemo } from "solid-js"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
 

+ 1 - 1
packages/desktop/src/context/local.tsx

@@ -2,7 +2,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { batch, createEffect, createMemo } from "solid-js"
 import { uniqueBy } from "remeda"
 import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
 import { base64Encode } from "@/utils"

+ 1 - 1
packages/desktop/src/context/sdk.tsx

@@ -1,5 +1,5 @@
 import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { onCleanup } from "solid-js"
 import { useGlobalSDK } from "./global-sdk"

+ 2 - 2
packages/desktop/src/context/session.tsx

@@ -1,5 +1,5 @@
 import { createStore, produce } from "solid-js/store"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createEffect, createMemo } from "solid-js"
 import { useSync } from "./sync"
 import { makePersisted } from "@solid-primitives/storage"
@@ -60,7 +60,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
     })
     const status = createMemo(
       () =>
-        sync.data.session_status[params.id] ?? {
+        sync.data.session_status[params.id ?? ""] ?? {
           type: "idle",
         },
     )

+ 2 - 2
packages/desktop/src/context/sync.tsx

@@ -1,8 +1,8 @@
 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 { Binary } from "@opencode-ai/util/binary"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 

+ 2 - 1
packages/desktop/src/index.tsx

@@ -3,7 +3,8 @@ import "@/index.css"
 import { render } from "solid-js/web"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
-import { Fonts, MarkedProvider } from "@opencode-ai/ui"
+import { Fonts } from "@opencode-ai/ui/fonts"
+import { MarkedProvider } from "@opencode-ai/ui/context/marked"
 import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
 import Layout from "@/pages/layout"
 import DirectoryLayout from "@/pages/directory-layout"

+ 12 - 3
packages/desktop/src/pages/directory-layout.tsx

@@ -1,22 +1,31 @@
 import { createMemo, type ParentProps } from "solid-js"
 import { useParams } from "@solidjs/router"
 import { SDKProvider } from "@/context/sdk"
-import { SyncProvider } from "@/context/sync"
+import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
 import { useGlobalSync } from "@/context/global-sync"
 import { base64Decode } from "@/utils"
+import { DataProvider } from "@opencode-ai/ui/context"
+import { iife } from "@opencode-ai/util/iife"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
   const sync = useGlobalSync()
   const directory = createMemo(() => {
-    const decoded = base64Decode(params.dir)
+    const decoded = base64Decode(params.dir!)
     return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
   })
   return (
     <SDKProvider directory={directory()}>
       <SyncProvider>
-        <LocalProvider>{props.children}</LocalProvider>
+        {iife(() => {
+          const sync = useSync()
+          return (
+            <DataProvider data={sync.data}>
+              <LocalProvider>{props.children}</LocalProvider>
+            </DataProvider>
+          )
+        })}
       </SyncProvider>
     </SDKProvider>
   )

+ 1 - 1
packages/desktop/src/pages/home.tsx

@@ -2,7 +2,7 @@ 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"
+import { Button } from "@opencode-ai/ui/button"
 
 export default function Home() {
   const sync = useGlobalSync()

+ 7 - 1
packages/desktop/src/pages/layout.tsx

@@ -1,10 +1,16 @@
-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 { A, useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { base64Encode, getFilename } from "@/utils"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()

+ 33 - 317
packages/desktop/src/pages/session.tsx

@@ -1,38 +1,20 @@
-import {
-  SelectDialog,
-  IconButton,
-  Tabs,
-  Icon,
-  Accordion,
-  Diff,
-  Collapsible,
-  DiffChanges,
-  Message,
-  Typewriter,
-  Card,
-  Code,
-  Tooltip,
-  ProgressCircle,
-} from "@opencode-ai/ui"
-import { FileIcon } from "@/ui"
-import { MessageProgress } from "@/components/message-progress"
-import {
-  For,
-  onCleanup,
-  onMount,
-  Show,
-  Match,
-  Switch,
-  createSignal,
-  createEffect,
-  createMemo,
-  createResource,
-} from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
 import { getDirectory, getFilename } from "@/utils"
 import { PromptInput } from "@/components/prompt-input"
 import { DateTime } from "luxon"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { Code } from "@opencode-ai/ui/code"
+import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import { SelectDialog } from "@opencode-ai/ui/select-dialog"
 import {
   DragDropProvider,
   DragDropSensors,
@@ -45,14 +27,8 @@ import {
 import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
 import type { JSX } from "solid-js"
 import { useSync } from "@/context/sync"
-import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
-import { Markdown } from "@opencode-ai/ui"
-import { Spinner } from "@/components/spinner"
 import { useSession } from "@/context/session"
-import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
-import { SessionReview } from "@/components/session-review"
 import { useLayout } from "@/context/layout"
-import { createSessionSeen } from "@/hooks/create-session-seen"
 
 export default function Page() {
   const layout = useLayout()
@@ -65,7 +41,6 @@ export default function Page() {
     activeDraggable: undefined as string | undefined,
   })
   let inputRef!: HTMLDivElement
-  let messageScrollElement!: HTMLDivElement
 
   const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
 
@@ -358,284 +333,11 @@ export default function Page() {
               <div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
                 <Switch>
                   <Match when={session.id}>
-                    <div
-                      classList={{
-                        "flex-1 min-h-0 pb-20": true,
-                        "flex items-start justify-start": layout.review.state() === "pane",
-                      }}
-                    >
-                      <Show when={session.messages.user().length > 1}>
-                        {(_) => {
-                          const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
-
-                          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 working = createMemo(
-                                    () => message.id === session.messages.last()?.id && session.working(),
-                                  )
-                                  const handleClick = () => session.messages.setActive(message.id)
-
-                                  return (
-                                    <li
-                                      classList={{
-                                        "group/li flex items-center self-stretch justify-end": true,
-                                        "@7xl:justify-start": expanded(),
-                                      }}
-                                    >
-                                      <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()}>
-                          {(message) => {
-                            const isActive = createMemo(() => session.messages.active()?.id === message.id)
-                            const titleSeen = createSessionSeen(`message-title-${message.id}`)
-                            const contentSeen = createSessionSeen(`message-content-${message.id}`)
-                            const [titled, setTitled] = createSignal(titleSeen())
-                            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 [detailsExpanded, setDetailsExpanded] = createSignal(false)
-                            const parts = createMemo(() => sync.data.part[message.id])
-                            const hasToolPart = createMemo(() =>
-                              assistantMessages()
-                                ?.flatMap((m) => sync.data.part[m.id])
-                                .some((p) => p?.type === "tool"),
-                            )
-                            const working = createMemo(
-                              () => message.id === session.messages.last()?.id && session.working(),
-                            )
-                            const initialCompleted = !(message.id === session.messages.last()?.id && session.working())
-                            const [completed, setCompleted] = createSignal(initialCompleted)
-
-                            // allowing time for the animations to finish
-                            createEffect(() => {
-                              if (titleSeen()) return
-                              const title = message.summary?.title
-                              if (title) setTimeout(() => setTitled(true), 10_000)
-                            })
-                            createEffect(() => {
-                              const completed = !working()
-                              setTimeout(() => setCompleted(completed), 1200)
-                            })
-
-                            return (
-                              <Show when={isActive()}>
-                                <div
-                                  data-message={message.id}
-                                  class="flex flex-col items-start self-stretch gap-8 pb-20"
-                                >
-                                  {/* Title */}
-                                  <div class="flex items-center gap-2 self-stretch sticky top-0 bg-background-stronger z-20 h-8">
-                                    <div class="w-full text-14-medium text-text-strong">
-                                      <Show
-                                        when={titled()}
-                                        fallback={
-                                          <Typewriter
-                                            as="h1"
-                                            text={message.summary?.title}
-                                            class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
-                                          />
-                                        }
-                                      >
-                                        <h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
-                                          {message.summary?.title}
-                                        </h1>
-                                      </Show>
-                                    </div>
-                                  </div>
-                                  <Message message={message} parts={parts()} />
-                                  {/* Summary */}
-                                  <Show when={completed()}>
-                                    <div class="w-full flex flex-col gap-6 items-start self-stretch">
-                                      <div class="flex flex-col items-start gap-1 self-stretch">
-                                        <h2 class="text-12-medium text-text-weak">
-                                          <Switch>
-                                            <Match when={message.summary?.diffs?.length}>Summary</Match>
-                                            <Match when={true}>Response</Match>
-                                          </Switch>
-                                        </h2>
-                                        <Show when={message.summary?.body}>
-                                          {(summary) => (
-                                            <Markdown
-                                              classList={{
-                                                "text-14-regular": !!message.summary?.diffs?.length,
-                                                "[&>*]:fade-up-text": !message.summary?.diffs?.length && !contentSeen(),
-                                              }}
-                                              text={summary()}
-                                            />
-                                          )}
-                                        </Show>
-                                      </div>
-                                      <Accordion class="w-full" multiple>
-                                        <For each={message.summary?.diffs ?? []}>
-                                          {(diff) => (
-                                            <Accordion.Item value={diff.file}>
-                                              <StickyAccordionHeader class="top-10 data-expanded:before:-top-10">
-                                                <Accordion.Trigger>
-                                                  <div class="flex items-center justify-between w-full gap-5">
-                                                    <div class="grow flex items-center gap-5 min-w-0">
-                                                      <FileIcon
-                                                        node={{ path: diff.file, type: "file" }}
-                                                        class="shrink-0 size-4"
-                                                      />
-                                                      <div class="flex grow min-w-0">
-                                                        <Show when={diff.file.includes("/")}>
-                                                          <span class="text-text-base truncate-start">
-                                                            {getDirectory(diff.file)}&lrm;
-                                                          </span>
-                                                        </Show>
-                                                        <span class="text-text-strong shrink-0">
-                                                          {getFilename(diff.file)}
-                                                        </span>
-                                                      </div>
-                                                    </div>
-                                                    <div class="shrink-0 flex gap-4 items-center justify-end">
-                                                      <DiffChanges changes={diff} />
-                                                      <Icon name="chevron-grabber-vertical" size="small" />
-                                                    </div>
-                                                  </div>
-                                                </Accordion.Trigger>
-                                              </StickyAccordionHeader>
-                                              <Accordion.Content class="max-h-60 overflow-y-auto no-scrollbar">
-                                                <Diff
-                                                  before={{
-                                                    name: diff.file!,
-                                                    contents: diff.before!,
-                                                  }}
-                                                  after={{
-                                                    name: diff.file!,
-                                                    contents: diff.after!,
-                                                  }}
-                                                />
-                                              </Accordion.Content>
-                                            </Accordion.Item>
-                                          )}
-                                        </For>
-                                      </Accordion>
-                                    </div>
-                                  </Show>
-                                  <Show when={error() && !detailsExpanded()}>
-                                    <Card variant="error" class="text-text-on-critical-base">
-                                      {error()?.data?.message as string}
-                                    </Card>
-                                  </Show>
-                                  {/* Response */}
-                                  <div class="w-full">
-                                    <Switch>
-                                      <Match when={!completed()}>
-                                        <MessageProgress assistantMessages={assistantMessages} done={!working()} />
-                                      </Match>
-                                      <Match when={completed() && hasToolPart()}>
-                                        <Collapsible
-                                          variant="ghost"
-                                          open={detailsExpanded()}
-                                          onOpenChange={setDetailsExpanded}
-                                        >
-                                          <Collapsible.Trigger class="text-text-weak hover:text-text-strong">
-                                            <div class="flex items-center gap-1 self-stretch">
-                                              <div class="text-12-medium">
-                                                <Switch>
-                                                  <Match when={detailsExpanded()}>Hide details</Match>
-                                                  <Match when={!detailsExpanded()}>Show details</Match>
-                                                </Switch>
-                                              </div>
-                                              <Collapsible.Arrow />
-                                            </div>
-                                          </Collapsible.Trigger>
-                                          <Collapsible.Content>
-                                            <div class="w-full flex flex-col items-start self-stretch gap-3">
-                                              <For each={assistantMessages()}>
-                                                {(assistantMessage) => {
-                                                  const parts = createMemo(() => sync.data.part[assistantMessage.id])
-                                                  return <Message message={assistantMessage} parts={parts()} />
-                                                }}
-                                              </For>
-                                              <Show when={error()}>
-                                                <Card variant="error" class="text-text-on-critical-base">
-                                                  {error()?.data?.message as string}
-                                                </Card>
-                                              </Show>
-                                            </div>
-                                          </Collapsible.Content>
-                                        </Collapsible>
-                                      </Match>
-                                    </Switch>
-                                  </div>
-                                </div>
-                              </Show>
-                            )
-                          }}
-                        </For>
-                      </div>
-                    </div>
+                    <SessionTimeline
+                      sessionID={session.id!}
+                      expanded={layout.review.state() === "tab" || !session.diffs().length}
+                      classes={{ root: "pb-20", container: "pb-20" }}
+                    />
                   </Match>
                   <Match when={true}>
                     <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
@@ -673,7 +375,21 @@ export default function Page() {
                     "relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
                   }}
                 >
-                  <SessionReview />
+                  <SessionReview
+                    diffs={session.diffs()}
+                    actions={
+                      <Tooltip value="Open in tab">
+                        <IconButton
+                          icon="expand"
+                          variant="ghost"
+                          onClick={() => {
+                            layout.review.tab()
+                            session.layout.setActiveTab("review")
+                          }}
+                        />
+                      </Tooltip>
+                    }
+                  />
                 </div>
               </Show>
             </div>
@@ -685,7 +401,7 @@ export default function Page() {
                   "relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
                 }}
               >
-                <SessionReview split hideExpand class="pb-40" />
+                <SessionReview diffs={session.diffs()} split class="pb-40" />
               </div>
             </Tabs.Content>
           </Show>

+ 1 - 1
packages/desktop/src/ui/collapsible.tsx

@@ -1,7 +1,7 @@
 import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
+import { Icon, IconProps } from "@opencode-ai/ui/icon"
 import { splitProps } from "solid-js"
 import type { ComponentProps, ParentProps } from "solid-js"
-import { Icon, type IconProps } from "@opencode-ai/ui"
 
 export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
 export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}

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

@@ -4,4 +4,3 @@ export {
   type CollapsibleTriggerProps,
   type CollapsibleContentProps,
 } from "./collapsible"
-export { FileIcon, type FileIconProps } from "./file-icon"

+ 2 - 11
packages/desktop/vite.config.ts

@@ -2,7 +2,6 @@ import { defineConfig } from "vite"
 import solidPlugin from "vite-plugin-solid"
 import tailwindcss from "@tailwindcss/vite"
 import path from "path"
-import { iconsSpritesheet } from "vite-plugin-icons-spritesheet"
 
 export default defineConfig({
   resolve: {
@@ -10,18 +9,10 @@ export default defineConfig({
       "@": path.resolve(__dirname, "./src"),
     },
   },
-  plugins: [
-    tailwindcss(),
-    solidPlugin(),
-    iconsSpritesheet({
-      withTypes: true,
-      inputDir: "src/assets/file-icons",
-      outputDir: "src/ui/file-icons",
-      formatter: "prettier",
-    }),
-  ],
+  plugins: [tailwindcss(), solidPlugin()],
   server: {
     host: "0.0.0.0",
+    allowedHosts: true,
     port: 3000,
   },
   build: {

+ 28 - 0
packages/enterprise/.gitignore

@@ -0,0 +1,28 @@
+dist
+.wrangler
+.output
+.vercel
+.netlify
+.vinxi
+app.config.timestamp_*.js
+
+# Environment
+.env
+.env*.local
+
+# dependencies
+/node_modules
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+*.launch
+.settings/
+
+# Temp
+gitignore
+
+# System Files
+.DS_Store
+Thumbs.db

+ 32 - 0
packages/enterprise/README.md

@@ -0,0 +1,32 @@
+# SolidStart
+
+Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
+
+## Creating a project
+
+```bash
+# create a new project in the current directory
+npm init solid@latest
+
+# create a new project in my-app
+npm init solid@latest my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```bash
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+Solid apps are built with _presets_, which optimise your project for deployment to different environments.
+
+By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
+
+## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)

+ 12 - 0
packages/enterprise/app.config.ts

@@ -0,0 +1,12 @@
+import { defineConfig } from "@solidjs/start/config"
+import tailwindcss from "@tailwindcss/vite"
+
+export default defineConfig({
+  vite: {
+    plugins: [tailwindcss() as any],
+    server: {
+      host: "0.0.0.0",
+      allowedHosts: true,
+    },
+  },
+})

+ 35 - 0
packages/enterprise/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "@opencode-ai/enterprise",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "typecheck": "tsgo --noEmit",
+    "dev": "vinxi dev",
+    "build": "vinxi build",
+    "start": "vinxi start"
+  },
+  "dependencies": {
+    "@opencode-ai/util": "workspace:*",
+    "@opencode-ai/ui": "workspace:*",
+    "@solidjs/router": "catalog:",
+    "@solidjs/start": "catalog:",
+    "@solidjs/meta": "catalog:",
+    "hono": "catalog:",
+    "hono-openapi": "catalog:",
+    "luxon": "catalog:",
+    "solid-js": "catalog:",
+    "vinxi": "^0.5.7",
+    "zod": "catalog:"
+  },
+  "devDependencies": {
+    "@tailwindcss/vite": "catalog:",
+    "@typescript/native-preview": "catalog:",
+    "@types/luxon": "catalog:",
+    "tailwindcss": "catalog:",
+    "typescript": "catalog:"
+  },
+  "engines": {
+    "node": ">=22"
+  }
+}

BIN
packages/enterprise/public/favicon.ico


+ 18 - 0
packages/enterprise/src/app.css

@@ -0,0 +1,18 @@
+@import "@opencode-ai/ui/styles/tailwind";
+
+:root {
+  --background-rgb: 214, 219, 220;
+  --foreground-rgb: 0, 0, 0;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --background-rgb: 0, 0, 0;
+    --foreground-rgb: 255, 255, 255;
+  }
+}
+
+body {
+  /* background: rgb(var(--background-rgb)); */
+  /* color: rgb(var(--foreground-rgb)); */
+}

+ 28 - 0
packages/enterprise/src/app.tsx

@@ -0,0 +1,28 @@
+import { Router } from "@solidjs/router"
+import { FileRoutes } from "@solidjs/start/router"
+import { Suspense } from "solid-js"
+import { Fonts } from "@opencode-ai/ui/fonts"
+import { MetaProvider } from "@solidjs/meta"
+import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import "./app.css"
+
+export default function App() {
+  return (
+    <Router
+      root={(props) => (
+        <>
+          <Suspense>
+            <MarkedProvider>
+              <MetaProvider>
+                <Fonts />
+                {props.children}
+              </MetaProvider>
+            </MarkedProvider>
+          </Suspense>
+        </>
+      )}
+    >
+      <FileRoutes />
+    </Router>
+  )
+}

+ 139 - 0
packages/enterprise/src/core/share.ts

@@ -0,0 +1,139 @@
+import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
+import { fn } from "@opencode-ai/util/fn"
+import { iife } from "@opencode-ai/util/iife"
+import z from "zod"
+import { Storage } from "./storage"
+
+export namespace Share {
+  export const Info = z.object({
+    id: z.string(),
+    secret: z.string(),
+  })
+  export type Info = z.infer<typeof Info>
+
+  export const Data = z.discriminatedUnion("type", [
+    z.object({
+      type: z.literal("session"),
+      data: z.custom<Session>(),
+    }),
+    z.object({
+      type: z.literal("message"),
+      data: z.custom<Message>(),
+    }),
+    z.object({
+      type: z.literal("part"),
+      data: z.custom<Part>(),
+    }),
+    z.object({
+      type: z.literal("session_diff"),
+      data: z.custom<FileDiff[]>(),
+    }),
+    z.object({
+      type: z.literal("session_status"),
+      data: z.custom<SessionStatus>(),
+    }),
+  ])
+  export type Data = z.infer<typeof Data>
+
+  export const create = fn(Info.pick({ id: true }), async (body) => {
+    const info: Info = {
+      id: body.id,
+      secret: crypto.randomUUID(),
+    }
+    const exists = await get(info.id)
+    if (exists) throw new Errors.AlreadyExists(info.id)
+    await Storage.write(["share", info.id], info)
+    console.log("created share", info.id)
+    return info
+  })
+
+  async function get(sessionID: string) {
+    return Storage.read<Info>(["share", sessionID])
+  }
+
+  export const remove = fn(Info.pick({ id: true, secret: true }), async (body) => {
+    const share = await get(body.id)
+    if (!share) throw new Errors.NotFound(body.id)
+    if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
+    await Storage.remove(["share", body.id])
+    const list = await Storage.list(["share_data", body.id])
+    for (const item of list) {
+      await Storage.remove(item)
+    }
+  })
+
+  export async function data(sessionID: string) {
+    const list = await Storage.list(["share_data", sessionID])
+    const promises = []
+    for (const item of list) {
+      promises.push(
+        iife(async () => {
+          const [, , type] = item
+          return {
+            type: type as any,
+            data: await Storage.read<any>(item),
+          } as Data
+        }),
+      )
+    }
+    return await Promise.all(promises)
+  }
+
+  export const sync = fn(
+    z.object({
+      share: Info,
+      data: Data.array(),
+    }),
+    async (input) => {
+      const share = await get(input.share.id)
+      if (!share) throw new Errors.NotFound(input.share.id)
+      if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
+      const promises = []
+      for (const item of input.data) {
+        promises.push(
+          iife(async () => {
+            switch (item.type) {
+              case "session":
+                await Storage.write(["share_data", input.share.id, "session"], item.data)
+                break
+              case "message":
+                await Storage.write(["share_data", input.share.id, "message", item.data.id], item.data)
+                break
+              case "part":
+                await Storage.write(
+                  ["share_data", input.share.id, "part", item.data.messageID, item.data.id],
+                  item.data,
+                )
+                break
+              case "session_diff":
+                await Storage.write(["share_data", input.share.id, "session_diff"], item.data)
+                break
+              case "session_status":
+                await Storage.write(["share_data", input.share.id, "session_status"], item.data)
+                break
+            }
+          }),
+        )
+      }
+      await Promise.all(promises)
+    },
+  )
+
+  export const Errors = {
+    NotFound: class extends Error {
+      constructor(public id: string) {
+        super(`Share not found: ${id}`)
+      }
+    },
+    InvalidSecret: class extends Error {
+      constructor(public id: string) {
+        super(`Share secret invalid: ${id}`)
+      }
+    },
+    AlreadyExists: class extends Error {
+      constructor(public id: string) {
+        super(`Share already exists: ${id}`)
+      }
+    },
+  }
+}

+ 134 - 0
packages/enterprise/src/core/storage.ts

@@ -0,0 +1,134 @@
+import {
+  S3Client,
+  PutObjectCommand,
+  GetObjectCommand,
+  DeleteObjectCommand,
+  ListObjectsV2Command,
+} from "@aws-sdk/client-s3"
+import { lazy } from "@opencode-ai/util/lazy"
+
+export namespace Storage {
+  export interface Adapter {
+    read(path: string): Promise<string | undefined>
+    write(path: string, value: string): Promise<void>
+    remove(path: string): Promise<void>
+    list(prefix: string): Promise<string[]>
+  }
+
+  function createAdapter(client: S3Client, bucket: string): Adapter {
+    return {
+      async read(path: string): Promise<string | undefined> {
+        try {
+          console.log("reading", bucket, path)
+          const command = new GetObjectCommand({
+            Bucket: bucket,
+            Key: path,
+          })
+          const response = await client.send(command)
+          if (!response.Body) return undefined
+          return response.Body.transformToString()
+        } catch (e: any) {
+          if (e.name === "NoSuchKey") return undefined
+          throw e
+        }
+      },
+
+      async write(path: string, value: string): Promise<void> {
+        const command = new PutObjectCommand({
+          Bucket: bucket,
+          Key: path,
+          Body: value,
+          ContentType: "application/json",
+        })
+        await client.send(command)
+      },
+
+      async remove(path: string): Promise<void> {
+        const command = new DeleteObjectCommand({
+          Bucket: bucket,
+          Key: path,
+        })
+        await client.send(command)
+      },
+
+      async list(prefix: string): Promise<string[]> {
+        const command = new ListObjectsV2Command({
+          Bucket: bucket,
+          Prefix: prefix,
+        })
+        const response = await client.send(command)
+        return response.Contents?.map((c) => c.Key!) || []
+      },
+    }
+  }
+
+  function s3(): Adapter {
+    const bucket = process.env.OPENCODE_STORAGE_BUCKET!
+    const client = new S3Client({
+      region: process.env.OPENCODE_STORAGE_REGION,
+      credentials: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID
+        ? {
+            accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!,
+            secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!,
+          }
+        : undefined,
+    })
+    return createAdapter(client, bucket)
+  }
+
+  function r2() {
+    const accountId = process.env.OPENCODE_STORAGE_ACCOUNT_ID!
+    const accessKeyId = process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!
+    const secretAccessKey = process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!
+    const bucket = process.env.OPENCODE_STORAGE_BUCKET!
+
+    const client = new S3Client({
+      region: "auto",
+      endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
+      credentials: {
+        accessKeyId,
+        secretAccessKey,
+      },
+    })
+    return createAdapter(client, bucket)
+  }
+
+  const adapter = lazy(() => {
+    const type = process.env.OPENCODE_STORAGE_ADAPTER
+    if (type === "r2") return r2()
+    if (type === "s3") return s3()
+    throw new Error("No storage adapter configured")
+  })
+
+  function resolve(key: string[]) {
+    return key.join("/") + ".json"
+  }
+
+  export async function read<T>(key: string[]) {
+    const result = await adapter().read(resolve(key))
+    if (!result) return undefined
+    return JSON.parse(result) as T
+  }
+
+  export function write<T>(key: string[], value: T) {
+    return adapter().write(resolve(key), JSON.stringify(value))
+  }
+
+  export function remove(key: string[]) {
+    return adapter().remove(resolve(key))
+  }
+
+  export async function list(prefix: string[]) {
+    const p = prefix.join("/") + (prefix.length ? "/" : "")
+    const result = await adapter().list(p)
+    return result.map((x) => x.replace(/\.json$/, "").split("/"))
+  }
+
+  export async function update<T>(key: string[], fn: (draft: T) => void) {
+    const val = await read<T>(key)
+    if (!val) throw new Error("Not found")
+    fn(val)
+    await write(key, val)
+    return val
+  }
+}

+ 4 - 0
packages/enterprise/src/entry-client.tsx

@@ -0,0 +1,4 @@
+// @refresh reload
+import { mount, StartClient } from "@solidjs/start/client"
+
+mount(() => <StartClient />, document.getElementById("app")!)

+ 22 - 0
packages/enterprise/src/entry-server.tsx

@@ -0,0 +1,22 @@
+// @refresh reload
+import { createHandler, StartServer } from "@solidjs/start/server"
+
+export default createHandler(() => (
+  <StartServer
+    document={({ assets, children, scripts }) => (
+      <html lang="en">
+        <head>
+          <meta charset="utf-8" />
+          <meta name="viewport" content="width=device-width, initial-scale=1" />
+          <link rel="icon" href="/favicon.ico" />
+          <title>OpenCode</title>
+          {assets}
+        </head>
+        <body class="antialiased overscroll-none select-none text-12-regular">
+          <div id="app">{children}</div>
+          {scripts}
+        </body>
+      </html>
+    )}
+  />
+))

+ 1 - 0
packages/enterprise/src/global.d.ts

@@ -0,0 +1 @@
+/// <reference types="@solidjs/start/env" />

+ 25 - 0
packages/enterprise/src/routes/[...404].tsx

@@ -0,0 +1,25 @@
+import { A } from "@solidjs/router"
+
+export default function NotFound() {
+  return (
+    <main class="text-center mx-auto text-gray-700 p-4">
+      <h1 class="max-6-xs text-6xl text-sky-700 font-thin uppercase my-16">Not Found</h1>
+      <p class="mt-8">
+        Visit{" "}
+        <a href="https://solidjs.com" target="_blank" class="text-sky-600 hover:underline">
+          solidjs.com
+        </a>{" "}
+        to learn how to build Solid apps.
+      </p>
+      <p class="my-4">
+        <A href="/" class="text-sky-600 hover:underline">
+          Home
+        </A>
+        {" - "}
+        <A href="/about" class="text-sky-600 hover:underline">
+          About Page
+        </A>
+      </p>
+    </main>
+  )
+}

+ 152 - 0
packages/enterprise/src/routes/api/[...path].ts

@@ -0,0 +1,152 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { Hono } from "hono"
+import { describeRoute, openAPIRouteHandler, resolver } from "hono-openapi"
+import { validator } from "hono-openapi"
+import z from "zod"
+import { cors } from "hono/cors"
+import { Share } from "~/core/share"
+
+const app = new Hono()
+
+app
+  .basePath("/api")
+  .use(cors())
+  .get(
+    "/doc",
+    openAPIRouteHandler(app, {
+      documentation: {
+        info: {
+          title: "Opencode Enterprise API",
+          version: "1.0.0",
+          description: "Opencode Enterprise API endpoints",
+        },
+        openapi: "3.1.1",
+      },
+    }),
+  )
+  .post(
+    "/share",
+    describeRoute({
+      description: "Create a share",
+      operationId: "share.create",
+      responses: {
+        200: {
+          description: "Success",
+          content: {
+            "application/json": {
+              schema: resolver(
+                z
+                  .object({
+                    url: z.string(),
+                    secret: z.string(),
+                  })
+                  .meta({ ref: "Share" }),
+              ),
+            },
+          },
+        },
+      },
+    }),
+    validator("json", z.object({ sessionID: z.string() })),
+    async (c) => {
+      const body = c.req.valid("json")
+      const share = await Share.create({ id: body.sessionID })
+      const protocol = c.req.header("x-forwarded-proto") ?? c.req.header("x-forwarded-protocol") ?? "https"
+      const host = c.req.header("x-forwarded-host") ?? c.req.header("host")
+      return c.json({
+        secret: share.secret,
+        url: `${protocol}://${host}/share/${share.id}`,
+      })
+    },
+  )
+  .post(
+    "/share/:sessionID/sync",
+    describeRoute({
+      description: "Sync share data",
+      operationId: "share.sync",
+      responses: {
+        200: {
+          description: "Success",
+          content: {
+            "application/json": {
+              schema: resolver(z.object({})),
+            },
+          },
+        },
+      },
+    }),
+    validator("param", z.object({ sessionID: z.string() })),
+    validator("json", z.object({ secret: z.string(), data: Share.Data.array() })),
+    async (c) => {
+      const { sessionID } = c.req.valid("param")
+      const body = c.req.valid("json")
+      await Share.sync({
+        share: { id: sessionID, secret: body.secret },
+        data: body.data,
+      })
+      return c.json({})
+    },
+  )
+  .get(
+    "/share/:sessionID/data",
+    describeRoute({
+      description: "Get share data",
+      operationId: "share.data",
+      responses: {
+        200: {
+          description: "Success",
+          content: {
+            "application/json": {
+              schema: resolver(z.array(Share.Data)),
+            },
+          },
+        },
+      },
+    }),
+    validator("param", z.object({ sessionID: z.string() })),
+    async (c) => {
+      const { sessionID } = c.req.valid("param")
+      return c.json(await Share.data(sessionID))
+    },
+  )
+  .delete(
+    "/share/:sessionID",
+    describeRoute({
+      description: "Remove a share",
+      operationId: "share.remove",
+      responses: {
+        200: {
+          description: "Success",
+          content: {
+            "application/json": {
+              schema: resolver(z.object({})),
+            },
+          },
+        },
+      },
+    }),
+    validator("param", z.object({ sessionID: z.string() })),
+    validator("json", z.object({ secret: z.string() })),
+    async (c) => {
+      const { sessionID } = c.req.valid("param")
+      const body = c.req.valid("json")
+      await Share.remove({ id: sessionID, secret: body.secret })
+      return c.json({})
+    },
+  )
+
+export function GET(event: APIEvent) {
+  return app.fetch(event.request)
+}
+
+export function POST(event: APIEvent) {
+  return app.fetch(event.request)
+}
+
+export function PUT(event: APIEvent) {
+  return app.fetch(event.request)
+}
+
+export async function DELETE(event: APIEvent) {
+  return app.fetch(event.request)
+}

+ 5 - 0
packages/enterprise/src/routes/share.tsx

@@ -0,0 +1,5 @@
+import { ParentProps } from "solid-js"
+
+export default function Share(props: ParentProps) {
+  return props.children
+}

+ 172 - 0
packages/enterprise/src/routes/share/[sessionID].tsx

@@ -0,0 +1,172 @@
+import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk"
+import { SessionTimeline } from "@opencode-ai/ui/session-timeline"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import { DataProvider, useData } from "@opencode-ai/ui/context"
+import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router"
+import { createMemo, Show } from "solid-js"
+import { Share } from "~/core/share"
+import { Logo, Mark } from "@opencode-ai/ui/logo"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { iife } from "@opencode-ai/util/iife"
+import { Binary } from "@opencode-ai/util/binary"
+import { DateTime } from "luxon"
+
+const getData = query(async (sessionID) => {
+  const data = await Share.data(sessionID)
+  const result: {
+    session: Session[]
+    session_diff: {
+      [sessionID: string]: FileDiff[]
+    }
+    session_status: {
+      [sessionID: string]: SessionStatus
+    }
+    message: {
+      [sessionID: string]: Message[]
+    }
+    part: {
+      [messageID: string]: Part[]
+    }
+  } = {
+    session: [],
+    session_diff: {
+      [sessionID]: [],
+    },
+    session_status: {
+      [sessionID]: {
+        type: "idle",
+      },
+    },
+    message: {},
+    part: {},
+  }
+
+  for (const item of data) {
+    switch (item.type) {
+      case "session":
+        result.session.push(item.data)
+        break
+      case "session_diff":
+        result.session_diff[sessionID] = item.data
+        break
+      case "session_status":
+        result.session_status[sessionID] = item.data
+        break
+      case "message":
+        result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
+        result.message[item.data.sessionID].push(item.data)
+        break
+      case "part":
+        result.part[item.data.messageID] = result.part[item.data.messageID] ?? []
+        result.part[item.data.messageID].push(item.data)
+        break
+    }
+  }
+  return result
+}, "getShareData")
+
+export const route = {
+  preload: ({ params }) => getData(params.sessionID),
+} satisfies RouteDefinition
+
+export default function () {
+  const params = useParams()
+  const data = createAsync(async () => {
+    if (!params.sessionID) return
+    return getData(params.sessionID)
+  })
+
+  return (
+    <Show when={data()}>
+      {(data) => (
+        <DataProvider data={data()}>
+          {iife(() => {
+            const data = useData()
+            const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id))
+            if (!match().found) throw new Error(`Session ${params.sessionID} not found`)
+            const info = createMemo(() => data.session[match().index])
+            const firstUserMessage = createMemo(() =>
+              data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0),
+            )
+            const provider = createMemo(() => firstUserMessage()?.model?.providerID)
+            const model = createMemo(() => firstUserMessage()?.model?.modelID)
+            const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? [])
+
+            return (
+              <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
+                <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
+                  <div class="">
+                    <a href="https://opencode.ai">
+                      <Mark />
+                    </a>
+                  </div>
+                  <div class="flex gap-3 items-center">
+                    <IconButton
+                      as={"a"}
+                      href="https://github.com/sst/opencode"
+                      target="_blank"
+                      icon="github"
+                      variant="ghost"
+                    />
+                    <IconButton
+                      as={"a"}
+                      href="https://opencode.ai/discord"
+                      target="_blank"
+                      icon="discord"
+                      variant="ghost"
+                    />
+                  </div>
+                </header>
+                <div class="select-text flex flex-col flex-1 min-h-0">
+                  <div class="w-full flex-1 min-h-0 flex">
+                    <div
+                      classList={{
+                        "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true,
+                        "px-21 @4xl:px-6 max-w-2xl": diffs().length > 0,
+                        "px-6 max-w-2xl": diffs().length === 0,
+                      }}
+                    >
+                      <div class="flex flex-col gap-4 shrink-0">
+                        <div class="h-8 flex gap-4 items-center justify-start self-stretch">
+                          <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
+                            <Mark class="shrink-0 w-3 my-0.5" />
+                            <div class="text-12-mono text-text-base">v{info().version}</div>
+                          </div>
+                          <div class="flex gap-2 items-center">
+                            <img
+                              src={`https://models.dev/logos/${provider()}.svg`}
+                              class="size-4 shrink-0 dark:invert"
+                            />
+                            <div class="text-12-regular text-text-base">{model()}</div>
+                          </div>
+                          <div class="text-12-regular text-text-weaker">
+                            {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
+                          </div>
+                        </div>
+                        <div class="text-left text-16-medium text-text-strong">{info().title}</div>
+                      </div>
+                      <SessionTimeline
+                        sessionID={params.sessionID!}
+                        classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
+                        expanded
+                      >
+                        <div class="flex items-center justify-center pb-8 shrink-0">
+                          <Logo class="w-58.5 opacity-12" />
+                        </div>
+                      </SessionTimeline>
+                    </div>
+                    <Show when={diffs().length}>
+                      <div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+                        <SessionReview diffs={diffs()} class="pb-20" />
+                      </div>
+                    </Show>
+                  </div>
+                </div>
+              </div>
+            )
+          })}
+        </DataProvider>
+      )}
+    </Show>
+  )
+}

+ 20 - 0
packages/enterprise/tsconfig.json

@@ -0,0 +1,20 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "skipLibCheck": true,
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "jsx": "preserve",
+    "jsxImportSource": "solid-js",
+    "allowJs": true,
+    "noEmit": true,
+    "strict": true,
+    "types": ["vinxi/types/client"],
+    "isolatedModules": true,
+    "paths": {
+      "~/*": ["./src/*"]
+    }
+  }
+}

+ 2 - 1
packages/opencode/package.json

@@ -55,6 +55,7 @@
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "@opentui/core": "0.1.47",
     "@opentui/solid": "0.1.47",
     "@parcel/watcher": "2.5.1",
@@ -70,7 +71,7 @@
     "fuzzysort": "3.1.0",
     "gray-matter": "4.0.3",
     "hono": "catalog:",
-    "hono-openapi": "1.1.1",
+    "hono-openapi": "catalog:",
     "ignore": "7.0.5",
     "jsonc-parser": "3.3.1",
     "minimatch": "10.0.3",

+ 1 - 1
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -17,7 +17,7 @@ import type {
 } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
-import { Binary } from "@/util/binary"
+import { Binary } from "@opencode-ai/util/binary"
 import { createSimpleContext } from "./helper"
 import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"

+ 5 - 0
packages/opencode/src/config/config.ts

@@ -609,6 +609,11 @@ export namespace Config {
         })
         .optional(),
       tools: z.record(z.string(), z.boolean()).optional(),
+      enterprise: z
+        .object({
+          url: z.string().optional().describe("Enterprise URL"),
+        })
+        .optional(),
       experimental: z
         .object({
           hook: z

+ 2 - 0
packages/opencode/src/project/bootstrap.ts

@@ -10,11 +10,13 @@ import { Bus } from "../bus"
 import { Command } from "../command"
 import { Instance } from "./instance"
 import { Log } from "@/util/log"
+import { ShareNext } from "@/share/share-next"
 
 export async function InstanceBootstrap() {
   Log.Default.info("bootstrapping", { directory: Instance.directory })
   await Plugin.init()
   Share.init()
+  ShareNext.init()
   Format.init()
   await LSP.init()
   FileWatcher.init()

+ 1 - 0
packages/opencode/src/server/server.ts

@@ -42,6 +42,7 @@ import { Snapshot } from "@/snapshot"
 import { SessionSummary } from "@/session/summary"
 import { GlobalBus } from "@/bus/global"
 import { SessionStatus } from "@/session/status"
+import { ShareNext } from "@/share/share-next"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false

+ 17 - 0
packages/opencode/src/session/index.ts

@@ -16,6 +16,7 @@ import { SessionPrompt } from "./prompt"
 import { fn } from "@/util/fn"
 import { Command } from "../command"
 import { Snapshot } from "@/snapshot"
+import { ShareNext } from "@/share/share-next"
 
 export namespace Session {
   const log = Log.create({ service: "session" })
@@ -221,6 +222,15 @@ export namespace Session {
       throw new Error("Sharing is disabled in configuration")
     }
 
+    if (cfg.enterprise?.url) {
+      const share = await ShareNext.create(id)
+      await update(id, (draft) => {
+        draft.share = {
+          url: share.url,
+        }
+      })
+    }
+
     const session = await get(id)
     if (session.share) return session.share
     const share = await Share.create(id)
@@ -241,6 +251,13 @@ export namespace Session {
   })
 
   export const unshare = fn(Identifier.schema("session"), async (id) => {
+    const cfg = await Config.get()
+    if (cfg.enterprise?.url) {
+      await ShareNext.remove(id)
+      await update(id, (draft) => {
+        draft.share = undefined
+      })
+    }
     const share = await getShare(id)
     if (!share) return
     await Storage.remove(["share", id])

+ 0 - 2
packages/opencode/src/session/processor.ts

@@ -319,8 +319,6 @@ export namespace SessionProcessor {
                   break
 
                 case "finish":
-                  input.assistantMessage.time.completed = Date.now()
-                  await Session.updateMessage(input.assistantMessage)
                   break
 
                 default:

+ 148 - 0
packages/opencode/src/share/share-next.ts

@@ -0,0 +1,148 @@
+import { Bus } from "@/bus"
+import { Config } from "@/config/config"
+import { Session } from "@/session"
+import { MessageV2 } from "@/session/message-v2"
+import { Storage } from "@/storage/storage"
+import { Log } from "@/util/log"
+import type * as SDK from "@opencode-ai/sdk"
+
+export namespace ShareNext {
+  const log = Log.create({ service: "share-next" })
+  export async function init() {
+    const config = await Config.get()
+    if (!config.enterprise) return
+    Bus.subscribe(Session.Event.Updated, async (evt) => {
+      await sync(evt.properties.info.id, [
+        {
+          type: "session",
+          data: evt.properties.info,
+        },
+      ])
+    })
+    Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
+      await sync(evt.properties.info.sessionID, [
+        {
+          type: "message",
+          data: evt.properties.info,
+        },
+      ])
+    })
+    Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
+      await sync(evt.properties.part.sessionID, [
+        {
+          type: "part",
+          data: evt.properties.part,
+        },
+      ])
+    })
+    Bus.subscribe(Session.Event.Diff, async (evt) => {
+      await sync(evt.properties.sessionID, [
+        {
+          type: "session_diff",
+          data: evt.properties.diff,
+        },
+      ])
+    })
+  }
+
+  export async function create(sessionID: string) {
+    log.info("creating share", { sessionID })
+    const url = await Config.get().then((x) => x.enterprise!.url)
+    const result = await fetch(`${url}/api/share`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({ sessionID: sessionID }),
+    })
+      .then((x) => x.json())
+      .then((x) => x as { url: string; secret: string })
+    await Storage.write(["session_share", sessionID], {
+      id: sessionID,
+      ...result,
+    })
+    fullSync(sessionID)
+    return result
+  }
+
+  function get(sessionID: string) {
+    return Storage.read<{
+      id: string
+      secret: string
+      url: string
+    }>(["session_share", sessionID])
+  }
+
+  type Data =
+    | {
+        type: "session"
+        data: SDK.Session
+      }
+    | {
+        type: "message"
+        data: SDK.Message
+      }
+    | {
+        type: "part"
+        data: SDK.Part
+      }
+    | {
+        type: "session_diff"
+        data: SDK.FileDiff[]
+      }
+
+  async function sync(sessionID: string, data: Data[]) {
+    const url = await Config.get().then((x) => x.enterprise!.url)
+    const share = await get(sessionID)
+    if (!share) return
+    await fetch(`${url}/api/share/${share.id}/sync`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        secret: share.secret,
+        data,
+      }),
+    })
+  }
+
+  export async function remove(sessionID: string) {
+    log.info("removing share", { sessionID })
+    const url = await Config.get().then((x) => x.enterprise!.url)
+    const share = await get(sessionID)
+    if (!share) return
+    await fetch(`${url}/api/share/${share.id}`, {
+      method: "DELETE",
+      headers: {
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        secret: share.secret,
+      }),
+    })
+    await Storage.remove(["session_share", share.id])
+  }
+
+  async function fullSync(sessionID: string) {
+    log.info("full sync", { sessionID })
+    const session = await Session.get(sessionID)
+    const diffs = await Session.diff(sessionID)
+    const messages = await Array.fromAsync(MessageV2.stream(sessionID))
+    await sync(sessionID, [
+      {
+        type: "session",
+        data: session,
+      },
+      ...messages.map((x) => ({
+        type: "message" as const,
+        data: x.info,
+      })),
+      ...messages.flatMap((x) => x.parts.map((y) => ({ type: "part" as const, data: y }))),
+      {
+        type: "session_diff",
+        data: diffs,
+      },
+    ])
+  }
+}

+ 0 - 41
packages/opencode/src/util/binary.ts

@@ -1,41 +0,0 @@
-export namespace Binary {
-  export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
-    let left = 0
-    let right = array.length - 1
-
-    while (left <= right) {
-      const mid = Math.floor((left + right) / 2)
-      const midId = compare(array[mid])
-
-      if (midId === id) {
-        return { found: true, index: mid }
-      } else if (midId < id) {
-        left = mid + 1
-      } else {
-        right = mid - 1
-      }
-    }
-
-    return { found: false, index: left }
-  }
-
-  export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
-    const id = compare(item)
-    let left = 0
-    let right = array.length
-
-    while (left < right) {
-      const mid = Math.floor((left + right) / 2)
-      const midId = compare(array[mid])
-
-      if (midId < id) {
-        left = mid + 1
-      } else {
-        right = mid
-      }
-    }
-
-    array.splice(left, 0, item)
-    return array
-  }
-}

+ 1 - 1
packages/plugin/package.json

@@ -24,4 +24,4 @@
     "typescript": "catalog:",
     "@typescript/native-preview": "catalog:"
   }
-}
+}

+ 1 - 1
packages/sdk/js/package.json

@@ -26,4 +26,4 @@
   "publishConfig": {
     "directory": "dist"
   }
-}
+}

+ 5 - 1
packages/ui/package.json

@@ -3,9 +3,10 @@
   "version": "1.0.90",
   "type": "module",
   "exports": {
-    ".": "./src/components/index.ts",
     "./*": "./src/components/*.tsx",
     "./hooks": "./src/hooks/index.ts",
+    "./context": "./src/context/index.ts",
+    "./context/*": "./src/context/*.tsx",
     "./styles": "./src/styles/index.css",
     "./styles/tailwind": "./src/styles/tailwind/index.css",
     "./fonts/*": "./src/assets/fonts/*"
@@ -13,6 +14,7 @@
   "scripts": {
     "typecheck": "tsgo --noEmit",
     "dev": "vite",
+    "build": "vite build",
     "generate:tailwind": "bun run script/tailwind.ts"
   },
   "devDependencies": {
@@ -20,6 +22,7 @@
     "@tsconfig/node22": "catalog:",
     "typescript": "catalog:",
     "vite": "catalog:",
+    "vite-plugin-icons-spritesheet": "3.0.1",
     "vite-plugin-solid": "catalog:",
     "tailwindcss": "catalog:",
     "@tailwindcss/vite": "catalog:"
@@ -27,6 +30,7 @@
   "dependencies": {
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "@pierre/precision-diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solidjs/meta": "catalog:",

+ 0 - 0
packages/desktop/src/assets/file-icons/3d.svg → packages/ui/src/assets/file-icons/3d.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/abap.svg → packages/ui/src/assets/file-icons/abap.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/abc.svg → packages/ui/src/assets/file-icons/abc.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/actionscript.svg → packages/ui/src/assets/file-icons/actionscript.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/ada.svg → packages/ui/src/assets/file-icons/ada.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/adobe-illustrator.svg → packages/ui/src/assets/file-icons/adobe-illustrator.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/adobe-illustrator_light.svg → packages/ui/src/assets/file-icons/adobe-illustrator_light.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/adobe-photoshop.svg → packages/ui/src/assets/file-icons/adobe-photoshop.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/adobe-photoshop_light.svg → packages/ui/src/assets/file-icons/adobe-photoshop_light.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/adobe-swc.svg → packages/ui/src/assets/file-icons/adobe-swc.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/adonis.svg → packages/ui/src/assets/file-icons/adonis.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/advpl.svg → packages/ui/src/assets/file-icons/advpl.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/amplify.svg → packages/ui/src/assets/file-icons/amplify.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/android.svg → packages/ui/src/assets/file-icons/android.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/angular.svg → packages/ui/src/assets/file-icons/angular.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/antlr.svg → packages/ui/src/assets/file-icons/antlr.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/apiblueprint.svg → packages/ui/src/assets/file-icons/apiblueprint.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/apollo.svg → packages/ui/src/assets/file-icons/apollo.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/applescript.svg → packages/ui/src/assets/file-icons/applescript.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/apps-script.svg → packages/ui/src/assets/file-icons/apps-script.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/appveyor.svg → packages/ui/src/assets/file-icons/appveyor.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/architecture.svg → packages/ui/src/assets/file-icons/architecture.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/arduino.svg → packages/ui/src/assets/file-icons/arduino.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/asciidoc.svg → packages/ui/src/assets/file-icons/asciidoc.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/assembly.svg → packages/ui/src/assets/file-icons/assembly.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/astro-config.svg → packages/ui/src/assets/file-icons/astro-config.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/astro.svg → packages/ui/src/assets/file-icons/astro.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/astyle.svg → packages/ui/src/assets/file-icons/astyle.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/audio.svg → packages/ui/src/assets/file-icons/audio.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/aurelia.svg → packages/ui/src/assets/file-icons/aurelia.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/authors.svg → packages/ui/src/assets/file-icons/authors.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/auto.svg → packages/ui/src/assets/file-icons/auto.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/auto_light.svg → packages/ui/src/assets/file-icons/auto_light.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/autohotkey.svg → packages/ui/src/assets/file-icons/autohotkey.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/autoit.svg → packages/ui/src/assets/file-icons/autoit.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/azure-pipelines.svg → packages/ui/src/assets/file-icons/azure-pipelines.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/azure.svg → packages/ui/src/assets/file-icons/azure.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/babel.svg → packages/ui/src/assets/file-icons/babel.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/ballerina.svg → packages/ui/src/assets/file-icons/ballerina.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/bazel.svg → packages/ui/src/assets/file-icons/bazel.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/bbx.svg → packages/ui/src/assets/file-icons/bbx.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/beancount.svg → packages/ui/src/assets/file-icons/beancount.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/bench-js.svg → packages/ui/src/assets/file-icons/bench-js.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/bench-jsx.svg → packages/ui/src/assets/file-icons/bench-jsx.svg


+ 0 - 0
packages/desktop/src/assets/file-icons/bench-ts.svg → packages/ui/src/assets/file-icons/bench-ts.svg


Некоторые файлы не были показаны из-за большого количества измененных файлов