summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-12 20:23:06 +0000
committerDavid Hill <[email protected]>2025-12-12 20:23:06 +0000
commit9b77246246ab0d81aaee23514c343ccb93c36847 (patch)
tree09749dd9d91275b7fec4035fafbefc9e4cd0cb6c /packages/desktop
parentcf3bc1e0a6324a01ae10a2849476d7dc07e4876b (diff)
parentd51c6ca39f382ef616157680ca7366b6ee58713e (diff)
downloadopencode-9b77246246ab0d81aaee23514c343ccb93c36847.tar.gz
opencode-9b77246246ab0d81aaee23514c343ccb93c36847.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop')
-rw-r--r--packages/desktop/index.html4
-rw-r--r--packages/desktop/package.json1
-rw-r--r--packages/desktop/src/app.tsx41
-rw-r--r--packages/desktop/src/components/header.tsx113
-rw-r--r--packages/desktop/src/context/global-sync.tsx78
-rw-r--r--packages/desktop/src/context/layout.tsx24
-rw-r--r--packages/desktop/src/context/notification.tsx106
-rw-r--r--packages/desktop/src/context/sync.tsx9
-rw-r--r--packages/desktop/src/pages/home.tsx5
-rw-r--r--packages/desktop/src/pages/layout.tsx307
-rw-r--r--packages/desktop/src/pages/session.tsx2
11 files changed, 469 insertions, 221 deletions
diff --git a/packages/desktop/index.html b/packages/desktop/index.html
index b9d3e5351..9803517a0 100644
--- a/packages/desktop/index.html
+++ b/packages/desktop/index.html
@@ -14,7 +14,7 @@
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
- <body class="antialiased overscroll-none select-none text-12-regular">
+ <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
@@ -22,7 +22,7 @@
})()
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
- <div id="root"></div>
+ <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
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/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 (
+ <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+ <A
+ href="/"
+ classList={{
+ "w-12 shrink-0 px-4 py-3.5": true,
+ "flex items-center justify-start self-stretch": true,
+ "border-r border-border-weak-base": true,
+ }}
+ style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+ data-tauri-drag-region
+ >
+ <Mark class="shrink-0" />
+ </A>
+ <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
+ <Show when={params.dir && layout.projects.list().length > 0}>
+ <div class="flex items-center gap-3">
+ <div class="flex items-center gap-2">
+ <Select
+ options={layout.projects.list().map((project) => project.worktree)}
+ current={currentDirectory()}
+ label={(x) => getFilename(x)}
+ onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+ class="text-14-regular text-text-base"
+ variant="ghost"
+ >
+ {/* @ts-ignore */}
+ {(i) => (
+ <div class="flex items-center gap-2">
+ <Icon name="folder" size="small" />
+ <div class="text-text-strong">{getFilename(i)}</div>
+ </div>
+ )}
+ </Select>
+ <div class="text-text-weaker">/</div>
+ <Select
+ options={sessions()}
+ current={currentSession()}
+ placeholder="New session"
+ label={(x) => x.title}
+ value={(x) => x.id}
+ onSelect={props.navigateToSession}
+ class="text-14-regular text-text-base max-w-md"
+ variant="ghost"
+ />
+ </div>
+ <Show when={currentSession()}>
+ <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
+ New session
+ </Button>
+ </Show>
+ </div>
+ <div class="flex items-center gap-4">
+ <Tooltip
+ class="shrink-0"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Toggle terminal</span>
+ <span class="text-icon-base text-12-medium">Ctrl `</span>
+ </div>
+ }
+ >
+ <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+ class="group-hover/terminal-toggle:hidden"
+ />
+ <Icon
+ size="small"
+ name="layout-bottom-partial"
+ class="hidden group-hover/terminal-toggle:inline-block"
+ />
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+ class="hidden group-active/terminal-toggle:inline-block"
+ />
+ </div>
+ </Button>
+ </Tooltip>
+ </div>
+ </Show>
+ </div>
+ </header>
+ )
+}
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index 2a24a845c..8151a2c6f 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -55,45 +55,20 @@ 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<string, State>
}>({
ready: false,
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
children: {},
})
- async function bootstrapInstance(directory: string) {
- const [store, setStore] = child(directory)
- const sdk = createOpencodeClient({
- baseUrl: globalSDK.url,
- directory,
- })
- const load = {
- project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
- 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)
- }),
- 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!)),
- node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
- }
- await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
- }
-
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
@@ -120,6 +95,38 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
return children[directory]
}
+ 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)
+ const [, setStore] = child(directory)
+ setStore("session", sessions)
+ })
+ }
+
+ async function bootstrapInstance(directory: string) {
+ const [, setStore] = child(directory)
+ const sdk = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ directory,
+ })
+ const load = {
+ project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
+ 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: () => 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!)),
+ node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
+ }
+ await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+ }
+
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -156,6 +163,17 @@ 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",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ break
+ }
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
@@ -224,6 +242,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",
@@ -252,6 +273,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 9cafdce96..3d5cad761 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<{
@@ -97,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)
}),
)
})
@@ -121,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/notification.tsx b/packages/desktop/src/context/notification.tsx
new file mode 100644
index 000000000..744e4fdf3
--- /dev/null
+++ b/packages/desktop/src/context/notification.tsx
@@ -0,0 +1,106 @@
+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 const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
+ name: "Notification",
+ init: () => {
+ const idlePlayer = makeAudioPlayer(idleSound)
+ const globalSDK = useGlobalSDK()
+
+ const [store, setStore] = makePersisted(
+ createStore({
+ list: [] as Notification[],
+ }),
+ {
+ 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": {
+ 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)
+ },
+ },
+ }
+ },
+})
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/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(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index b997296fa..7da920c5f 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,10 +1,21 @@
-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"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
-import { Mark } from "@opencode-ai/ui/logo"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
@@ -15,7 +26,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
-import { Select } from "@opencode-ai/ui/select"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
@@ -42,6 +52,9 @@ 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"
+import { Binary } from "@opencode-ai/util/binary"
+import { Header } from "@/components/header"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -54,10 +67,8 @@ 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 ?? [])
- const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const providers = useProviders()
function navigateToProject(directory: string | undefined) {
@@ -77,9 +88,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 +118,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,8 +178,51 @@ 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))
+ const current = createMemo(() => base64Decode(params.dir ?? ""))
return (
<Switch>
<Match when={layout.sidebar.opened()}>
@@ -176,14 +233,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>
@@ -193,17 +243,10 @@ export default function Layout(props: ParentProps) {
variant="ghost"
size="large"
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
- data-selected={props.project.worktree === currentDirectory()}
+ data-selected={props.project.worktree === current()}
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 +254,31 @@ 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 [store, setStore] = globalSync.child(props.project.worktree)
+ const sessions = createMemo(() => store.session ?? [])
+ 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">
@@ -260,50 +299,102 @@ export default function Layout(props: ParentProps) {
</Button>
<Collapsible.Content>
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
- <For each={projectStore.session}>
+ <For each={sessions()}>
{(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"))
+ async function archive(session: Session) {
+ await globalSDK.client.session.update({
+ directory: session.directory,
+ sessionID: session.id,
+ time: { archived: Date.now() },
+ })
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, session.id, (s) => s.id)
+ if (match.found) draft.session.splice(match.index, 1)
+ }),
+ )
+ }
return (
<A
- data-active={session.id === params.id}
href={`${slug()}/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
>
<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-1 py-1 rounded-md
+ group-[.active]/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>
- </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>
- <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+ <div class="shrink-0 group-hover/session:hidden mr-1">
+ <Switch>
+ <Match when={hasError()}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={notifications().length > 0}>
+ <div class="size-1.5 mr-1.5 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 group-hover/session:flex group-active/session:flex text-text-base gap-1">
+ {/* <IconButton icon="dot-grid" variant="ghost" /> */}
+ <Tooltip placement="right" value="Archive session">
+ <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
+ </Tooltip>
+ </div>
</div>
+ <Show when={session.summary?.files}>
+ <div class="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>
+ <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+ </div>
+ </Show>
</div>
</Tooltip>
</A>
)
}}
</For>
+ <Show when={sessions().length === 0}>
+ <A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default">
+ <Tooltip placement="right" value="New session">
+ <div
+ class="relative w-full pl-4 pr-1 py-1 rounded-md
+ group-[.active]/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">
+ New session
+ </span>
+ </div>
+ </div>
+ </Tooltip>
+ </A>
+ </Show>
</nav>
</Collapsible.Content>
</Collapsible>
@@ -332,93 +423,9 @@ export default function Layout(props: ParentProps) {
}
return (
- <div class="relative h-screen flex flex-col">
- <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
- <A
- href="/"
- classList={{
- "w-12 shrink-0 px-4 py-3.5": true,
- "flex items-center justify-start self-stretch": true,
- "border-r border-border-weak-base": true,
- }}
- style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
- data-tauri-drag-region
- >
- <Mark class="shrink-0" />
- </A>
- <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
- <Show when={params.dir && layout.projects.list().length > 0}>
- <div class="flex items-center gap-3">
- <div class="flex items-center gap-2">
- <Select
- options={layout.projects.list().map((project) => 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) => (
- <div class="flex items-center gap-2">
- <Icon name="folder" size="small" />
- <div class="text-text-strong">{getFilename(i)}</div>
- </div>
- )}
- </Select>
- <div class="text-text-weaker">/</div>
- <Select
- options={sessions()}
- current={currentSession()}
- placeholder="New session"
- label={(x) => x.title}
- value={(x) => x.id}
- onSelect={navigateToSession}
- class="text-14-regular text-text-base max-w-md"
- variant="ghost"
- />
- </div>
- <Show when={currentSession()}>
- <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
- New session
- </Button>
- </Show>
- </div>
- <div class="flex items-center gap-4">
- <Tooltip
- class="shrink-0"
- value={
- <div class="flex items-center gap-2">
- <span>Toggle terminal</span>
- <span class="text-icon-base text-12-medium">Ctrl `</span>
- </div>
- }
- >
- <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
- <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
- <Icon
- size="small"
- name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
- class="group-hover/terminal-toggle:hidden"
- />
- <Icon
- size="small"
- name="layout-bottom-partial"
- class="hidden group-hover/terminal-toggle:inline-block"
- />
- <Icon
- size="small"
- name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
- class="hidden group-active/terminal-toggle:inline-block"
- />
- </div>
- </Button>
- </Tooltip>
- </div>
- </Show>
- </div>
- </header>
- <div class="h-[calc(100vh-3rem)] flex">
+ <div class="relative flex-1 min-h-0 flex flex-col">
+ <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
+ <div class="flex-1 min-h-0 flex">
<div
classList={{
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 5dae4ce55..8ea9f87e1 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -675,7 +675,7 @@ export default function Page() {
<For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
- <Tooltip value="Open file" class="flex items-center">
+ <Tooltip value="New Terminal" class="flex items-center">
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
</Tooltip>
</div>