Ver código fonte

Merge branch 'dev' of https://github.com/sst/opencode into dev

David Hill 2 meses atrás
pai
commit
9b77246246
36 arquivos alterados com 602 adições e 237 exclusões
  1. 1 0
      STATS.md
  2. 6 0
      bun.lock
  3. 1 1
      nix/hashes.json
  4. 8 1
      packages/console/app/src/routes/download/index.tsx
  5. 2 2
      packages/desktop/index.html
  6. 1 0
      packages/desktop/package.json
  7. 22 19
      packages/desktop/src/app.tsx
  8. 113 0
      packages/desktop/src/components/header.tsx
  9. 51 27
      packages/desktop/src/context/global-sync.tsx
  10. 4 20
      packages/desktop/src/context/layout.tsx
  11. 106 0
      packages/desktop/src/context/notification.tsx
  12. 9 0
      packages/desktop/src/context/sync.tsx
  13. 3 2
      packages/desktop/src/pages/home.tsx
  14. 157 150
      packages/desktop/src/pages/layout.tsx
  15. 1 1
      packages/desktop/src/pages/session.tsx
  16. 2 0
      packages/opencode/src/acp/agent.ts
  17. 1 1
      packages/opencode/src/cli/cmd/tui/util/clipboard.ts
  18. 1 1
      packages/opencode/src/provider/transform.ts
  19. 2 2
      packages/tauri/index.html
  20. 1 0
      packages/tauri/package.json
  21. 84 0
      packages/tauri/src-tauri/Cargo.lock
  22. 1 0
      packages/tauri/src-tauri/Cargo.toml
  23. 2 1
      packages/tauri/src-tauri/capabilities/default.json
  24. 5 1
      packages/tauri/src-tauri/src/lib.rs
  25. 4 0
      packages/tauri/src/index.tsx
  26. 2 1
      packages/ui/package.json
  27. BIN
      packages/ui/src/assets/audio/staplebops-01.aac
  28. BIN
      packages/ui/src/assets/audio/staplebops-02.aac
  29. BIN
      packages/ui/src/assets/audio/staplebops-03.aac
  30. BIN
      packages/ui/src/assets/audio/staplebops-04.aac
  31. BIN
      packages/ui/src/assets/audio/staplebops-05.aac
  32. BIN
      packages/ui/src/assets/audio/staplebops-06.aac
  33. BIN
      packages/ui/src/assets/audio/staplebops-07.aac
  34. 2 1
      packages/ui/src/components/icon.tsx
  35. 8 6
      packages/ui/src/components/toast.css
  36. 2 0
      packages/web/src/content/docs/zen.mdx

+ 1 - 0
STATS.md

@@ -167,3 +167,4 @@
 | 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773)   | 1,985,410 (+27,363) |
 | 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786)   | 2,017,599 (+32,189) |
 | 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
+| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |

+ 6 - 0
bun.lock

@@ -131,6 +131,7 @@
         "@opencode-ai/util": "workspace:*",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/active-element": "2.1.3",
+        "@solid-primitives/audio": "1.4.2",
         "@solid-primitives/event-bus": "1.1.2",
         "@solid-primitives/resize-observer": "2.1.3",
         "@solid-primitives/scroll": "2.1.3",
@@ -355,6 +356,7 @@
         "@tauri-apps/api": "^2",
         "@tauri-apps/plugin-dialog": "~2",
         "@tauri-apps/plugin-opener": "^2",
+        "@tauri-apps/plugin-os": "~2",
         "@tauri-apps/plugin-process": "~2",
         "@tauri-apps/plugin-shell": "~2",
         "@tauri-apps/plugin-store": "~2",
@@ -1548,6 +1550,8 @@
 
     "@solid-primitives/active-element": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
 
+    "@solid-primitives/audio": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
+
     "@solid-primitives/event-bus": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
 
     "@solid-primitives/event-listener": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
@@ -1660,6 +1664,8 @@
 
     "@tauri-apps/plugin-opener": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
 
+    "@tauri-apps/plugin-os": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
+
     "@tauri-apps/plugin-process": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
 
     "@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ=="],

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-b6AEbARiEcI/Pu1g0LbRfH1Oo5rClncW44Ug0d4oP0w="
+  "nodeModules": "sha256-3CG0wAMQp2E6ghPUXbYaYifJorp9b1WvCtHD+o8Nhck="
 }

+ 8 - 1
packages/console/app/src/routes/download/index.tsx

@@ -10,7 +10,14 @@ import { Legal } from "~/component/legal"
 import { config } from "~/config"
 
 const getLatestRelease = query(async () => {
-  const response = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+  "use server"
+  const response = await fetch("https://api.github.com/repos/sst/opencode/releases/latest", {
+    headers: {
+      "User-Agent":
+        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
+    },
+  })
+
   if (!response.ok) return null
   const data = await response.json()
   return data.tag_name as string

+ 2 - 2
packages/desktop/index.html

@@ -14,7 +14,7 @@
     <meta property="og:image" content="/social-share.png" />
     <meta property="twitter:image" content="/social-share.png" />
   </head>
-  <body class="antialiased overscroll-none select-none text-12-regular">
+  <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
     <script>
       ;(function () {
         const savedTheme = localStorage.getItem("theme") || "oc-1"
@@ -22,7 +22,7 @@
       })()
     </script>
     <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root"></div>
+    <div id="root" class="flex flex-col h-screen"></div>
     <script src="/src/entry.tsx" type="module"></script>
   </body>
 </html>

+ 1 - 0
packages/desktop/package.json

@@ -35,6 +35,7 @@
     "@opencode-ai/util": "workspace:*",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
+    "@solid-primitives/audio": "1.4.2",
     "@solid-primitives/event-bus": "1.1.2",
     "@solid-primitives/resize-observer": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",

+ 22 - 19
packages/desktop/src/app.tsx

@@ -14,6 +14,7 @@ import { LayoutProvider } from "./context/layout"
 import { GlobalSDKProvider } from "./context/global-sdk"
 import { SessionProvider } from "./context/session"
 import { Show } from "solid-js"
+import { NotificationProvider } from "./context/notification"
 
 declare global {
   interface Window {
@@ -37,25 +38,27 @@ export function App() {
         <GlobalSDKProvider url={url}>
           <GlobalSyncProvider>
             <LayoutProvider>
-              <MetaProvider>
-                <Font />
-                <Router root={Layout}>
-                  <Route path="/" component={Home} />
-                  <Route path="/:dir" component={DirectoryLayout}>
-                    <Route path="/" component={() => <Navigate href="session" />} />
-                    <Route
-                      path="/session/:id?"
-                      component={(p) => (
-                        <Show when={p.params.id || true} keyed>
-                          <SessionProvider>
-                            <Session />
-                          </SessionProvider>
-                        </Show>
-                      )}
-                    />
-                  </Route>
-                </Router>
-              </MetaProvider>
+              <NotificationProvider>
+                <MetaProvider>
+                  <Font />
+                  <Router root={Layout}>
+                    <Route path="/" component={Home} />
+                    <Route path="/:dir" component={DirectoryLayout}>
+                      <Route path="/" component={() => <Navigate href="session" />} />
+                      <Route
+                        path="/session/:id?"
+                        component={(p) => (
+                          <Show when={p.params.id || true} keyed>
+                            <SessionProvider>
+                              <Session />
+                            </SessionProvider>
+                          </Show>
+                        )}
+                      />
+                    </Route>
+                  </Router>
+                </MetaProvider>
+              </NotificationProvider>
             </LayoutProvider>
           </GlobalSyncProvider>
         </GlobalSDKProvider>

+ 113 - 0
packages/desktop/src/components/header.tsx

@@ -0,0 +1,113 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { useLayout } from "@/context/layout"
+import { Session } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Select } from "@opencode-ai/ui/select"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { getFilename } from "@opencode-ai/util/path"
+import { A, useParams } from "@solidjs/router"
+import { createMemo, Show } from "solid-js"
+
+export function Header(props: {
+  navigateToProject: (directory: string) => void
+  navigateToSession: (session: Session | undefined) => void
+}) {
+  const globalSync = useGlobalSync()
+  const layout = useLayout()
+  const params = useParams()
+  const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+  const store = createMemo(() => globalSync.child(currentDirectory())[0])
+  const sessions = createMemo(() => store().session ?? [])
+  const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+
+  return (
+    <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+      <A
+        href="/"
+        classList={{
+          "w-12 shrink-0 px-4 py-3.5": true,
+          "flex items-center justify-start self-stretch": true,
+          "border-r border-border-weak-base": true,
+        }}
+        style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+        data-tauri-drag-region
+      >
+        <Mark class="shrink-0" />
+      </A>
+      <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
+        <Show when={params.dir && layout.projects.list().length > 0}>
+          <div class="flex items-center gap-3">
+            <div class="flex items-center gap-2">
+              <Select
+                options={layout.projects.list().map((project) => project.worktree)}
+                current={currentDirectory()}
+                label={(x) => getFilename(x)}
+                onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+                class="text-14-regular text-text-base"
+                variant="ghost"
+              >
+                {/* @ts-ignore */}
+                {(i) => (
+                  <div class="flex items-center gap-2">
+                    <Icon name="folder" size="small" />
+                    <div class="text-text-strong">{getFilename(i)}</div>
+                  </div>
+                )}
+              </Select>
+              <div class="text-text-weaker">/</div>
+              <Select
+                options={sessions()}
+                current={currentSession()}
+                placeholder="New session"
+                label={(x) => x.title}
+                value={(x) => x.id}
+                onSelect={props.navigateToSession}
+                class="text-14-regular text-text-base max-w-md"
+                variant="ghost"
+              />
+            </div>
+            <Show when={currentSession()}>
+              <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
+                New session
+              </Button>
+            </Show>
+          </div>
+          <div class="flex items-center gap-4">
+            <Tooltip
+              class="shrink-0"
+              value={
+                <div class="flex items-center gap-2">
+                  <span>Toggle terminal</span>
+                  <span class="text-icon-base text-12-medium">Ctrl `</span>
+                </div>
+              }
+            >
+              <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+                <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                  <Icon
+                    size="small"
+                    name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+                    class="group-hover/terminal-toggle:hidden"
+                  />
+                  <Icon
+                    size="small"
+                    name="layout-bottom-partial"
+                    class="hidden group-hover/terminal-toggle:inline-block"
+                  />
+                  <Icon
+                    size="small"
+                    name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+                    class="hidden group-active/terminal-toggle:inline-block"
+                  />
+                </div>
+              </Button>
+            </Tooltip>
+          </div>
+        </Show>
+      </div>
+    </header>
+  )
+}

+ 51 - 27
packages/desktop/src/context/global-sync.tsx

@@ -55,45 +55,20 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
     const globalSDK = useGlobalSDK()
     const [globalStore, setGlobalStore] = createStore<{
       ready: boolean
+      path: Path
       project: Project[]
       provider: ProviderListResponse
       provider_auth: ProviderAuthResponse
       children: Record<string, State>
     }>({
       ready: false,
+      path: { state: "", config: "", worktree: "", directory: "", home: "" },
       project: [],
       provider: { all: [], connected: [], default: {} },
       provider_auth: {},
       children: {},
     })
 
-    async function bootstrapInstance(directory: string) {
-      const [store, setStore] = child(directory)
-      const sdk = createOpencodeClient({
-        baseUrl: globalSDK.url,
-        directory,
-      })
-      const load = {
-        project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
-        provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
-        path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
-        agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
-        session: () =>
-          sdk.session.list().then((x) => {
-            const sessions = (x.data ?? [])
-              .slice()
-              .sort((a, b) => a.id.localeCompare(b.id))
-              .slice(0, store.limit)
-            setStore("session", sessions)
-          }),
-        status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
-        config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
-        changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
-        node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
-      }
-      await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
-    }
-
     const children: Record<string, ReturnType<typeof createStore<State>>> = {}
     function child(directory: string) {
       if (!children[directory]) {
@@ -120,6 +95,38 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
       return children[directory]
     }
 
+    async function loadSessions(directory: string) {
+      globalSDK.client.session.list({ directory }).then((x) => {
+        const sessions = (x.data ?? [])
+          .slice()
+          .filter((s) => !s.time.archived)
+          .sort((a, b) => a.id.localeCompare(b.id))
+          .slice(0, 5)
+        const [, setStore] = child(directory)
+        setStore("session", sessions)
+      })
+    }
+
+    async function bootstrapInstance(directory: string) {
+      const [, setStore] = child(directory)
+      const sdk = createOpencodeClient({
+        baseUrl: globalSDK.url,
+        directory,
+      })
+      const load = {
+        project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
+        provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
+        path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
+        agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+        session: () => loadSessions(directory),
+        status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
+        config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
+        changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
+        node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
+      }
+      await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+    }
+
     globalSDK.event.listen((e) => {
       const directory = e.name
       const event = e.details
@@ -156,6 +163,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
         }
         case "session.updated": {
           const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+          if (event.properties.info.time.archived) {
+            if (result.found) {
+              setStore(
+                "session",
+                produce((draft) => {
+                  draft.splice(result.index, 1)
+                }),
+              )
+            }
+            break
+          }
           if (result.found) {
             setStore("session", result.index, reconcile(event.properties.info))
             break
@@ -224,6 +242,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
 
     async function bootstrap() {
       return Promise.all([
+        globalSDK.client.path.get().then((x) => {
+          setGlobalStore("path", x.data!)
+        }),
         globalSDK.client.project.list().then(async (x) => {
           setGlobalStore(
             "project",
@@ -252,6 +273,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
       },
       child,
       bootstrap,
+      project: {
+        loadSessions,
+      },
     }
   },
 })

+ 4 - 20
packages/desktop/src/context/layout.tsx

@@ -7,15 +7,10 @@ import { useGlobalSDK } from "./global-sdk"
 import { Project } from "@opencode-ai/sdk/v2"
 
 const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
-
 export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
 
-export function isAvatarColorKey(value: string): value is AvatarColorKey {
-  return AVATAR_COLOR_KEYS.includes(value as AvatarColorKey)
-}
-
 export function getAvatarColors(key?: string) {
-  if (key && isAvatarColorKey(key)) {
+  if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
     return {
       background: `var(--avatar-background-${key})`,
       foreground: `var(--avatar-text-${key})`,
@@ -50,7 +45,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
       }),
       {
-        name: "default-layout.v7",
+        name: "layout.v1",
       },
     )
     const [ephemeral, setEphemeral] = createStore<{
@@ -97,21 +92,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
     const enriched = createMemo(() => store.projects.flatMap(enrich))
     const list = createMemo(() => enriched().flatMap(colorize))
 
-    async function loadProjectSessions(directory: string) {
-      const [, setStore] = globalSync.child(directory)
-      globalSdk.client.session.list({ directory }).then((x) => {
-        const sessions = (x.data ?? [])
-          .slice()
-          .sort((a, b) => a.id.localeCompare(b.id))
-          .slice(0, 5)
-        setStore("session", sessions)
-      })
-    }
-
     onMount(() => {
       Promise.all(
         store.projects.map((project) => {
-          return loadProjectSessions(project.worktree)
+          return globalSync.project.loadSessions(project.worktree)
         }),
       )
     })
@@ -121,7 +105,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         list,
         open(directory: string) {
           if (store.projects.find((x) => x.worktree === directory)) return
-          loadProjectSessions(directory)
+          globalSync.project.loadSessions(directory)
           setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
         },
         close(directory: string) {

+ 106 - 0
packages/desktop/src/context/notification.tsx

@@ -0,0 +1,106 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { makePersisted } from "@solid-primitives/storage"
+import { useGlobalSDK } from "./global-sdk"
+import { EventSessionError } from "@opencode-ai/sdk/v2"
+import { makeAudioPlayer } from "@solid-primitives/audio"
+import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
+
+type NotificationBase = {
+  directory?: string
+  session?: string
+  metadata?: any
+  time: number
+  viewed: boolean
+}
+
+type TurnCompleteNotification = NotificationBase & {
+  type: "turn-complete"
+}
+
+type ErrorNotification = NotificationBase & {
+  type: "error"
+  error: EventSessionError["properties"]["error"]
+}
+
+export type Notification = TurnCompleteNotification | ErrorNotification
+
+export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
+  name: "Notification",
+  init: () => {
+    const idlePlayer = makeAudioPlayer(idleSound)
+    const globalSDK = useGlobalSDK()
+
+    const [store, setStore] = makePersisted(
+      createStore({
+        list: [] as Notification[],
+      }),
+      {
+        name: "notification.v1",
+      },
+    )
+
+    // onMount(() => {
+    //   const daysToKeep = 7
+    //   // setStore("list", (n) => n.filter((n) => !n.viewed && n.time + 1000 * 60 * 60 * 24 * daysToKeep < Date.now()))
+    // })
+
+    globalSDK.event.listen((e) => {
+      const directory = e.name
+      const event = e.details
+      const base = {
+        directory,
+        time: Date.now(),
+        viewed: false,
+      }
+      switch (event.type) {
+        case "session.idle": {
+          idlePlayer.play()
+          const session = event.properties.sessionID
+          setStore("list", store.list.length, {
+            ...base,
+            type: "turn-complete",
+            session,
+          })
+          break
+        }
+        case "session.error": {
+          const session = event.properties.sessionID ?? "global"
+          // errorPlayer.play()
+          setStore("list", store.list.length, {
+            ...base,
+            type: "error",
+            session,
+            error: "error" in event.properties ? event.properties.error : undefined,
+          })
+          break
+        }
+      }
+    })
+
+    return {
+      session: {
+        all(session: string) {
+          return store.list.filter((n) => n.session === session)
+        },
+        unseen(session: string) {
+          return store.list.filter((n) => n.session === session && !n.viewed)
+        },
+        markViewed(session: string) {
+          setStore("list", (n) => n.session === session, "viewed", true)
+        },
+      },
+      project: {
+        all(directory: string) {
+          return store.list.filter((n) => n.directory === directory)
+        },
+        unseen(directory: string) {
+          return store.list.filter((n) => n.directory === directory && !n.viewed)
+        },
+        markViewed(directory: string) {
+          setStore("list", (n) => n.directory === directory, "viewed", true)
+        },
+      },
+    }
+  },
+})

+ 9 - 0
packages/desktop/src/context/sync.tsx

@@ -65,6 +65,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           })
         },
         more: createMemo(() => store.session.length >= store.limit),
+        archive: async (sessionID: string) => {
+          await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
+          setStore(
+            produce((draft) => {
+              const match = Binary.search(draft.session, sessionID, (s) => s.id)
+              if (match.found) draft.session.splice(match.index, 1)
+            }),
+          )
+        },
       },
       absolute,
       get directory() {

+ 3 - 2
packages/desktop/src/pages/home.tsx

@@ -1,5 +1,5 @@
 import { useGlobalSync } from "@/context/global-sync"
-import { For, Match, Show, Switch } from "solid-js"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
 import { Button } from "@opencode-ai/ui/button"
 import { Logo } from "@opencode-ai/ui/logo"
 import { useLayout } from "@/context/layout"
@@ -14,6 +14,7 @@ export default function Home() {
   const layout = useLayout()
   const platform = usePlatform()
   const navigate = useNavigate()
+  const homedir = createMemo(() => sync.data.path.home)
 
   function openProject(directory: string) {
     layout.projects.open(directory)
@@ -61,7 +62,7 @@ export default function Home() {
                     class="text-14-mono text-left justify-between px-3"
                     onClick={() => openProject(project.worktree)}
                   >
-                    {project.worktree}
+                    {project.worktree.replace(homedir(), "~")}
                     <div class="text-14-regular text-text-weak">
                       {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
                     </div>

+ 157 - 150
packages/desktop/src/pages/layout.tsx

@@ -1,10 +1,21 @@
-import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Match,
+  onCleanup,
+  onMount,
+  ParentProps,
+  Show,
+  Switch,
+  type JSX,
+} from "solid-js"
 import { DateTime } from "luxon"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, getAvatarColors } from "@/context/layout"
 import { useGlobalSync } from "@/context/global-sync"
 import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
-import { Mark } from "@opencode-ai/ui/logo"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Button } from "@opencode-ai/ui/button"
@@ -15,7 +26,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { getFilename } from "@opencode-ai/util/path"
-import { Select } from "@opencode-ai/ui/select"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
 import { usePlatform } from "@/context/platform"
@@ -42,6 +52,9 @@ import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast, Toast } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { Spinner } from "@opencode-ai/ui/spinner"
+import { useNotification } from "@/context/notification"
+import { Binary } from "@opencode-ai/util/binary"
+import { Header } from "@/components/header"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -54,10 +67,8 @@ export default function Layout(props: ParentProps) {
   const globalSync = useGlobalSync()
   const layout = useLayout()
   const platform = usePlatform()
+  const notification = useNotification()
   const navigate = useNavigate()
-  const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
-  const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
-  const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
   const providers = useProviders()
 
   function navigateToProject(directory: string | undefined) {
@@ -77,9 +88,11 @@ export default function Layout(props: ParentProps) {
   }
 
   function closeProject(directory: string) {
+    const index = layout.projects.list().findIndex((x) => x.worktree === directory)
+    const next = layout.projects.list()[index + 1]
     layout.projects.close(directory)
-    // TODO: more intelligent navigation
-    navigate("/")
+    if (next) navigateToProject(next.worktree)
+    else navigate("/")
   }
 
   async function chooseProject() {
@@ -105,6 +118,7 @@ export default function Layout(props: ParentProps) {
     if (!params.dir || !params.id) return
     const directory = base64Decode(params.dir)
     setStore("lastSession", directory, params.id)
+    notification.session.markViewed(params.id)
   })
 
   createEffect(() => {
@@ -164,8 +178,51 @@ export default function Layout(props: ParentProps) {
     return <></>
   }
 
+  const ProjectAvatar = (props: {
+    project: Project
+    class?: string
+    expandable?: boolean
+    notify?: boolean
+  }): JSX.Element => {
+    const notification = useNotification()
+    const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
+    const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+    const name = createMemo(() => getFilename(props.project.worktree))
+    const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
+    return (
+      <div class="relative size-6 shrink-0">
+        <Avatar
+          fallback={name()}
+          src={props.project.icon?.url}
+          {...getAvatarColors(props.project.icon?.color)}
+          class={`size-full ${props.class ?? ""}`}
+          style={
+            notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
+          }
+        />
+        <Show when={props.expandable}>
+          <Icon
+            name="chevron-right"
+            size="large"
+            class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
+          />
+        </Show>
+        <Show when={notifications().length > 0 && props.notify}>
+          <div
+            classList={{
+              "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
+              "bg-icon-critical-base": hasError(),
+              "bg-text-interactive-base": !hasError(),
+            }}
+          />
+        </Show>
+      </div>
+    )
+  }
+
   const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
     const name = createMemo(() => getFilename(props.project.worktree))
+    const current = createMemo(() => base64Decode(params.dir ?? ""))
     return (
       <Switch>
         <Match when={layout.sidebar.opened()}>
@@ -176,14 +233,7 @@ export default function Layout(props: ParentProps) {
             class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
           >
             <div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
-              <div class="size-6 shrink-0">
-                <Avatar
-                  fallback={name()}
-                  src={props.project.icon?.url}
-                  {...getAvatarColors(props.project.icon?.color)}
-                  class="size-full"
-                />
-              </div>
+              <ProjectAvatar project={props.project} />
               <span class="truncate text-14-medium text-text-strong">{name()}</span>
             </div>
           </Button>
@@ -193,17 +243,10 @@ export default function Layout(props: ParentProps) {
             variant="ghost"
             size="large"
             class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
-            data-selected={props.project.worktree === currentDirectory()}
+            data-selected={props.project.worktree === current()}
             onClick={() => navigateToProject(props.project.worktree)}
           >
-            <div class="size-6 shrink-0">
-              <Avatar
-                fallback={name()}
-                src={props.project.icon?.url}
-                {...getAvatarColors(props.project.icon?.color)}
-                class="size-full"
-              />
-            </div>
+            <ProjectAvatar project={props.project} notify />
           </Button>
         </Match>
       </Switch>
@@ -211,35 +254,31 @@ export default function Layout(props: ParentProps) {
   }
 
   const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
+    const notification = useNotification()
     const sortable = createSortable(props.project.worktree)
-    const [projectStore] = globalSync.child(props.project.worktree)
     const slug = createMemo(() => base64Encode(props.project.worktree))
     const name = createMemo(() => getFilename(props.project.worktree))
+    const [store, setStore] = globalSync.child(props.project.worktree)
+    const sessions = createMemo(() => store.session ?? [])
+    const [expanded, setExpanded] = createSignal(true)
     return (
       // @ts-ignore
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
         <Switch>
           <Match when={layout.sidebar.opened()}>
-            <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0">
+            <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}>
               <Button
                 as={"div"}
                 variant="ghost"
                 class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none rounded-lg"
               >
                 <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
-                  <div class="size-6 shrink-0">
-                    <Avatar
-                      fallback={name()}
-                      src={props.project.icon?.url}
-                      {...getAvatarColors(props.project.icon?.color)}
-                      class="size-full group-hover/session:hidden"
-                    />
-                    <Icon
-                      name="chevron-right"
-                      size="large"
-                      class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
-                    />
-                  </div>
+                  <ProjectAvatar
+                    project={props.project}
+                    class="group-hover/session:hidden"
+                    expandable
+                    notify={!expanded()}
+                  />
                   <span class="truncate text-14-medium text-text-strong">{name()}</span>
                 </Collapsible.Trigger>
                 <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
@@ -260,50 +299,102 @@ export default function Layout(props: ParentProps) {
               </Button>
               <Collapsible.Content>
                 <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
-                  <For each={projectStore.session}>
+                  <For each={sessions()}>
                     {(session) => {
                       const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+                      const notifications = createMemo(() => notification.session.unseen(session.id))
+                      const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+                      async function archive(session: Session) {
+                        await globalSDK.client.session.update({
+                          directory: session.directory,
+                          sessionID: session.id,
+                          time: { archived: Date.now() },
+                        })
+                        setStore(
+                          produce((draft) => {
+                            const match = Binary.search(draft.session, session.id, (s) => s.id)
+                            if (match.found) draft.session.splice(match.index, 1)
+                          }),
+                        )
+                      }
                       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 pl-4 pr-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"
+                              class="relative w-full pl-4 pr-1 py-1 rounded-md
+                                     group-[.active]/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 class="shrink-0 group-hover/session:hidden mr-1">
+                                  <Switch>
+                                    <Match when={hasError()}>
+                                      <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
+                                    </Match>
+                                    <Match when={notifications().length > 0}>
+                                      <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
+                                    </Match>
+                                    <Match when={true}>
+                                      <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>
+                                    </Match>
+                                  </Switch>
+                                </div>
+                                <div class="hidden group-hover/session:flex group-active/session:flex text-text-base gap-1">
+                                  {/* <IconButton icon="dot-grid" variant="ghost" /> */}
+                                  <Tooltip placement="right" value="Archive session">
+                                    <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
+                                  </Tooltip>
+                                </div>
                               </div>
+                              <Show when={session.summary?.files}>
+                                <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>
+                              </Show>
                             </div>
                           </Tooltip>
                         </A>
                       )
                     }}
                   </For>
+                  <Show when={sessions().length === 0}>
+                    <A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default">
+                      <Tooltip placement="right" value="New session">
+                        <div
+                          class="relative w-full pl-4 pr-1 py-1 rounded-md
+                                 group-[.active]/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">
+                              New session
+                            </span>
+                          </div>
+                        </div>
+                      </Tooltip>
+                    </A>
+                  </Show>
                 </nav>
               </Collapsible.Content>
             </Collapsible>
@@ -332,93 +423,9 @@ export default function Layout(props: ParentProps) {
   }
 
   return (
-    <div class="relative h-screen flex flex-col">
-      <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
-        <A
-          href="/"
-          classList={{
-            "w-12 shrink-0 px-4 py-3.5": true,
-            "flex items-center justify-start self-stretch": true,
-            "border-r border-border-weak-base": true,
-          }}
-          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
-          data-tauri-drag-region
-        >
-          <Mark class="shrink-0" />
-        </A>
-        <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
-          <Show when={params.dir && layout.projects.list().length > 0}>
-            <div class="flex items-center gap-3">
-              <div class="flex items-center gap-2">
-                <Select
-                  options={layout.projects.list().map((project) => project.worktree)}
-                  current={currentDirectory()}
-                  label={(x) => getFilename(x)}
-                  onSelect={(x) => (x ? navigateToProject(x) : undefined)}
-                  class="text-14-regular text-text-base"
-                  variant="ghost"
-                >
-                  {/* @ts-ignore */}
-                  {(i) => (
-                    <div class="flex items-center gap-2">
-                      <Icon name="folder" size="small" />
-                      <div class="text-text-strong">{getFilename(i)}</div>
-                    </div>
-                  )}
-                </Select>
-                <div class="text-text-weaker">/</div>
-                <Select
-                  options={sessions()}
-                  current={currentSession()}
-                  placeholder="New session"
-                  label={(x) => x.title}
-                  value={(x) => x.id}
-                  onSelect={navigateToSession}
-                  class="text-14-regular text-text-base max-w-md"
-                  variant="ghost"
-                />
-              </div>
-              <Show when={currentSession()}>
-                <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
-                  New session
-                </Button>
-              </Show>
-            </div>
-            <div class="flex items-center gap-4">
-              <Tooltip
-                class="shrink-0"
-                value={
-                  <div class="flex items-center gap-2">
-                    <span>Toggle terminal</span>
-                    <span class="text-icon-base text-12-medium">Ctrl `</span>
-                  </div>
-                }
-              >
-                <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
-                  <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                    <Icon
-                      size="small"
-                      name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
-                      class="group-hover/terminal-toggle:hidden"
-                    />
-                    <Icon
-                      size="small"
-                      name="layout-bottom-partial"
-                      class="hidden group-hover/terminal-toggle:inline-block"
-                    />
-                    <Icon
-                      size="small"
-                      name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
-                      class="hidden group-active/terminal-toggle:inline-block"
-                    />
-                  </div>
-                </Button>
-              </Tooltip>
-            </div>
-          </Show>
-        </div>
-      </header>
-      <div class="h-[calc(100vh-3rem)] flex">
+    <div class="relative flex-1 min-h-0 flex flex-col">
+      <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
+      <div class="flex-1 min-h-0 flex">
         <div
           classList={{
             "relative @container w-12 pb-5 shrink-0 bg-background-base": true,

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

@@ -675,7 +675,7 @@ export default function Page() {
                   <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
                 </SortableProvider>
                 <div class="h-full flex items-center justify-center">
-                  <Tooltip value="Open file" class="flex items-center">
+                  <Tooltip value="New Terminal" class="flex items-center">
                     <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
                   </Tooltip>
                 </div>

+ 2 - 0
packages/opencode/src/acp/agent.ts

@@ -914,6 +914,8 @@ export namespace ACP {
             {
               sessionID,
               directory,
+              providerID: model.providerID,
+              modelID: model.modelID,
             },
             { throwOnError: true },
           )

+ 1 - 1
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -61,7 +61,7 @@ export namespace Clipboard {
   const getCopyMethod = lazy(() => {
     const os = platform()
 
-    if (os === "darwin" && Bun.which("oascript")) {
+    if (os === "darwin" && Bun.which("osascript")) {
       console.log("clipboard: using osascript")
       return async (text: string) => {
         const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')

+ 1 - 1
packages/opencode/src/provider/transform.ts

@@ -272,7 +272,7 @@ export namespace ProviderTransform {
     const options: Record<string, any> = {}
 
     if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
-      if (model.api.id.includes("5.1")) {
+      if (model.api.id.includes("5.")) {
         options["reasoningEffort"] = "low"
       } else {
         options["reasoningEffort"] = "minimal"

+ 2 - 2
packages/tauri/index.html

@@ -14,7 +14,7 @@
     <meta property="og:image" content="/social-share.png" />
     <meta property="twitter:image" content="/social-share.png" />
   </head>
-  <body class="antialiased overscroll-none select-none text-12-regular">
+  <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
     <script>
       ;(function () {
         const savedTheme = localStorage.getItem("theme") || "oc-1"
@@ -22,7 +22,7 @@
       })()
     </script>
     <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root"></div>
+    <div id="root" class="flex flex-col h-screen"></div>
     <script src="/src/index.tsx" type="module"></script>
   </body>
 </html>

+ 1 - 0
packages/tauri/package.json

@@ -16,6 +16,7 @@
     "@tauri-apps/api": "^2",
     "@tauri-apps/plugin-dialog": "~2",
     "@tauri-apps/plugin-opener": "^2",
+    "@tauri-apps/plugin-os": "~2",
     "@tauri-apps/plugin-process": "~2",
     "@tauri-apps/plugin-shell": "~2",
     "@tauri-apps/plugin-store": "~2",

+ 84 - 0
packages/tauri/src-tauri/Cargo.lock

@@ -1256,6 +1256,16 @@ dependencies = [
  "version_check",
 ]
 
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.1.16"
@@ -2309,6 +2319,16 @@ dependencies = [
  "objc2-foundation 0.3.2",
 ]
 
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2 0.6.3",
+ "objc2-foundation 0.3.2",
+]
+
 [[package]]
 name = "objc2-core-text"
 version = "0.3.2"
@@ -2440,6 +2460,7 @@ checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
 dependencies = [
  "bitflags 2.10.0",
  "objc2 0.6.3",
+ "objc2-core-foundation",
  "objc2-foundation 0.3.2",
 ]
 
@@ -2461,8 +2482,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
 dependencies = [
  "bitflags 2.10.0",
+ "block2 0.6.2",
  "objc2 0.6.3",
+ "objc2-cloud-kit",
+ "objc2-core-data",
  "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2 0.6.3",
  "objc2-foundation 0.3.2",
 ]
 
@@ -2511,6 +2551,7 @@ dependencies = [
  "tauri-build",
  "tauri-plugin-dialog",
  "tauri-plugin-opener",
+ "tauri-plugin-os",
  "tauri-plugin-process",
  "tauri-plugin-shell",
  "tauri-plugin-store",
@@ -2535,6 +2576,22 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "os_info"
+version = "3.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c39b5918402d564846d5aba164c09a66cc88d232179dfd3e3c619a25a268392"
+dependencies = [
+ "android_system_properties",
+ "log",
+ "nix",
+ "objc2 0.6.3",
+ "objc2-foundation 0.3.2",
+ "objc2-ui-kit",
+ "serde",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "os_pipe"
 version = "1.2.3"
@@ -3872,6 +3929,15 @@ dependencies = [
  "syn 2.0.110",
 ]
 
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "system-deps"
 version = "6.2.2"
@@ -4146,6 +4212,24 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "tauri-plugin-os"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
+dependencies = [
+ "gethostname",
+ "log",
+ "os_info",
+ "serde",
+ "serde_json",
+ "serialize-to-javascript",
+ "sys-locale",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.17",
+]
+
 [[package]]
 name = "tauri-plugin-process"
 version = "2.3.1"

+ 1 - 0
packages/tauri/src-tauri/Cargo.toml

@@ -31,3 +31,4 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 tokio = "1.48.0"
 listeners = "0.3"
+tauri-plugin-os = "2"

+ 2 - 1
packages/tauri/src-tauri/capabilities/default.json

@@ -13,6 +13,7 @@
     "dialog:default",
     "process:default",
     "store:default",
-    "window-state:default"
+    "window-state:default",
+    "os:default"
   ]
 }

+ 5 - 1
packages/tauri/src-tauri/src/lib.rs

@@ -4,7 +4,9 @@ use std::{
     sync::{Arc, Mutex},
     time::{Duration, Instant},
 };
-use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow};
+use tauri::{
+    AppHandle, LogicalSize, Manager, Monitor, RunEvent, TitleBarStyle, WebviewUrl, WebviewWindow,
+};
 use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
 use tauri_plugin_shell::process::{CommandChild, CommandEvent};
 use tauri_plugin_shell::ShellExt;
@@ -107,6 +109,7 @@ pub fn run() {
     let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
 
     let mut builder = tauri::Builder::default()
+        .plugin(tauri_plugin_os::init())
         .plugin(tauri_plugin_window_state::Builder::new().build())
         .plugin(tauri_plugin_store::Builder::new().build())
         .plugin(tauri_plugin_dialog::init())
@@ -180,6 +183,7 @@ pub fn run() {
                         .inner_size(size.width as f64, size.height as f64)
                         .decorations(true)
                         .zoom_hotkeys_enabled(true)
+                        .title_bar_style(TitleBarStyle::Overlay)
                         .initialization_script(format!(
                             r#"
                           window.__OPENCODE__ ??= {{}};

+ 4 - 0
packages/tauri/src/index.tsx

@@ -5,6 +5,7 @@ import { runUpdater } from "./updater"
 import { onMount } from "solid-js"
 import { open, save } from "@tauri-apps/plugin-dialog"
 import { open as shellOpen } from "@tauri-apps/plugin-shell"
+import { type as ostype } from "@tauri-apps/plugin-os"
 
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -54,6 +55,9 @@ render(() => {
 
   return (
     <PlatformProvider value={platform}>
+      {ostype() === "macos" && (
+        <div class="bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
+      )}
       <App />
     </PlatformProvider>
   )

+ 2 - 1
packages/ui/package.json

@@ -12,7 +12,8 @@
     "./styles/tailwind": "./src/styles/tailwind/index.css",
     "./icons/provider": "./src/components/provider-icons/types.ts",
     "./icons/file-type": "./src/components/file-icons/types.ts",
-    "./fonts/*": "./src/assets/fonts/*"
+    "./fonts/*": "./src/assets/fonts/*",
+    "./audio/*": "./src/assets/audio/*"
   },
   "scripts": {
     "typecheck": "tsgo --noEmit",

BIN
packages/ui/src/assets/audio/staplebops-01.aac


BIN
packages/ui/src/assets/audio/staplebops-02.aac


BIN
packages/ui/src/assets/audio/staplebops-03.aac


BIN
packages/ui/src/assets/audio/staplebops-04.aac


BIN
packages/ui/src/assets/audio/staplebops-05.aac


BIN
packages/ui/src/assets/audio/staplebops-06.aac


BIN
packages/ui/src/assets/audio/staplebops-07.aac


+ 2 - 1
packages/ui/src/components/icon.tsx

@@ -4,6 +4,7 @@ const icons = {
   "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"/>`,
   "arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
   "arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`,
+  archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
   "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"/>`,
   "bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
   "check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
@@ -23,7 +24,7 @@ const icons = {
   folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
   "magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
   "plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
-  "plus": `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
+  plus: `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
   "pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
   mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
   glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,

+ 8 - 6
packages/ui/src/components/toast.css

@@ -31,7 +31,7 @@
   border-radius: var(--radius-lg);
   border: 1px solid var(--border-weak-base);
   background: var(--surface-float-base);
-  color: var(--text-inverted-base);
+  color: var(--text-invert-base);
   box-shadow: var(--shadow-md);
 
   [data-slot="toast-inner"] {
@@ -80,7 +80,8 @@
     justify-content: center;
 
     [data-component="icon"] {
-      color: rgba(253, 252, 252, 0.94);
+      color: var(--text-invert-stronger);
+      /* color: var(--icon-invert-base); */
     }
   }
 
@@ -93,7 +94,7 @@
   }
 
   [data-slot="toast-title"] {
-    color: var(--text-inverted-strong);
+    color: var(--text-invert-strong);
 
     /* text-14-medium */
     font-family: var(--font-family-sans);
@@ -107,7 +108,8 @@
   }
 
   [data-slot="toast-description"] {
-    color: var(--text-inverted-base);
+    color: var(--text-invert-base);
+    text-wrap-style: pretty;
 
     /* text-14-regular */
     font-family: var(--font-family-sans);
@@ -132,7 +134,7 @@
     padding: 0;
     cursor: pointer;
 
-    color: var(--text-inverted-strong);
+    color: var(--text-invert-strong);
     font-family: var(--font-family-sans);
     font-size: var(--font-size-base);
     font-weight: var(--font-weight-medium);
@@ -144,7 +146,7 @@
     }
 
     &:last-child {
-      color: var(--text-inverted-weak);
+      color: var(--text-invert-weak);
     }
   }
 

+ 2 - 0
packages/web/src/content/docs/zen.mdx

@@ -64,6 +64,7 @@ You can also access our models through the following API endpoints.
 
 | Model             | Model ID          | Endpoint                                         | AI SDK Package              |
 | ----------------- | ----------------- | ------------------------------------------------ | --------------------------- |
+| GPT 5.2           | gpt-5.2           | `https://opencode.ai/zen/v1/responses`           | `@ai-sdk/openai`            |
 | GPT 5.1           | gpt-5.1           | `https://opencode.ai/zen/v1/responses`           | `@ai-sdk/openai`            |
 | GPT 5.1 Codex     | gpt-5.1-codex     | `https://opencode.ai/zen/v1/responses`           | `@ai-sdk/openai`            |
 | GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses`           | `@ai-sdk/openai`            |
@@ -122,6 +123,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
 | Claude Opus 4.1                   | $15.00 | $75.00 | $1.50       | $18.75       |
 | Gemini 3 Pro (≤ 200K tokens)      | $2.00  | $12.00 | $0.20       | -            |
 | Gemini 3 Pro (> 200K tokens)      | $4.00  | $18.00 | $0.40       | -            |
+| GPT 5.2                           | $1.75  | $14.00 | $0.175      | -            |
 | GPT 5.1                           | $1.07  | $8.50  | $0.107      | -            |
 | GPT 5.1 Codex                     | $1.07  | $8.50  | $0.107      | -            |
 | GPT 5.1 Codex Max                 | $1.25  | $10.00 | $0.125      | -            |