feat(desktop): basic alerting
parent
c0e30f48c6
commit
04b4dacee3
3
bun.lock
3
bun.lock
|
|
@ -131,6 +131,7 @@
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "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/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-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=="],
|
"@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:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "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 { GlobalSDKProvider } from "./context/global-sdk"
|
||||||
import { SessionProvider } from "./context/session"
|
import { SessionProvider } from "./context/session"
|
||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
|
import { NotificationProvider } from "./context/notification"
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -37,25 +38,27 @@ export function App() {
|
||||||
<GlobalSDKProvider url={url}>
|
<GlobalSDKProvider url={url}>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<MetaProvider>
|
<NotificationProvider>
|
||||||
<Font />
|
<MetaProvider>
|
||||||
<Router root={Layout}>
|
<Font />
|
||||||
<Route path="/" component={Home} />
|
<Router root={Layout}>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/" component={Home} />
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
path="/session/:id?"
|
<Route
|
||||||
component={(p) => (
|
path="/session/:id?"
|
||||||
<Show when={p.params.id || true} keyed>
|
component={(p) => (
|
||||||
<SessionProvider>
|
<Show when={p.params.id || true} keyed>
|
||||||
<Session />
|
<SessionProvider>
|
||||||
</SessionProvider>
|
<Session />
|
||||||
</Show>
|
</SessionProvider>
|
||||||
)}
|
</Show>
|
||||||
/>
|
)}
|
||||||
</Route>
|
/>
|
||||||
</Router>
|
</Route>
|
||||||
</MetaProvider>
|
</Router>
|
||||||
|
</MetaProvider>
|
||||||
|
</NotificationProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</GlobalSDKProvider>
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,10 @@ import { useGlobalSDK } from "./global-sdk"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
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) {
|
export function getAvatarColors(key?: string) {
|
||||||
if (key && isAvatarColorKey(key)) {
|
if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
|
||||||
return {
|
return {
|
||||||
background: `var(--avatar-background-${key})`,
|
background: `var(--avatar-background-${key})`,
|
||||||
foreground: `var(--avatar-text-${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<{
|
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 { DateTime } from "luxon"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, getAvatarColors } from "@/context/layout"
|
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 { showToast, Toast } from "@opencode-ai/ui/toast"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
|
import { useNotification } from "@/context/notification"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
|
|
@ -54,6 +67,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const notification = useNotification()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
||||||
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
|
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
|
||||||
|
|
@ -77,9 +91,11 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeProject(directory: string) {
|
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)
|
layout.projects.close(directory)
|
||||||
// TODO: more intelligent navigation
|
if (next) navigateToProject(next.worktree)
|
||||||
navigate("/")
|
else navigate("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseProject() {
|
async function chooseProject() {
|
||||||
|
|
@ -105,6 +121,7 @@ export default function Layout(props: ParentProps) {
|
||||||
if (!params.dir || !params.id) return
|
if (!params.dir || !params.id) return
|
||||||
const directory = base64Decode(params.dir)
|
const directory = base64Decode(params.dir)
|
||||||
setStore("lastSession", directory, params.id)
|
setStore("lastSession", directory, params.id)
|
||||||
|
notification.session.markViewed(params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -164,6 +181,48 @@ export default function Layout(props: ParentProps) {
|
||||||
return <></>
|
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 ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
|
||||||
const name = createMemo(() => getFilename(props.project.worktree))
|
const name = createMemo(() => getFilename(props.project.worktree))
|
||||||
return (
|
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"
|
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="flex items-center gap-3 p-0 text-left min-w-0 grow">
|
||||||
<div class="size-6 shrink-0">
|
<ProjectAvatar project={props.project} />
|
||||||
<Avatar
|
|
||||||
fallback={name()}
|
|
||||||
src={props.project.icon?.url}
|
|
||||||
{...getAvatarColors(props.project.icon?.color)}
|
|
||||||
class="size-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -196,14 +248,7 @@ export default function Layout(props: ParentProps) {
|
||||||
data-selected={props.project.worktree === currentDirectory()}
|
data-selected={props.project.worktree === currentDirectory()}
|
||||||
onClick={() => navigateToProject(props.project.worktree)}
|
onClick={() => navigateToProject(props.project.worktree)}
|
||||||
>
|
>
|
||||||
<div class="size-6 shrink-0">
|
<ProjectAvatar project={props.project} notify />
|
||||||
<Avatar
|
|
||||||
fallback={name()}
|
|
||||||
src={props.project.icon?.url}
|
|
||||||
{...getAvatarColors(props.project.icon?.color)}
|
|
||||||
class="size-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
@ -211,35 +256,30 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
|
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
|
||||||
|
const notification = useNotification()
|
||||||
const sortable = createSortable(props.project.worktree)
|
const sortable = createSortable(props.project.worktree)
|
||||||
const [projectStore] = globalSync.child(props.project.worktree)
|
const [projectStore] = globalSync.child(props.project.worktree)
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
const name = createMemo(() => getFilename(props.project.worktree))
|
const name = createMemo(() => getFilename(props.project.worktree))
|
||||||
|
const [expanded, setExpanded] = createSignal(true)
|
||||||
return (
|
return (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={layout.sidebar.opened()}>
|
<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
|
<Button
|
||||||
as={"div"}
|
as={"div"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none rounded-lg"
|
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">
|
<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">
|
<ProjectAvatar
|
||||||
<Avatar
|
project={props.project}
|
||||||
fallback={name()}
|
class="group-hover/session:hidden"
|
||||||
src={props.project.icon?.url}
|
expandable
|
||||||
{...getAvatarColors(props.project.icon?.color)}
|
notify={!expanded()}
|
||||||
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>
|
|
||||||
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
<div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
|
<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}>
|
<For each={projectStore.session}>
|
||||||
{(session) => {
|
{(session) => {
|
||||||
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
|
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 (
|
return (
|
||||||
<A
|
<A
|
||||||
data-active={session.id === params.id}
|
data-active={session.id === params.id}
|
||||||
|
|
@ -271,28 +313,38 @@ export default function Layout(props: ParentProps) {
|
||||||
>
|
>
|
||||||
<Tooltip placement="right" value={session.title}>
|
<Tooltip placement="right" value={session.title}>
|
||||||
<div
|
<div
|
||||||
class="w-full pl-4 pr-2 py-1 rounded-md
|
class="relative w-full pl-4 pr-2 py-1 rounded-md
|
||||||
group-data-[active=true]/session:bg-surface-raised-base-hover
|
group-data-[active=true]/session:bg-surface-raised-base-hover
|
||||||
group-hover/session:bg-surface-raised-base-hover
|
group-hover/session:bg-surface-raised-base-hover
|
||||||
group-focus/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">
|
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||||
{session.title}
|
{session.title}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
<Switch>
|
||||||
{Math.abs(updated().diffNow().as("seconds")) < 60
|
<Match when={hasError()}>
|
||||||
? "Now"
|
<div class="size-1.5 shrink-0 mr-1 rounded-full bg-text-diff-delete-base" />
|
||||||
: updated()
|
</Match>
|
||||||
.toRelative({
|
<Match when={notifications().length > 0}>
|
||||||
style: "short",
|
<div class="size-1.5 shrink-0 mr-1 rounded-full bg-text-interactive-base" />
|
||||||
unit: ["days", "hours", "minutes"],
|
</Match>
|
||||||
})
|
<Match when={true}>
|
||||||
?.replace(" ago", "")
|
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||||
?.replace(/ days?/, "d")
|
{Math.abs(updated().diffNow().as("seconds")) < 60
|
||||||
?.replace(" min.", "m")
|
? "Now"
|
||||||
?.replace(" hr.", "h")}
|
: updated()
|
||||||
</span>
|
.toRelative({
|
||||||
|
style: "short",
|
||||||
|
unit: ["days", "hours", "minutes"],
|
||||||
|
})
|
||||||
|
?.replace(" ago", "")
|
||||||
|
?.replace(/ days?/, "d")
|
||||||
|
?.replace(" min.", "m")
|
||||||
|
?.replace(" hr.", "h")}
|
||||||
|
</span>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden _flex justify-between items-center self-stretch">
|
<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>
|
<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",
|
"./styles/tailwind": "./src/styles/tailwind/index.css",
|
||||||
"./icons/provider": "./src/components/provider-icons/types.ts",
|
"./icons/provider": "./src/components/provider-icons/types.ts",
|
||||||
"./icons/file-type": "./src/components/file-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": {
|
"scripts": {
|
||||||
"typecheck": "tsgo --noEmit",
|
"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