فهرست منبع

feat(desktop): basic alerting

Adam 2 ماه پیش
والد
کامیت
04b4dacee3

+ 3 - 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",
@@ -1548,6 +1549,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=="],

+ 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>

+ 2 - 7
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<{

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

@@ -0,0 +1,130 @@
+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 type AudioSettings = {
+  enabled: boolean
+  volume: number
+}
+
+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[],
+        audio: {
+          enabled: true,
+          volume: 1,
+        } as AudioSettings,
+      }),
+      {
+        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": {
+          if (store.audio.enabled) {
+            idlePlayer.setVolume(store.audio.volume)
+            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)
+        },
+      },
+      audio: {
+        get settings() {
+          return store.audio
+        },
+        setEnabled(enabled: boolean) {
+          setStore("audio", "enabled", enabled)
+        },
+        setVolume(volume: number) {
+          const clamped = Math.max(0, Math.min(1, volume))
+          setStore("audio", "volume", clamped)
+        },
+      },
+    }
+  },
+})

+ 102 - 50
packages/desktop/src/pages/layout.tsx

@@ -1,4 +1,16 @@
-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"
@@ -42,6 +54,7 @@ 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"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -54,6 +67,7 @@ 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 ?? [])
@@ -77,9 +91,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 +121,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,6 +181,48 @@ 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))
     return (
@@ -176,14 +235,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>
@@ -196,14 +248,7 @@ export default function Layout(props: ParentProps) {
             data-selected={props.project.worktree === currentDirectory()}
             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 +256,30 @@ 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 [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">
@@ -263,6 +303,8 @@ export default function Layout(props: ParentProps) {
                   <For each={projectStore.session}>
                     {(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"))
                       return (
                         <A
                           data-active={session.id === params.id}
@@ -271,28 +313,38 @@ export default function Layout(props: ParentProps) {
                         >
                           <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-2 py-1 rounded-md
+                                     group-data-[active=true]/session:bg-surface-raised-base-hover
+                                     group-hover/session:bg-surface-raised-base-hover
+                                     group-focus/session:bg-surface-raised-base-hover"
                             >
                               <div class="flex items-center self-stretch gap-6 justify-between">
                                 <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
                                   {session.title}
                                 </span>
-                                <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
-                                  {Math.abs(updated().diffNow().as("seconds")) < 60
-                                    ? "Now"
-                                    : updated()
-                                        .toRelative({
-                                          style: "short",
-                                          unit: ["days", "hours", "minutes"],
-                                        })
-                                        ?.replace(" ago", "")
-                                        ?.replace(/ days?/, "d")
-                                        ?.replace(" min.", "m")
-                                        ?.replace(" hr.", "h")}
-                                </span>
+                                <Switch>
+                                  <Match when={hasError()}>
+                                    <div class="size-1.5 shrink-0 mr-1 rounded-full bg-text-diff-delete-base" />
+                                  </Match>
+                                  <Match when={notifications().length > 0}>
+                                    <div class="size-1.5 shrink-0 mr-1 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 _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>

+ 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