diff options
| author | Adam <[email protected]> | 2025-12-12 05:14:47 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-12 05:14:51 -0600 |
| commit | 04b4dacee3c0705725241ab3d92ac83ccb9a80a9 (patch) | |
| tree | cf4a6ce9894e915fc174cb5c19d4c05ed5342bd8 | |
| parent | c0e30f48c6cc497d849626026699f71503a6074d (diff) | |
| download | opencode-04b4dacee3c0705725241ab3d92ac83ccb9a80a9.tar.gz opencode-04b4dacee3c0705725241ab3d92ac83ccb9a80a9.zip | |
feat(desktop): basic alerting
| -rw-r--r-- | bun.lock | 3 | ||||
| -rw-r--r-- | packages/desktop/package.json | 1 | ||||
| -rw-r--r-- | packages/desktop/src/app.tsx | 41 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 9 | ||||
| -rw-r--r-- | packages/desktop/src/context/notification.tsx | 130 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 152 | ||||
| -rw-r--r-- | packages/ui/package.json | 3 | ||||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-01.aac | bin | 0 -> 5573 bytes | |||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-02.aac | bin | 0 -> 5945 bytes | |||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-03.aac | bin | 0 -> 9660 bytes | |||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-04.aac | bin | 0 -> 5202 bytes | |||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-05.aac | bin | 0 -> 3716 bytes | |||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-06.aac | bin | 0 -> 6316 bytes | |||
| -rw-r--r-- | packages/ui/src/assets/audio/staplebops-07.aac | bin | 0 -> 29351 bytes |
14 files changed, 262 insertions, 77 deletions
@@ -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 Binary files differnew file mode 100644 index 000000000..01ae83db7 --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-01.aac diff --git a/packages/ui/src/assets/audio/staplebops-02.aac b/packages/ui/src/assets/audio/staplebops-02.aac Binary files differnew file mode 100644 index 000000000..698137c26 --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-02.aac diff --git a/packages/ui/src/assets/audio/staplebops-03.aac b/packages/ui/src/assets/audio/staplebops-03.aac Binary files differnew file mode 100644 index 000000000..5efa4451e --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-03.aac diff --git a/packages/ui/src/assets/audio/staplebops-04.aac b/packages/ui/src/assets/audio/staplebops-04.aac Binary files differnew file mode 100644 index 000000000..02d6bd5d7 --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-04.aac diff --git a/packages/ui/src/assets/audio/staplebops-05.aac b/packages/ui/src/assets/audio/staplebops-05.aac Binary files differnew file mode 100644 index 000000000..7f0de4aa5 --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-05.aac diff --git a/packages/ui/src/assets/audio/staplebops-06.aac b/packages/ui/src/assets/audio/staplebops-06.aac Binary files differnew file mode 100644 index 000000000..0c010dfb0 --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-06.aac diff --git a/packages/ui/src/assets/audio/staplebops-07.aac b/packages/ui/src/assets/audio/staplebops-07.aac Binary files differnew file mode 100644 index 000000000..7d20ce755 --- /dev/null +++ b/packages/ui/src/assets/audio/staplebops-07.aac |
