From 04b4dacee3c0705725241ab3d92ac83ccb9a80a9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 05:14:47 -0600 Subject: feat(desktop): basic alerting --- packages/desktop/src/app.tsx | 41 +++---- packages/desktop/src/context/layout.tsx | 9 +- packages/desktop/src/context/notification.tsx | 130 ++++++++++++++++++++++ packages/desktop/src/pages/layout.tsx | 152 +++++++++++++++++--------- 4 files changed, 256 insertions(+), 76 deletions(-) create mode 100644 packages/desktop/src/context/notification.tsx (limited to 'packages/desktop/src') 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() { - - - - - - } /> - ( - - - - - - )} - /> - - - + + + + + + + } /> + ( + + + + + + )} + /> + + + + 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 ( +
+ 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined + } + /> + + + 0 && props.notify}> +
+ +
+ ) + } + 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" >
-
- -
+ {name()}
@@ -196,14 +248,7 @@ export default function Layout(props: ParentProps) { data-selected={props.project.worktree === currentDirectory()} onClick={() => navigateToProject(props.project.worktree)} > -
- -
+ @@ -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
- +