summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-12 05:14:47 -0600
committerAdam <[email protected]>2025-12-12 05:14:51 -0600
commit04b4dacee3c0705725241ab3d92ac83ccb9a80a9 (patch)
treecf4a6ce9894e915fc174cb5c19d4c05ed5342bd8
parentc0e30f48c6cc497d849626026699f71503a6074d (diff)
downloadopencode-04b4dacee3c0705725241ab3d92ac83ccb9a80a9.tar.gz
opencode-04b4dacee3c0705725241ab3d92ac83ccb9a80a9.zip
feat(desktop): basic alerting
-rw-r--r--bun.lock3
-rw-r--r--packages/desktop/package.json1
-rw-r--r--packages/desktop/src/app.tsx41
-rw-r--r--packages/desktop/src/context/layout.tsx9
-rw-r--r--packages/desktop/src/context/notification.tsx130
-rw-r--r--packages/desktop/src/pages/layout.tsx152
-rw-r--r--packages/ui/package.json3
-rw-r--r--packages/ui/src/assets/audio/staplebops-01.aacbin0 -> 5573 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-02.aacbin0 -> 5945 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-03.aacbin0 -> 9660 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-04.aacbin0 -> 5202 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-05.aacbin0 -> 3716 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-06.aacbin0 -> 6316 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-07.aacbin0 -> 29351 bytes
14 files changed, 262 insertions, 77 deletions
diff --git a/bun.lock b/bun.lock
index eba116719..d9cdd91d9 100644
--- a/bun.lock
+++ b/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=="],
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 1d12a9cb9..a2b995a4a 100644
--- a/packages/desktop/package.json
+++ b/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",
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx
index a1ff90d26..bf9dfd3b7 100644
--- a/packages/desktop/src/app.tsx
+++ b/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>
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 9cafdce96..4ec0af601 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/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<{
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
new file mode 100644
index 000000000..4e334126f
--- /dev/null
+++ b/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)
+ },
+ },
+ }
+ },
+})
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index b997296fa..e7ae83162 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/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>
diff --git a/packages/ui/package.json b/packages/ui/package.json
index e7bcbbf79..7aede1dcd 100644
--- a/packages/ui/package.json
+++ b/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",
diff --git a/packages/ui/src/assets/audio/staplebops-01.aac b/packages/ui/src/assets/audio/staplebops-01.aac
new file mode 100644
index 000000000..01ae83db7
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-01.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-02.aac b/packages/ui/src/assets/audio/staplebops-02.aac
new file mode 100644
index 000000000..698137c26
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-02.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-03.aac b/packages/ui/src/assets/audio/staplebops-03.aac
new file mode 100644
index 000000000..5efa4451e
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-03.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-04.aac b/packages/ui/src/assets/audio/staplebops-04.aac
new file mode 100644
index 000000000..02d6bd5d7
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-04.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-05.aac b/packages/ui/src/assets/audio/staplebops-05.aac
new file mode 100644
index 000000000..7f0de4aa5
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-05.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-06.aac b/packages/ui/src/assets/audio/staplebops-06.aac
new file mode 100644
index 000000000..0c010dfb0
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-06.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-07.aac b/packages/ui/src/assets/audio/staplebops-07.aac
new file mode 100644
index 000000000..7d20ce755
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-07.aac
Binary files differ