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/context/layout.tsx | 9 +- packages/desktop/src/context/notification.tsx | 130 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 packages/desktop/src/context/notification.tsx (limited to 'packages/desktop/src/context') 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) + }, + }, + } + }, +}) -- cgit v1.2.3 From a160eb76df0e8f28b89440163d4e43848e007424 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 06:14:22 -0600 Subject: fix(desktop): homedir aware path on home --- packages/desktop/src/context/global-sync.tsx | 5 +++++ packages/desktop/src/pages/home.tsx | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) (limited to 'packages/desktop/src/context') diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 2a24a845c..39e0fd34a 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -55,12 +55,14 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple const globalSDK = useGlobalSDK() const [globalStore, setGlobalStore] = createStore<{ ready: boolean + path: Path project: Project[] provider: ProviderListResponse provider_auth: ProviderAuthResponse children: Record }>({ ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, project: [], provider: { all: [], connected: [], default: {} }, provider_auth: {}, @@ -224,6 +226,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple async function bootstrap() { return Promise.all([ + globalSDK.client.path.get().then((x) => { + setGlobalStore("path", x.data!) + }), globalSDK.client.project.list().then(async (x) => { setGlobalStore( "project", diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx index 205ffd815..7cd2916e8 100644 --- a/packages/desktop/src/pages/home.tsx +++ b/packages/desktop/src/pages/home.tsx @@ -1,5 +1,5 @@ import { useGlobalSync } from "@/context/global-sync" -import { For, Match, Show, Switch } from "solid-js" +import { createMemo, For, Match, Show, Switch } from "solid-js" import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { useLayout } from "@/context/layout" @@ -14,6 +14,7 @@ export default function Home() { const layout = useLayout() const platform = usePlatform() const navigate = useNavigate() + const homedir = createMemo(() => sync.data.path.home) function openProject(directory: string) { layout.projects.open(directory) @@ -61,7 +62,7 @@ export default function Home() { class="text-14-mono text-left justify-between px-3" onClick={() => openProject(project.worktree)} > - {project.worktree} + {project.worktree.replace(homedir(), "~")}
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
-- cgit v1.2.3 From 3f5cd2c4a810168ed03094cbf1d3e51a4b62f261 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 06:21:18 -0600 Subject: fix(desktop): audio stuff --- packages/desktop/src/context/notification.tsx | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) (limited to 'packages/desktop/src/context') diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx index 4e334126f..744e4fdf3 100644 --- a/packages/desktop/src/context/notification.tsx +++ b/packages/desktop/src/context/notification.tsx @@ -25,11 +25,6 @@ type ErrorNotification = NotificationBase & { export type Notification = TurnCompleteNotification | ErrorNotification -export type AudioSettings = { - enabled: boolean - volume: number -} - export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", init: () => { @@ -39,10 +34,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const [store, setStore] = makePersisted( createStore({ list: [] as Notification[], - audio: { - enabled: true, - volume: 1, - } as AudioSettings, }), { name: "notification.v1", @@ -64,10 +55,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi } switch (event.type) { case "session.idle": { - if (store.audio.enabled) { - idlePlayer.setVolume(store.audio.volume) - idlePlayer.play() - } + idlePlayer.play() const session = event.properties.sessionID setStore("list", store.list.length, { ...base, @@ -113,18 +101,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi 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) - }, - }, } }, }) -- cgit v1.2.3 From feb8c4f3c60e1ada28bd24abd09d534375a6bc08 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 07:07:54 -0600 Subject: feat(desktop): archive sessions --- packages/desktop/src/context/global-sync.tsx | 31 +++++--- packages/desktop/src/context/layout.tsx | 15 +--- packages/desktop/src/context/sync.tsx | 9 +++ packages/desktop/src/pages/layout.tsx | 106 +++++++++++++++++++-------- packages/ui/src/components/icon.tsx | 1 + 5 files changed, 108 insertions(+), 54 deletions(-) (limited to 'packages/desktop/src/context') diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 39e0fd34a..4c461a4e9 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -69,8 +69,19 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple children: {}, }) + async function loadSessions(directory: string) { + globalSDK.client.session.list({ directory }).then((x) => { + const sessions = (x.data ?? []) + .slice() + .filter((s) => !s.time.archived) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, 5) + setGlobalStore("children", directory, "session", sessions) + }) + } + async function bootstrapInstance(directory: string) { - const [store, setStore] = child(directory) + const [, setStore] = child(directory) const sdk = createOpencodeClient({ baseUrl: globalSDK.url, directory, @@ -80,14 +91,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - session: () => - sdk.session.list().then((x) => { - const sessions = (x.data ?? []) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, store.limit) - setStore("session", sessions) - }), + session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), @@ -158,6 +162,12 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple } case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + if (event.properties.info.time.archived) { + if (result.found) { + setStore("session", (s) => s.filter((x) => x.id !== event.properties.info.id)) + } + break + } if (result.found) { setStore("session", result.index, reconcile(event.properties.info)) break @@ -257,6 +267,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple }, child, bootstrap, + project: { + loadSessions, + }, } }, }) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 4ec0af601..3d5cad761 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -92,21 +92,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const enriched = createMemo(() => store.projects.flatMap(enrich)) const list = createMemo(() => enriched().flatMap(colorize)) - async function loadProjectSessions(directory: string) { - const [, setStore] = globalSync.child(directory) - globalSdk.client.session.list({ directory }).then((x) => { - const sessions = (x.data ?? []) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - .slice(0, 5) - setStore("session", sessions) - }) - } - onMount(() => { Promise.all( store.projects.map((project) => { - return loadProjectSessions(project.worktree) + return globalSync.project.loadSessions(project.worktree) }), ) }) @@ -116,7 +105,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( list, open(directory: string) { if (store.projects.find((x) => x.worktree === directory)) return - loadProjectSessions(directory) + globalSync.project.loadSessions(directory) setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x]) }, close(directory: string) { diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 85758c5b6..2ab54b3ae 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -65,6 +65,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }, more: createMemo(() => store.session.length >= store.limit), + archive: async (sessionID: string) => { + await sdk.client.session.update({ sessionID, time: { archived: Date.now() } }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + }, }, absolute, get directory() { diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index e7ae83162..c34904e11 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -55,6 +55,7 @@ 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" +import { Binary } from "@opencode-ai/util/binary" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -258,7 +259,8 @@ 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 [projectStore, setProjectStore] = globalSync.child(props.project.worktree) + const sessions = createMemo(() => projectStore.session.filter((s) => !s.time.archived)) const slug = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => getFilename(props.project.worktree)) const [expanded, setExpanded] = createSignal(true) @@ -300,21 +302,33 @@ export default function Layout(props: ParentProps) { diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index ad3988030..0dbd7a650 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -4,6 +4,7 @@ const icons = { "align-right": ``, "arrow-up": ``, "arrow-left": ``, + archive: ``, "bubble-5": ``, "bullet-list": ``, "check-small": ``, -- cgit v1.2.3 From 9629f7464b3a1a6077e7c6b04923c4c13993ac08 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:45:02 -0600 Subject: chore: cleanup --- packages/desktop/src/components/header.tsx | 113 +++++++++++++++++++++++++++ packages/desktop/src/context/global-sync.tsx | 62 ++++++++------- packages/desktop/src/pages/layout.tsx | 101 ++---------------------- 3 files changed, 154 insertions(+), 122 deletions(-) create mode 100644 packages/desktop/src/components/header.tsx (limited to 'packages/desktop/src/context') diff --git a/packages/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx new file mode 100644 index 000000000..cc4d01816 --- /dev/null +++ b/packages/desktop/src/components/header.tsx @@ -0,0 +1,113 @@ +import { useGlobalSync } from "@/context/global-sync" +import { useLayout } from "@/context/layout" +import { Session } from "@opencode-ai/sdk/v2/client" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Mark } from "@opencode-ai/ui/logo" +import { Select } from "@opencode-ai/ui/select" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { base64Decode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { A, useParams } from "@solidjs/router" +import { createMemo, Show } from "solid-js" + +export function Header(props: { + navigateToProject: (directory: string) => void + navigateToSession: (session: Session | undefined) => void +}) { + const globalSync = useGlobalSync() + const layout = useLayout() + const params = useParams() + const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const store = createMemo(() => globalSync.child(currentDirectory())[0]) + const sessions = createMemo(() => store().session ?? []) + const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + + return ( +
+ + + +
+ 0}> +
+
+ +
/
+ project.worktree)} - current={currentDirectory()} - label={(x) => getFilename(x)} - onSelect={(x) => (x ? navigateToProject(x) : undefined)} - class="text-14-regular text-text-base" - variant="ghost" - > - {/* @ts-ignore */} - {(i) => ( -
- -
{getFilename(i)}
-
- )} - -
/
-