summaryrefslogtreecommitdiffhomepage
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
parent4069999b782cc00d4e707f5eca32082bdfad45bc (diff)
downloadopencode-fc5fc2c570e6ac5a6f9647080b80f7fec0c4b605.tar.gz
opencode-fc5fc2c570e6ac5a6f9647080b80f7fec0c4b605.zip
wip(desktop): new layout work
-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
-rw-r--r--packages/opencode/src/server/server.ts10
-rw-r--r--packages/opencode/src/session/summary.ts2
-rw-r--r--packages/sdk/js/src/client.ts15
-rw-r--r--packages/ui/src/components/button.css24
-rw-r--r--packages/ui/src/components/icon.tsx3
-rw-r--r--packages/ui/src/components/tooltip.tsx32
24 files changed, 634 insertions, 423 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"
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 74087e1bd..787b08c07 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -118,6 +118,7 @@ export namespace Server {
timer.stop()
}
})
+ .use(cors())
.get(
"/global/event",
describeRoute({
@@ -146,12 +147,6 @@ export namespace Server {
async (c) => {
log.info("global event connected")
return streamSSE(c, async (stream) => {
- stream.writeSSE({
- data: JSON.stringify({
- type: "server.connected",
- properties: {},
- }),
- })
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event),
@@ -169,7 +164,7 @@ export namespace Server {
},
)
.use(async (c, next) => {
- const directory = c.req.query("directory") ?? process.cwd()
+ const directory = c.req.query("directory") ?? c.req.header("x-opencode-directory") ?? process.cwd()
return Instance.provide({
directory,
init: InstanceBootstrap,
@@ -178,7 +173,6 @@ export namespace Server {
},
})
})
- .use(cors())
.get(
"/doc",
openAPIRouteHandler(app, {
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index 3e290c6c0..e1ea483ff 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/packages/opencode/src/session/summary.ts
@@ -145,7 +145,7 @@ export namespace SessionSummary {
messageID: Identifier.schema("message").optional(),
}),
async (input) => {
- return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]) ?? []
+ return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
},
)
diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts
index ebe0b8ed4..ab4b75b5b 100644
--- a/packages/sdk/js/src/client.ts
+++ b/packages/sdk/js/src/client.ts
@@ -5,7 +5,7 @@ import { createClient } from "./gen/client/client.gen.js"
import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
-export function createOpencodeClient(config?: Config, options?: { directory?: string }) {
+export function createOpencodeClient(config?: Config & { directory?: string }) {
if (!config?.fetch) {
config = {
...config,
@@ -17,16 +17,13 @@ export function createOpencodeClient(config?: Config, options?: { directory?: st
}
}
- const client = createClient(config)
-
- if (options?.directory) {
- async function middleware(request: Request) {
- const url = new URL(request.url)
- url.searchParams.set("directory", options!.directory!)
- return new Request(url.toString(), request)
+ if (config?.directory) {
+ config.headers = {
+ ...config.headers,
+ "x-opencode-directory": config.directory,
}
- client.interceptors.request.use(middleware)
}
+ const client = createClient(config)
return new OpencodeClient({ client })
}
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index 7015ea88d..4f5ebc241 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -27,6 +27,12 @@
border-color: var(--border-active);
background-color: var(--surface-brand-active);
}
+ &:disabled {
+ border-color: var(--border-disabled);
+ background-color: var(--surface-disabled);
+ color: var(--text-weak);
+ cursor: not-allowed;
+ }
}
&[data-variant="ghost"] {
@@ -43,6 +49,11 @@
&:active:not(:disabled) {
background-color: var(--surface-raised-base-active);
}
+ &:disabled {
+ color: var(--text-weak);
+ opacity: 0.7;
+ cursor: not-allowed;
+ }
}
&[data-variant="secondary"] {
@@ -69,6 +80,12 @@
scale: 0.99;
transition: all 150ms ease-out;
}
+ &:disabled {
+ border-color: var(--border-disabled);
+ background-color: var(--surface-disabled);
+ color: var(--text-weak);
+ cursor: not-allowed;
+ }
[data-slot="icon"] {
color: var(--icon-strong-base);
@@ -106,13 +123,6 @@
letter-spacing: var(--letter-spacing-normal);
}
- &:disabled {
- border-color: var(--border-disabled);
- background-color: var(--surface-disabled);
- color: var(--text-weak);
- cursor: not-allowed;
- }
-
&:focus {
outline: none;
}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index da893564e..f2594cdaa 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -162,6 +162,9 @@ const newIcons = {
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
expand: `<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="currentColor" stroke-linecap="square"/>`,
collapse: `<path d="M16.666 8.33398H11.666V3.33398" stroke="currentColor" stroke-linecap="square"/><path d="M8.33398 16.666V11.666H3.33398" stroke="currentColor" stroke-linecap="square"/>`,
+ "folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
+ "settings-gear": `<path d="M9.99935 2.08398L17.0827 6.04227L17.0827 13.9589L9.99934 17.9172L2.91602 13.9592L2.91602 6.04225L9.99935 2.08398Z" stroke="currentColor" stroke-linecap="square"/><path d="M12.916 10.0006C12.916 11.6115 11.6102 12.9173 9.99937 12.9173C8.38854 12.9173 7.0827 11.6115 7.0827 10.0006C7.0827 8.38982 8.38854 7.08398 9.99937 7.08398C11.6102 7.08398 12.916 8.38982 12.916 10.0006Z" stroke="currentColor" stroke-linecap="square"/>`,
+ "bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx
index e3784ed8e..663dc12e6 100644
--- a/packages/ui/src/components/tooltip.tsx
+++ b/packages/ui/src/components/tooltip.tsx
@@ -1,15 +1,16 @@
import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
-import { children, createEffect, createSignal, splitProps, type JSX } from "solid-js"
+import { children, createEffect, createSignal, Match, splitProps, Switch, type JSX } from "solid-js"
import type { ComponentProps } from "solid-js"
export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
value: JSX.Element
class?: string
+ inactive?: boolean
}
export function Tooltip(props: TooltipProps) {
const [open, setOpen] = createSignal(false)
- const [local, others] = splitProps(props, ["children", "class"])
+ const [local, others] = splitProps(props, ["children", "class", "inactive"])
const c = children(() => local.children)
@@ -29,16 +30,21 @@ export function Tooltip(props: TooltipProps) {
})
return (
- <KobalteTooltip forceMount gutter={4} {...others} open={open()} onOpenChange={setOpen}>
- <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
- {c()}
- </KobalteTooltip.Trigger>
- <KobalteTooltip.Portal>
- <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
- {others.value}
- {/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
- </KobalteTooltip.Content>
- </KobalteTooltip.Portal>
- </KobalteTooltip>
+ <Switch>
+ <Match when={local.inactive}>{local.children}</Match>
+ <Match when={true}>
+ <KobalteTooltip forceMount gutter={4} {...others} open={open()} onOpenChange={setOpen}>
+ <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
+ {c()}
+ </KobalteTooltip.Trigger>
+ <KobalteTooltip.Portal>
+ <KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
+ {others.value}
+ {/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
+ </KobalteTooltip.Content>
+ </KobalteTooltip.Portal>
+ </KobalteTooltip>
+ </Match>
+ </Switch>
)
}