diff options
| author | Adam <[email protected]> | 2025-11-18 11:00:27 -0600 |
|---|---|---|
| committer | opencode <[email protected]> | 2025-11-18 17:07:34 +0000 |
| commit | fc5fc2c570e6ac5a6f9647080b80f7fec0c4b605 (patch) | |
| tree | a3971bfbe17308df9f2d3e1771c0ab544900c099 /packages/desktop | |
| parent | 4069999b782cc00d4e707f5eca32082bdfad45bc (diff) | |
| download | opencode-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.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-review.tsx | 6 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sdk.tsx | 33 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 174 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 25 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/context/sdk.tsx | 25 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 26 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 128 | ||||
| -rw-r--r-- | packages/desktop/src/index.css | 6 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 39 | ||||
| -rw-r--r-- | packages/desktop/src/pages/directory-layout.tsx | 25 | ||||
| -rw-r--r-- | packages/desktop/src/pages/home.tsx | 20 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 266 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session-layout.tsx | 24 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 162 | ||||
| -rw-r--r-- | packages/desktop/src/utils/encode.ts | 7 | ||||
| -rw-r--r-- | packages/desktop/src/utils/index.ts | 1 |
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" |
