feat(desktop): basic alerting
parent
c0e30f48c6
commit
04b4dacee3
3
bun.lock
3
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/active-element@2.1.3", "", { "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/audio@1.4.2", "", { "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/event-bus@1.1.2", "", { "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/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue