summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-11-18 11:00:27 -0600
committeropencode <[email protected]>2025-11-18 17:07:34 +0000
commitfc5fc2c570e6ac5a6f9647080b80f7fec0c4b605 (patch)
treea3971bfbe17308df9f2d3e1771c0ab544900c099 /packages/desktop
parent4069999b782cc00d4e707f5eca32082bdfad45bc (diff)
downloadopencode-fc5fc2c570e6ac5a6f9647080b80f7fec0c4b605.tar.gz
opencode-fc5fc2c570e6ac5a6f9647080b80f7fec0c4b605.zip
wip(desktop): new layout work
Diffstat (limited to 'packages/desktop')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx2
-rw-r--r--packages/desktop/src/components/session-review.tsx6
-rw-r--r--packages/desktop/src/context/global-sdk.tsx33
-rw-r--r--packages/desktop/src/context/global-sync.tsx174
-rw-r--r--packages/desktop/src/context/layout.tsx25
-rw-r--r--packages/desktop/src/context/local.tsx2
-rw-r--r--packages/desktop/src/context/sdk.tsx25
-rw-r--r--packages/desktop/src/context/session.tsx26
-rw-r--r--packages/desktop/src/context/sync.tsx128
-rw-r--r--packages/desktop/src/index.css6
-rw-r--r--packages/desktop/src/index.tsx39
-rw-r--r--packages/desktop/src/pages/directory-layout.tsx25
-rw-r--r--packages/desktop/src/pages/home.tsx20
-rw-r--r--packages/desktop/src/pages/layout.tsx266
-rw-r--r--packages/desktop/src/pages/session-layout.tsx24
-rw-r--r--packages/desktop/src/pages/session.tsx162
-rw-r--r--packages/desktop/src/utils/encode.ts7
-rw-r--r--packages/desktop/src/utils/index.ts1
18 files changed, 586 insertions, 385 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 2ae5f87fc..c2948a659 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -266,7 +266,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) {
const created = await sdk.client.session.create()
existing = created.data ?? undefined
- if (existing) navigate(`/session/${existing.id}`)
+ if (existing) navigate(`${local.slug()}/session/${existing.id}`)
}
if (!existing) return
diff --git a/packages/desktop/src/components/session-review.tsx b/packages/desktop/src/components/session-review.tsx
index 8aefbbd46..6022164c5 100644
--- a/packages/desktop/src/components/session-review.tsx
+++ b/packages/desktop/src/components/session-review.tsx
@@ -1,4 +1,3 @@
-import { useLocal } from "@/context/local"
import { useSession } from "@/context/session"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
@@ -6,9 +5,10 @@ import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from
import { For, Match, Show, Switch } from "solid-js"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { createStore } from "solid-js/store"
+import { useLayout } from "@/context/layout"
export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
- const local = useLocal()
+ const layout = useLayout()
const session = useSession()
const [store, setStore] = createStore({
open: session.diffs().map((d) => d.file),
@@ -51,7 +51,7 @@ export const SessionReview = (props: { split?: boolean; class?: string; hideExpa
icon="expand"
variant="ghost"
onClick={() => {
- local.layout.review.tab()
+ layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
diff --git a/packages/desktop/src/context/global-sdk.tsx b/packages/desktop/src/context/global-sdk.tsx
new file mode 100644
index 000000000..59bd9b87d
--- /dev/null
+++ b/packages/desktop/src/context/global-sdk.tsx
@@ -0,0 +1,33 @@
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
+import { createSimpleContext } from "./helper"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
+
+export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
+ name: "GlobalSDK",
+ init: (props: { url: string }) => {
+ const abort = new AbortController()
+ const sdk = createOpencodeClient({
+ baseUrl: props.url,
+ signal: abort.signal,
+ })
+
+ const emitter = createGlobalEmitter<{
+ [key: string]: Event
+ }>()
+
+ sdk.global.event().then(async (events) => {
+ for await (const event of events.stream) {
+ console.log("event", event)
+ // console.log("event", event.payload.type)
+ emitter.emit(event.directory, event.payload)
+ }
+ })
+
+ onCleanup(() => {
+ abort.abort()
+ })
+
+ return { url: props.url, client: sdk, event: emitter }
+ },
+})
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
new file mode 100644
index 000000000..99c679c93
--- /dev/null
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -0,0 +1,174 @@
+import type {
+ Message,
+ Agent,
+ Provider,
+ Session,
+ Part,
+ Config,
+ Path,
+ File,
+ FileNode,
+ Project,
+ FileDiff,
+ Todo,
+} from "@opencode-ai/sdk"
+import { createStore, produce, reconcile } from "solid-js/store"
+import { Binary } from "@/utils/binary"
+import { createSimpleContext } from "./helper"
+import { useGlobalSDK } from "./global-sdk"
+
+type State = {
+ ready: boolean
+ provider: Provider[]
+ agent: Agent[]
+ project: Project
+ config: Config
+ path: Path
+ session: Session[]
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ limit: number
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+ node: FileNode[]
+ changes: File[]
+}
+
+export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
+ name: "GlobalSync",
+ init: () => {
+ const [globalStore, setGlobalStore] = createStore<{
+ ready: boolean
+ defaultProject?: Project // TODO: remove this when we can select projects
+ projects: Project[]
+ children: Record<string, State>
+ }>({
+ ready: false,
+ projects: [],
+ children: {},
+ })
+
+ const children: Record<string, ReturnType<typeof createStore<State>>> = {}
+
+ function child(directory: string) {
+ if (!children[directory]) {
+ setGlobalStore("children", directory, {
+ project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "" },
+ ready: false,
+ agent: [],
+ provider: [],
+ session: [],
+ session_diff: {},
+ todo: {},
+ limit: 10,
+ message: {},
+ part: {},
+ node: [],
+ changes: [],
+ })
+ children[directory] = createStore(globalStore.children[directory])
+ }
+ return children[directory]
+ }
+
+ const sdk = useGlobalSDK()
+ sdk.event.listen((e) => {
+ const directory = e.name
+ const [store, setStore] = child(directory)
+
+ const event = e.details
+ switch (event.type) {
+ case "session.updated": {
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+ if (result.found) {
+ setStore("session", result.index, reconcile(event.properties.info))
+ break
+ }
+ setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties.info)
+ }),
+ )
+ break
+ }
+ case "session.diff":
+ setStore("session_diff", event.properties.sessionID, event.properties.diff)
+ break
+ case "todo.updated":
+ setStore("todo", event.properties.sessionID, event.properties.todos)
+ break
+ case "message.updated": {
+ const messages = store.message[event.properties.info.sessionID]
+ if (!messages) {
+ setStore("message", event.properties.info.sessionID, [event.properties.info])
+ break
+ }
+ const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
+ if (result.found) {
+ setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+ break
+ }
+ setStore(
+ "message",
+ event.properties.info.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, event.properties.info)
+ }),
+ )
+ break
+ }
+ case "message.part.updated": {
+ const part = event.properties.part
+ const parts = store.part[part.messageID]
+ if (!parts) {
+ setStore("part", part.messageID, [part])
+ break
+ }
+ const result = Binary.search(parts, part.id, (p) => p.id)
+ if (result.found) {
+ setStore("part", part.messageID, result.index, reconcile(part))
+ break
+ }
+ setStore(
+ "part",
+ part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, part)
+ }),
+ )
+ break
+ }
+ }
+ })
+
+ Promise.all([
+ sdk.client.project.list().then((x) =>
+ setGlobalStore(
+ "projects",
+ x.data!.filter((x) => !x.worktree.includes("opencode-test")),
+ ),
+ ),
+ // TODO: remove this when we can select projects
+ sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
+ ]).then(() => setGlobalStore("ready", true))
+
+ return {
+ data: globalStore,
+ get ready() {
+ return globalStore.ready
+ },
+ child,
+ }
+ },
+})
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 9e4af90aa..d6edd84d1 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -2,12 +2,15 @@ import { createStore } from "solid-js/store"
import { createMemo } from "solid-js"
import { createSimpleContext } from "./helper"
import { makePersisted } from "@solid-primitives/storage"
+import { useGlobalSync } from "./global-sync"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
+ const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
createStore({
+ projects: [] as { directory: string; expanded: boolean }[],
sidebar: {
opened: true,
width: 280,
@@ -17,11 +20,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
{
- name: "__default-layout",
+ name: "___default-layout",
},
)
return {
+ projects: {
+ list: createMemo(() =>
+ globalSync.data.defaultProject
+ ? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
+ : store.projects,
+ ),
+ open(directory: string) {
+ if (store.projects.find((x) => x.directory === directory)) return
+ setStore("projects", (x) => [...x, { directory, expanded: true }])
+ },
+ close(directory: string) {
+ setStore("projects", (x) => x.filter((x) => x.directory !== directory))
+ },
+ expand(directory: string) {
+ setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
+ },
+ collapse(directory: string) {
+ setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
+ },
+ },
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index cef6c5555..4b7ad7cc2 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -5,6 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
+import { base64Encode } from "@/utils"
export type LocalFile = FileNode &
Partial<{
@@ -457,6 +458,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const result = {
+ slug: createMemo(() => base64Encode(sdk.directory)),
model,
agent,
file,
diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx
index b7b753dbc..40165b1ba 100644
--- a/packages/desktop/src/context/sdk.tsx
+++ b/packages/desktop/src/context/sdk.tsx
@@ -2,34 +2,31 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
+import { useGlobalSDK } from "./global-sdk"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
- init: (props: { url: string; directory?: string }) => {
+ init: (props: { directory: string }) => {
+ const globalSDK = useGlobalSDK()
const abort = new AbortController()
- const sdk = createOpencodeClient(
- {
- baseUrl: props.url,
- signal: abort.signal,
- },
- { directory: props.directory },
- )
+ const sdk = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ signal: abort.signal,
+ directory: props.directory,
+ })
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
- sdk.event.subscribe().then(async (events) => {
- for await (const event of events.stream) {
- console.log("event", event.type)
- emitter.emit(event.type, event)
- }
+ globalSDK.event.on(props.directory, async (event) => {
+ emitter.emit(event.type, event)
})
onCleanup(() => {
abort.abort()
})
- return { url: props.url, directory: props.directory, client: sdk, event: emitter }
+ return { directory: props.directory, client: sdk, event: emitter }
},
})
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index a468f4673..36319b0c5 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/packages/desktop/src/context/session.tsx
@@ -6,11 +6,17 @@ import { makePersisted } from "@solid-primitives/storage"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage } from "@opencode-ai/sdk"
+import { useParams } from "@solidjs/router"
+import { base64Encode } from "@/utils"
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
- init: (props: { sessionId?: string }) => {
+ init: () => {
+ const params = useParams()
const sync = useSync()
+ const name = createMemo(
+ () => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
+ )
const [store, setStore] = makePersisted(
createStore<{
@@ -29,17 +35,17 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
cursor: undefined,
}),
{
- name: props.sessionId ?? "new-session",
+ name: name(),
},
)
createEffect(() => {
- if (!props.sessionId) return
- sync.session.sync(props.sessionId)
+ if (!params.id) return
+ sync.session.sync(params.id)
})
- const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
- const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
@@ -53,10 +59,10 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
return userMessages()?.find((m) => m.id === store.messageId)
})
const working = createMemo(() => {
- if (!props.sessionId) return false
+ if (!params.id) return false
const last = lastUserMessage()
if (!last) return false
- const assistantMessages = sync.data.message[props.sessionId]?.filter(
+ const assistantMessages = sync.data.message[params.id]?.filter(
(m) => m.role === "assistant" && m.parentID == last?.id,
) as AssistantMessage[]
const error = assistantMessages?.find((m) => m?.error)?.error
@@ -80,7 +86,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
const model = createMemo(() =>
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
- const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
@@ -96,7 +102,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
})
return {
- id: props.sessionId,
+ id: params.id,
info,
working,
diffs,
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 11b2c36b8..14c0309c4 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -1,135 +1,17 @@
-import type {
- Message,
- Agent,
- Provider,
- Session,
- Part,
- Config,
- Path,
- File,
- FileNode,
- Project,
- FileDiff,
- Todo,
-} from "@opencode-ai/sdk"
-import { createStore, produce, reconcile } from "solid-js/store"
+import type { Part } from "@opencode-ai/sdk"
+import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
+import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
- const [store, setStore] = createStore<{
- ready: boolean
- provider: Provider[]
- agent: Agent[]
- project: Project
- config: Config
- path: Path
- session: Session[]
- session_diff: {
- [sessionID: string]: FileDiff[]
- }
- todo: {
- [sessionID: string]: Todo[]
- }
- limit: number
- message: {
- [sessionID: string]: Message[]
- }
- part: {
- [messageID: string]: Part[]
- }
- node: FileNode[]
- changes: File[]
- }>({
- project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
- config: {},
- path: { state: "", config: "", worktree: "", directory: "" },
- ready: false,
- agent: [],
- provider: [],
- session: [],
- session_diff: {},
- todo: {},
- limit: 10,
- message: {},
- part: {},
- node: [],
- changes: [],
- })
-
+ const globalSync = useGlobalSync()
const sdk = useSDK()
- sdk.event.listen((e) => {
- // fetch the child store
- // make a set store function that always rights to the child store
- const event = e.details
- switch (event.type) {
- case "session.updated": {
- const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
- if (result.found) {
- setStore("session", result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "session.diff":
- setStore("session_diff", event.properties.sessionID, event.properties.diff)
- break
- case "todo.updated":
- setStore("todo", event.properties.sessionID, event.properties.todos)
- break
- case "message.updated": {
- const messages = store.message[event.properties.info.sessionID]
- if (!messages) {
- setStore("message", event.properties.info.sessionID, [event.properties.info])
- break
- }
- const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
- if (result.found) {
- setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "message",
- event.properties.info.sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "message.part.updated": {
- const part = sanitizePart(event.properties.part)
- const parts = store.part[part.messageID]
- if (!parts) {
- setStore("part", part.messageID, [part])
- break
- }
- const result = Binary.search(parts, part.id, (p) => p.id)
- if (result.found) {
- setStore("part", part.messageID, result.index, reconcile(part))
- break
- }
- setStore(
- "part",
- part.messageID,
- produce((draft) => {
- draft.splice(result.index, 0, part)
- }),
- )
- break
- }
- }
- })
+ const [store, setStore] = globalSync.child(sdk.directory)
const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
diff --git a/packages/desktop/src/index.css b/packages/desktop/src/index.css
index 4af87bca6..e40f0842b 100644
--- a/packages/desktop/src/index.css
+++ b/packages/desktop/src/index.css
@@ -1 +1,7 @@
@import "@opencode-ai/ui/styles/tailwind";
+
+:root {
+ a {
+ cursor: default;
+ }
+}
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 2499e5e85..64fab09ec 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -1,15 +1,18 @@
/* @refresh reload */
import "@/index.css"
import { render } from "solid-js/web"
-import { Router, Route } from "@solidjs/router"
+import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Fonts, MarkedProvider } from "@opencode-ai/ui"
-import { SDKProvider } from "./context/sdk"
-import { SyncProvider } from "./context/sync"
+import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout"
-import SessionLayout from "@/pages/session-layout"
+import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
+import { GlobalSDKProvider } from "./context/global-sdk"
+import { SessionProvider } from "./context/session"
+import { base64Encode } from "./utils"
+import { createMemo } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -30,20 +33,36 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
render(
() => (
<MarkedProvider>
- <SDKProvider url={url}>
- <SyncProvider>
+ <GlobalSDKProvider url={url}>
+ <GlobalSyncProvider>
<LayoutProvider>
<MetaProvider>
<Fonts />
<Router root={Layout}>
- <Route path={["/", "/session"]} component={SessionLayout}>
- <Route path="/:id?" component={Session} />
+ <Route
+ path="/"
+ component={() => {
+ const globalSync = useGlobalSync()
+ const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
+ return <Navigate href={`${slug()}/session`} />
+ }}
+ />
+ <Route path="/:dir" component={DirectoryLayout}>
+ <Route path="/" component={() => <Navigate href="session" />} />
+ <Route
+ path="/session/:id?"
+ component={() => (
+ <SessionProvider>
+ <Session />
+ </SessionProvider>
+ )}
+ />
</Route>
</Router>
</MetaProvider>
</LayoutProvider>
- </SyncProvider>
- </SDKProvider>
+ </GlobalSyncProvider>
+ </GlobalSDKProvider>
</MarkedProvider>
),
root!,
diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx
new file mode 100644
index 000000000..fe35f20f9
--- /dev/null
+++ b/packages/desktop/src/pages/directory-layout.tsx
@@ -0,0 +1,25 @@
+import { createMemo, Show, type ParentProps } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { SDKProvider } from "@/context/sdk"
+import { SyncProvider } from "@/context/sync"
+import { LocalProvider } from "@/context/local"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@/utils"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ const sync = useGlobalSync()
+ const directory = createMemo(() => {
+ const decoded = base64Decode(params.dir)
+ return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
+ })
+ return (
+ <Show when={params.id || true} keyed>
+ <SDKProvider directory={directory()}>
+ <SyncProvider>
+ <LocalProvider>{props.children}</LocalProvider>
+ </SyncProvider>
+ </SDKProvider>
+ </Show>
+ )
+}
diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx
new file mode 100644
index 000000000..e2f99afa8
--- /dev/null
+++ b/packages/desktop/src/pages/home.tsx
@@ -0,0 +1,20 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Encode, getFilename } from "@/utils"
+import { For } from "solid-js"
+import { A } from "@solidjs/router"
+import { Button } from "@opencode-ai/ui"
+
+export default function Home() {
+ const sync = useGlobalSync()
+ return (
+ <div class="flex flex-col gap-3">
+ <For each={sync.data.projects}>
+ {(project) => (
+ <Button as={A} href={base64Encode(project.worktree)}>
+ {getFilename(project.worktree)}
+ </Button>
+ )}
+ </For>
+ </div>
+ )
+}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 6e0078a16..33cf7b66f 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,19 +1,25 @@
-import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon } from "@opencode-ai/ui"
-import { createMemo, For, Match, ParentProps, Show, Switch } from "solid-js"
+import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon, Collapsible } from "@opencode-ai/ui"
+import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
-import { useSync } from "@/context/sync"
import { A, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Encode, getFilename } from "@/utils"
export default function Layout(props: ParentProps) {
const params = useParams()
- const sync = useSync()
+ const globalSync = useGlobalSync()
const layout = useLayout()
+ const handleOpenProject = async () => {
+ // layout.projects.open(dir.)
+ }
+
return (
<div class="relative h-screen flex flex-col">
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
- <div
+ <A
+ href="/"
classList={{
"w-12 shrink-0 px-4 py-3.5": true,
"flex items-center justify-start self-stretch": true,
@@ -22,7 +28,7 @@ export default function Layout(props: ParentProps) {
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<Mark class="shrink-0" />
- </div>
+ </A>
</header>
<div class="h-[calc(100vh-3rem)] flex">
<div
@@ -33,136 +39,154 @@ export default function Layout(props: ParentProps) {
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
- <div class="flex flex-col justify-center items-start gap-4 self-stretch p-2 overflow-hidden mx-auto @[4rem]:mx-0">
- <Switch>
- <Match when={layout.sidebar.opened()}>
- <Button
- variant="ghost"
- size="large"
- class="group/sidebar-toggle w-full text-left justify-start"
- onClick={layout.sidebar.toggle}
- >
- <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
- <Icon name="layout-left" size="small" class="group-hover/sidebar-toggle:hidden" />
- <Icon
- name="layout-left-partial"
- size="small"
- class="hidden group-hover/sidebar-toggle:inline-block"
- />
- <Icon
- name="layout-left-full"
- size="small"
- class="hidden group-active/sidebar-toggle:inline-block"
- />
- </div>
+ <div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
+ <Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
+ <Button
+ variant="ghost"
+ size="large"
+ class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
+ onClick={layout.sidebar.toggle}
+ >
+ <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
+ size="small"
+ class="group-hover/sidebar-toggle:hidden"
+ />
+ <Icon
+ name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
+ size="small"
+ class="hidden group-hover/sidebar-toggle:inline-block"
+ />
+ <Icon
+ name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
+ size="small"
+ class="hidden group-active/sidebar-toggle:inline-block"
+ />
+ </div>
+ <Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
- </Button>
- </Match>
- <Match when={!layout.sidebar.opened()}>
- <Tooltip placement="right" value="Toggle sidebar">
- <Button variant="ghost" size="large" class="group/sidebar-toggle" onClick={layout.sidebar.toggle}>
- <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
- <Icon name="layout-right" size="small" class="group-hover/sidebar-toggle:hidden" />
- <Icon
- name="layout-right-partial"
- size="small"
- class="hidden group-hover/sidebar-toggle:inline-block"
- />
- <Icon
- name="layout-right-full"
- size="small"
- class="hidden group-active/sidebar-toggle:inline-block"
- />
- </div>
- </Button>
- </Tooltip>
- </Match>
- </Switch>
- <div class="w-full px-3">
- <Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
- New Session
+ </Show>
</Button>
- <Tooltip placement="right" value="New session">
- <IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
- </Tooltip>
- </div>
- <div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
- <nav class="w-full">
- <For each={sync.data.session}>
- {(session) => {
- const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+ </Tooltip>
+ <div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
+ <div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
+ <For each={layout.projects.list()}>
+ {(project) => {
+ const [store] = globalSync.child(project.directory)
+ const slug = createMemo(() => base64Encode(project.directory))
return (
- <A
- data-active={session.id === params.id}
- href={`/session/${session.id}`}
- class="group/session focus:outline-none cursor-default"
- >
- <Tooltip placement="right" value={session.title}>
- <div
- class="w-full mb-1.5 px-3 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>
- </div>
- <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>
- </div>
- </Tooltip>
- </A>
+ <Collapsible variant="ghost" defaultOpen class="gap-2">
+ <Button
+ as={"div"}
+ variant="ghost"
+ class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
+ >
+ <Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
+ {getFilename(project.directory)}
+ </Collapsible.Trigger>
+ <IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
+ </Button>
+ <Collapsible.Content>
+ <nav class="w-full flex flex-col gap-1.5">
+ <For each={store.session}>
+ {(session) => {
+ const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+ 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 px-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>
+ </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>
+ </div>
+ </Tooltip>
+ </A>
+ )
+ }}
+ </For>
+ </nav>
+ {/* <Show when={sync.session.more()}> */}
+ {/* <button */}
+ {/* class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
+ {/* onClick={() => sync.session.fetch()} */}
+ {/* > */}
+ {/* Show more */}
+ {/* </button> */}
+ {/* </Show> */}
+ </Collapsible.Content>
+ </Collapsible>
)
}}
</For>
- </nav>
- <Show when={sync.session.more()}>
- <button
- class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
- onClick={() => sync.session.fetch()}
- >
- Show more
- </button>
- </Show>
+ </div>
</div>
</div>
- <div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
- <Button
- as={"a"}
- href="https://opencode.ai/desktop-feedback"
- target="_blank"
- class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
- variant="ghost"
- icon="speech-bubble"
- >
- Share feedback
- </Button>
- <Tooltip placement="right" value="Share feedback">
- <IconButton
+ <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+ <Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
+ <Button
+ disabled
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
+ variant="ghost"
+ size="large"
+ icon="folder-add-left"
+ onClick={handleOpenProject}
+ >
+ <Show when={layout.sidebar.opened()}>Open project</Show>
+ </Button>
+ </Tooltip>
+ <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
+ <Button
+ disabled
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
+ variant="ghost"
+ size="large"
+ icon="settings-gear"
+ >
+ <Show when={layout.sidebar.opened()}>Settings</Show>
+ </Button>
+ </Tooltip>
+ <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
+ <Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
- icon="speech-bubble"
+ class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
variant="ghost"
size="large"
- class="@[4rem]:hidden stroke-[1.5px]"
- />
+ icon="bubble-5"
+ >
+ <Show when={layout.sidebar.opened()}>Share feedback</Show>
+ </Button>
</Tooltip>
</div>
</div>
diff --git a/packages/desktop/src/pages/session-layout.tsx b/packages/desktop/src/pages/session-layout.tsx
deleted file mode 100644
index 7f355c9bc..000000000
--- a/packages/desktop/src/pages/session-layout.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Show, type ParentProps } from "solid-js"
-import { SessionProvider, useSession } from "@/context/session"
-import { useParams } from "@solidjs/router"
-import { SDKProvider, useSDK } from "@/context/sdk"
-import { LocalProvider } from "@/context/local"
-
-export default function Layout(props: ParentProps) {
- const params = useParams()
- const root = useSDK()
- return (
- <Show when={params.id || true} keyed>
- <SessionProvider sessionId={params.id}>
- {(() => {
- const session = useSession()
- return (
- <SDKProvider url={root.url} directory={session.info()?.directory}>
- <LocalProvider>{props.children}</LocalProvider>
- </SDKProvider>
- )
- })()}
- </SessionProvider>
- </Show>
- )
-}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 884d789b1..01d32a672 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -294,7 +294,7 @@ export default function Page() {
<Tabs.List>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
- <div>Chat</div>
+ <div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
@@ -364,88 +364,94 @@ export default function Page() {
}}
>
<Show when={session.messages.user().length > 1}>
- <ul
- role="list"
- classList={{
- "mr-8 shrink-0 flex flex-col items-start": true,
- "absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": layout.review.state() === "tab",
- "mt-3": layout.review.state() === "pane",
- }}
- >
- <For each={session.messages.user()}>
- {(message) => {
- const assistantMessages = createMemo(() => {
- if (!session.id) return []
- return sync.data.message[session.id]?.filter(
- (m) => m.role === "assistant" && m.parentID == message.id,
- ) as AssistantMessageType[]
- })
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
- const working = createMemo(() => !message.summary?.body && !error())
+ {(_) => {
+ const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
- const handleClick = () => session.messages.setActive(message.id)
+ return (
+ <ul
+ role="list"
+ classList={{
+ "mr-8 shrink-0 flex flex-col items-start": true,
+ "absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": expanded(),
+ "mt-3": !expanded(),
+ }}
+ >
+ <For each={session.messages.user()}>
+ {(message) => {
+ const assistantMessages = createMemo(() => {
+ if (!session.id) return []
+ return sync.data.message[session.id]?.filter(
+ (m) => m.role === "assistant" && m.parentID == message.id,
+ ) as AssistantMessageType[]
+ })
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+ const working = createMemo(() => !message.summary?.body && !error())
- return (
- <li
- classList={{
- "group/li flex items-center self-stretch justify-end": true,
- "@7xl:justify-start": layout.review.state() === "tab",
- }}
- >
- <Tooltip
- placement="right"
- gutter={8}
- value={
- <div class="flex items-center gap-2">
- <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
- {message.summary?.title}
- </div>
- }
- >
- <button
- data-active={session.messages.active()?.id === message.id}
- onClick={handleClick}
- classList={{
- "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
- "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
- "@7xl:hidden": layout.review.state() === "tab",
- }}
- >
- <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
- </button>
- </Tooltip>
- <button
- classList={{
- "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
- "@7xl:flex": layout.review.state() === "tab",
- }}
- onClick={handleClick}
- >
- <Switch>
- <Match when={working()}>
- <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
- </Match>
- <Match when={true}>
- <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
- </Match>
- </Switch>
- <div
- data-active={session.messages.active()?.id === message.id}
+ const handleClick = () => session.messages.setActive(message.id)
+
+ return (
+ <li
classList={{
- "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
- "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+ "group/li flex items-center self-stretch justify-end": true,
+ "@7xl:justify-start": expanded(),
}}
>
- <Show when={message.summary?.title} fallback="New message">
- {message.summary?.title}
- </Show>
- </div>
- </button>
- </li>
- )
- }}
- </For>
- </ul>
+ <Tooltip
+ placement="right"
+ gutter={8}
+ value={
+ <div class="flex items-center gap-2">
+ <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+ {message.summary?.title}
+ </div>
+ }
+ >
+ <button
+ data-active={session.messages.active()?.id === message.id}
+ onClick={handleClick}
+ classList={{
+ "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
+ "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
+ "@7xl:hidden": expanded(),
+ }}
+ >
+ <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
+ </button>
+ </Tooltip>
+ <button
+ classList={{
+ "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
+ "@7xl:flex": expanded(),
+ }}
+ onClick={handleClick}
+ >
+ <Switch>
+ <Match when={working()}>
+ <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
+ </Match>
+ <Match when={true}>
+ <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+ </Match>
+ </Switch>
+ <div
+ data-active={session.messages.active()?.id === message.id}
+ classList={{
+ "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+ "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+ }}
+ >
+ <Show when={message.summary?.title} fallback="New message">
+ {message.summary?.title}
+ </Show>
+ </div>
+ </button>
+ </li>
+ )
+ }}
+ </For>
+ </ul>
+ )
+ }}
</Show>
<div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
<For each={session.messages.user()}>
diff --git a/packages/desktop/src/utils/encode.ts b/packages/desktop/src/utils/encode.ts
new file mode 100644
index 000000000..265bba5c4
--- /dev/null
+++ b/packages/desktop/src/utils/encode.ts
@@ -0,0 +1,7 @@
+export function base64Encode(value: string) {
+ return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}
+
+export function base64Decode(value: string) {
+ return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
+}
diff --git a/packages/desktop/src/utils/index.ts b/packages/desktop/src/utils/index.ts
index ae89e4417..63a656cc4 100644
--- a/packages/desktop/src/utils/index.ts
+++ b/packages/desktop/src/utils/index.ts
@@ -1,2 +1,3 @@
export * from "./path"
export * from "./dom"
+export * from "./encode"