diff options
| author | Adam <[email protected]> | 2025-10-23 15:27:31 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-24 12:16:32 -0500 |
| commit | 3eb2db98ed0a9c266e1bf00544e460cb0633b368 (patch) | |
| tree | eb04fea563b3a3b74a3d89ca9500e92cf4b908c8 /packages | |
| parent | 35dec0649db8f46bffd7121af9cd301668e69e8c (diff) | |
| download | opencode-3eb2db98ed0a9c266e1bf00544e460cb0633b368.tar.gz opencode-3eb2db98ed0a9c266e1bf00544e460cb0633b368.zip | |
wip: desktop work
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/desktop/src/components/code.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/components/editor-pane.tsx | 252 | ||||
| -rw-r--r-- | packages/desktop/src/components/file-tree.tsx | 3 | ||||
| -rw-r--r-- | packages/desktop/src/components/markdown.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 9 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-timeline.tsx | 7 | ||||
| -rw-r--r-- | packages/desktop/src/context/event.tsx | 34 | ||||
| -rw-r--r-- | packages/desktop/src/context/helper.tsx | 25 | ||||
| -rw-r--r-- | packages/desktop/src/context/index.ts | 6 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 1054 | ||||
| -rw-r--r-- | packages/desktop/src/context/marked.tsx | 63 | ||||
| -rw-r--r-- | packages/desktop/src/context/sdk.tsx | 56 | ||||
| -rw-r--r-- | packages/desktop/src/context/shiki.tsx | 24 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 301 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 33 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 372 | ||||
| -rw-r--r-- | packages/ui/src/components/icon-button.tsx | 5 | ||||
| -rw-r--r-- | packages/ui/src/components/icon.css | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/tabs.css | 24 |
19 files changed, 1175 insertions, 1103 deletions
diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index b4dd216e9..11518e73a 100644 --- a/packages/desktop/src/components/code.tsx +++ b/packages/desktop/src/components/code.tsx @@ -1,8 +1,8 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki" import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" -import { useLocal, useShiki } from "@/context" -import type { TextSelection } from "@/context/local" +import { useLocal, type TextSelection } from "@/context/local" import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" +import { useShiki } from "@/context/shiki" type DefinedSelection = Exclude<TextSelection, undefined> diff --git a/packages/desktop/src/components/editor-pane.tsx b/packages/desktop/src/components/editor-pane.tsx deleted file mode 100644 index a97a0ef7f..000000000 --- a/packages/desktop/src/components/editor-pane.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js" -import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui" -import { FileIcon } from "@/ui" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, - useDragDropContext, -} from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import type { LocalFile } from "@/context/local" -import { Code } from "@/components/code" -import { useLocal } from "@/context" -import type { JSX } from "solid-js" - -interface EditorPaneProps { - onFileClick: (file: LocalFile) => void -} - -export default function EditorPane(props: EditorPaneProps): JSX.Element { - const [localProps] = splitProps(props, ["onFileClick"]) - const local = useLocal() - const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined) - - const navigateChange = (dir: 1 | -1) => { - const active = local.file.active() - if (!active) return - const current = local.file.changeIndex(active.path) - const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir - local.file.setChangeIndex(active.path, next) - } - - const handleTabChange = (path: string) => { - local.file.open(path) - } - - const handleTabClose = (file: LocalFile) => { - local.file.close(file.path) - } - - const handleDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setActiveItem(id) - } - - const handleDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const currentFiles = local.file.opened().map((file) => file.path) - const fromIndex = currentFiles.indexOf(draggable.id.toString()) - const toIndex = currentFiles.indexOf(droppable.id.toString()) - if (fromIndex !== toIndex) { - local.file.move(draggable.id.toString(), toIndex) - } - } - } - - const handleDragEnd = () => { - setActiveItem(undefined) - } - - return ( - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs value={local.file.active()?.path} onChange={handleTabChange}> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List> - <SortableProvider ids={local.file.opened().map((file) => file.path)}> - <For each={local.file.opened()}> - {(file) => <SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />} - </For> - </SortableProvider> - </Tabs.List> - <div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40"> - <Show when={local.file.active() && local.file.active()!.content?.diff}> - {(() => { - const activeFile = local.file.active()! - const view = local.file.view(activeFile.path) - return ( - <div class="flex items-center gap-1"> - <Show when={view !== "raw"}> - <div class="mr-1 flex items-center gap-1"> - <Tooltip value="Previous change" placement="bottom"> - <IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} /> - </Tooltip> - <Tooltip value="Next change" placement="bottom"> - <IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} /> - </Tooltip> - </div> - </Show> - <Tooltip value="Raw" placement="bottom"> - <IconButton - icon="file-text" - variant="ghost" - classList={{ - "text-text": view === "raw", - "text-text-muted/70": view !== "raw", - "bg-background-element": view === "raw", - }} - onClick={() => local.file.setView(activeFile.path, "raw")} - /> - </Tooltip> - <Tooltip value="Unified diff" placement="bottom"> - <IconButton - icon="checklist" - variant="ghost" - classList={{ - "text-text": view === "diff-unified", - "text-text-muted/70": view !== "diff-unified", - "bg-background-element": view === "diff-unified", - }} - onClick={() => local.file.setView(activeFile.path, "diff-unified")} - /> - </Tooltip> - <Tooltip value="Split diff" placement="bottom"> - <IconButton - icon="columns" - variant="ghost" - classList={{ - "text-text": view === "diff-split", - "text-text-muted/70": view !== "diff-split", - "bg-background-element": view === "diff-split", - }} - onClick={() => local.file.setView(activeFile.path, "diff-split")} - /> - </Tooltip> - </div> - ) - })()} - </Show> - </div> - </div> - <For each={local.file.opened()}> - {(file) => ( - <Tabs.Content value={file.path} class="select-text"> - {(() => { - const view = local.file.view(file.path) - const showRaw = view === "raw" || !file.content?.diff - const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") - return <Code path={file.path} code={code} class="[&_code]:pb-60" /> - })()} - </Tabs.Content> - )} - </For> - </Tabs> - <DragOverlay> - {(() => { - const id = activeItem() - if (!id) return null - const draggedFile = local.file.node(id) - if (!draggedFile) return null - return ( - <div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent"> - <TabVisual file={draggedFile} /> - </div> - ) - })()} - </DragOverlay> - </DragDropProvider> - ) -} - -function TabVisual(props: { file: LocalFile }): JSX.Element { - return ( - <div class="flex items-center gap-x-1.5"> - <FileIcon node={props.file} class="" /> - <span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}> - {props.file.name} - </span> - <span class="text-xs opacity-70"> - <Switch> - <Match when={props.file.status?.status === "modified"}> - <span class="text-primary">M</span> - </Match> - <Match when={props.file.status?.status === "added"}> - <span class="text-success">A</span> - </Match> - <Match when={props.file.status?.status === "deleted"}> - <span class="text-error">D</span> - </Match> - </Switch> - </span> - </div> - ) -} - -function SortableTab(props: { - file: LocalFile - onTabClick: (file: LocalFile) => void - onTabClose: (file: LocalFile) => void -}): JSX.Element { - const sortable = createSortable(props.file.path) - - return ( - // @ts-ignore - <div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}> - <Tooltip value={props.file.path} placement="bottom"> - <div class="relative"> - <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}> - <TabVisual file={props.file} /> - </Tabs.Trigger> - <IconButton - icon="close" - class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100" - variant="ghost" - onClick={() => props.onTabClose(props.file)} - /> - </div> - </Tooltip> - </div> - ) -} - -function ConstrainDragYAxis(): JSX.Element { - const context = useDragDropContext() - if (!context) return <></> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-y-axis", - order: 100, - callback: (transform) => ({ ...transform, y: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <></> -} - -const getDraggableId = (event: unknown): string | undefined => { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined -} diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index 7e4b1abcc..d10328136 100644 --- a/packages/desktop/src/components/file-tree.tsx +++ b/packages/desktop/src/components/file-tree.tsx @@ -1,5 +1,4 @@ -import { useLocal } from "@/context" -import type { LocalFile } from "@/context/local" +import { useLocal, type LocalFile } from "@/context/local" import { Tooltip } from "@opencode-ai/ui" import { Collapsible, FileIcon } from "@/ui" import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js" diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx index 30e3831e3..e0f185f5f 100644 --- a/packages/desktop/src/components/markdown.tsx +++ b/packages/desktop/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { useMarked } from "@/context" +import { useMarked } from "@/context/marked" import { createResource } from "solid-js" function strip(text: string): string { diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 55a410516..47893f44c 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,12 +1,11 @@ -import { useLocal } from "@/context" -import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui" +import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui" import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js" +import { createEffect, on, Component, createMemo, Show, For } from "solid-js" import { createStore } from "solid-js/store" import { FileIcon } from "@/ui" import { getDirectory, getFilename } from "@/utils" import { createFocusSignal } from "@solid-primitives/active-element" -import { TextSelection } from "@/context/local" +import { TextSelection, useLocal } from "@/context/local" import { DateTime } from "luxon" interface PartBase { @@ -245,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } return ( - <div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3"> + <div class="relative size-full _max-h-[320px] flex flex-col gap-3"> <Show when={store.popoverIsOpen}> <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"> <For each={flat()}> diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx index 0d8a7cd3c..b751f2940 100644 --- a/packages/desktop/src/components/session-timeline.tsx +++ b/packages/desktop/src/components/session-timeline.tsx @@ -1,4 +1,3 @@ -import { useLocal, useSync } from "@/context" import { Icon, Tooltip } from "@opencode-ai/ui" import { Collapsible } from "@/ui" import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk" @@ -22,6 +21,8 @@ import { createElementSize } from "@solid-primitives/resize-observer" import { createScrollPosition } from "@solid-primitives/scroll" import { ProgressCircle } from "./progress-circle" import { pipe, sumBy } from "remeda" +import { useSync } from "@/context/sync" +import { useLocal } from "@/context/local" function Part(props: ParentProps & ComponentProps<"div">) { const [local, others] = splitProps(props, ["class", "classList", "children"]) @@ -394,7 +395,7 @@ export default function SessionTimeline(props: { session: string; class?: string [props.class ?? ""]: !!props.class, }} > - <div class="py-1.5 px-6 flex justify-end items-center self-stretch"> + <div class="flex justify-end items-center self-stretch"> <div class="flex items-center gap-6"> <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5"> <Show when={context()}> @@ -405,7 +406,7 @@ export default function SessionTimeline(props: { session: string; class?: string <div class="text-14-regular text-text-strong text-right">{cost()}</div> </div> </div> - <ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1"> + <ul role="list" class="flex flex-col items-start self-stretch"> <For each={messagesWithValidParts()}> {(message) => ( <div diff --git a/packages/desktop/src/context/event.tsx b/packages/desktop/src/context/event.tsx deleted file mode 100644 index a2aa54181..000000000 --- a/packages/desktop/src/context/event.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js" -import { createEventBus } from "@solid-primitives/event-bus" -import type { Event as SDKEvent } from "@opencode-ai/sdk" -import { useSDK } from "@/context" - -export type Event = SDKEvent // can extend with custom events later - -function init() { - const sdk = useSDK() - const bus = createEventBus<Event>() - sdk.event.subscribe().then(async (events) => { - for await (const event of events.stream) { - bus.emit(event) - } - }) - return bus -} - -type EventContext = ReturnType<typeof init> - -const ctx = createContext<EventContext>() - -export function EventProvider(props: ParentProps) { - const value = init() - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} - -export function useEvent() { - const value = useContext(ctx) - if (!value) { - throw new Error("useEvent must be used within a EventProvider") - } - return value -} diff --git a/packages/desktop/src/context/helper.tsx b/packages/desktop/src/context/helper.tsx new file mode 100644 index 000000000..6be88e775 --- /dev/null +++ b/packages/desktop/src/context/helper.tsx @@ -0,0 +1,25 @@ +import { createContext, Show, useContext, type ParentProps } from "solid-js" + +export function createSimpleContext<T, Props extends Record<string, any>>(input: { + name: string + init: ((input: Props) => T) | (() => T) +}) { + const ctx = createContext<T>() + + return { + provider: (props: ParentProps<Props>) => { + const init = input.init(props) + return ( + // @ts-expect-error + <Show when={init.ready === undefined || init.ready === true}> + <ctx.Provider value={init}>{props.children}</ctx.Provider> + </Show> + ) + }, + use() { + const value = useContext(ctx) + if (!value) throw new Error(`${input.name} context must be used within a context provider`) + return value + }, + } +} diff --git a/packages/desktop/src/context/index.ts b/packages/desktop/src/context/index.ts deleted file mode 100644 index 6ca3bbf97..000000000 --- a/packages/desktop/src/context/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { EventProvider, useEvent } from "./event" -export { LocalProvider, useLocal } from "./local" -export { MarkedProvider, useMarked } from "./marked" -export { SDKProvider, useSDK } from "./sdk" -export { ShikiProvider, useShiki } from "./shiki" -export { SyncProvider, useSync } from "./sync" diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index c60e4520e..981039bb6 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -1,8 +1,19 @@ import { createStore, produce, reconcile } from "solid-js/store" -import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js" -import { uniqueBy } from "remeda" -import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk" -import { useSDK, useEvent, useSync } from "@/context" +import { batch, createEffect, createMemo } from "solid-js" +import { pipe, sumBy, uniqueBy } from "remeda" +import type { + FileContent, + FileNode, + Model, + Provider, + File as FileStatus, + Part, + Message, + AssistantMessage, +} from "@opencode-ai/sdk" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" +import { useSync } from "./sync" export type LocalFile = FileNode & Partial<{ @@ -28,542 +39,567 @@ export type ModelKey = { providerID: string; modelID: string } export type FileContext = { type: "file"; path: string; selection?: TextSelection } export type ContextItem = FileContext -function init() { - const sdk = useSDK() - const sync = useSync() - - const agent = (() => { - const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) - const [store, setStore] = createStore<{ - current: string - }>({ - current: list()[0].name, - }) - return { - list, - current() { - return list().find((x) => x.name === store.current)! - }, - set(name: string | undefined) { - setStore("current", name ?? list()[0].name) - }, - move(direction: 1 | -1) { - let next = list().findIndex((x) => x.name === store.current) + direction - if (next < 0) next = list().length - 1 - if (next >= list().length) next = 0 - const value = list()[next] - setStore("current", value.name) - if (value.model) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - }, - } - })() - - const model = (() => { - const list = createMemo(() => - sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), - ) - const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) - - const [store, setStore] = createStore<{ - model: Record<string, ModelKey> - recent: ModelKey[] - }>({ - model: {}, - recent: [], - }) - - const value = localStorage.getItem("model") - setStore("recent", JSON.parse(value ?? "[]")) - createEffect(() => { - localStorage.setItem("model", JSON.stringify(store.recent)) - }) - - const fallback = createMemo(() => { - if (store.recent.length) return store.recent[0] - const provider = sync.data.provider[0] - const model = Object.values(provider.models)[0] - return { modelID: model.id, providerID: provider.id } - }) - - const current = createMemo(() => { - const a = agent.current() - return find(store.model[agent.current().name]) ?? find(a.model ?? fallback()) - }) - - const recent = createMemo(() => store.recent.map(find).filter(Boolean)) - - return { - list, - current, - recent, - set(model: ModelKey | undefined, options?: { recent?: boolean }) { - batch(() => { - setStore("model", agent.current().name, model ?? fallback()) - if (options?.recent && model) { - const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() - setStore("recent", uniq) - } - }) - }, - } - })() - - const file = (() => { - const [store, setStore] = createStore<{ - node: Record<string, LocalFile> - opened: string[] - active?: string - }>({ - node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])), - opened: [], - }) - - const active = createMemo(() => { - if (!store.active) return undefined - return store.node[store.active] - }) - const opened = createMemo(() => store.opened.map((x) => store.node[x])) - const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) - const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) - - // createEffect((prev: FileStatus[]) => { - // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) - // for (const p of removed) { - // setStore( - // "node", - // p.path, - // produce((draft) => { - // draft.status = undefined - // draft.view = "raw" - // }), - // ) - // load(p.path) - // } - // for (const p of sync.data.changes) { - // if (store.node[p.path] === undefined) { - // fetch(p.path).then(() => { - // if (store.node[p.path] === undefined) return - // setStore("node", p.path, "status", p) - // }) - // } else { - // setStore("node", p.path, "status", p) - // } - // } - // return sync.data.changes - // }, sync.data.changes) - - const changed = (path: string) => { - const node = store.node[path] - if (node?.status) return true - const set = changeset() - if (set.has(path)) return true - for (const p of set) { - if (p.startsWith(path ? path + "/" : "")) return true +export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ + name: "Local", + init: () => { + const sdk = useSDK() + const sync = useSync() + + const agent = (() => { + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const [store, setStore] = createStore<{ + current: string + }>({ + current: list()[0].name, + }) + return { + list, + current() { + return list().find((x) => x.name === store.current)! + }, + set(name: string | undefined) { + setStore("current", name ?? list()[0].name) + }, + move(direction: 1 | -1) { + let next = list().findIndex((x) => x.name === store.current) + direction + if (next < 0) next = list().length - 1 + if (next >= list().length) next = 0 + const value = list()[next] + setStore("current", value.name) + if (value.model) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + }, } - return false - } - - const resetNode = (path: string) => { - setStore("node", path, { - loaded: undefined, - pinned: undefined, - content: undefined, - selection: undefined, - scrollTop: undefined, - folded: undefined, - view: undefined, - selectedChange: undefined, + })() + + const model = (() => { + const list = createMemo(() => + sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) + + const [store, setStore] = createStore<{ + model: Record<string, ModelKey> + recent: ModelKey[] + }>({ + model: {}, + recent: [], }) - } - const relative = (path: string) => path.replace(sync.data.path.directory + "/", "") - - const load = async (path: string) => { - const relativePath = relative(path) - sdk.file.read({ query: { path: relativePath } }).then((x) => { - setStore( - "node", - relativePath, - produce((draft) => { - draft.loaded = true - draft.content = x.data - }), - ) + const value = localStorage.getItem("model") + setStore("recent", JSON.parse(value ?? "[]")) + createEffect(() => { + localStorage.setItem("model", JSON.stringify(store.recent)) }) - } - const fetch = async (path: string) => { - const relativePath = relative(path) - const parent = relativePath.split("/").slice(0, -1).join("/") - if (parent) { - await list(parent) - } - } + const fallback = createMemo(() => { + if (store.recent.length) return store.recent[0] + const provider = sync.data.provider[0] + const model = Object.values(provider.models)[0] + return { modelID: model.id, providerID: provider.id } + }) - const init = async (path: string) => { - const relativePath = relative(path) - if (!store.node[relativePath]) await fetch(path) - if (store.node[relativePath].loaded) return - return load(relativePath) - } + const current = createMemo(() => { + const a = agent.current() + return find(store.model[agent.current().name]) ?? find(a.model ?? fallback()) + }) - const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { - const relativePath = relative(path) - if (!store.node[relativePath]) await fetch(path) - setStore("opened", (x) => { - if (x.includes(relativePath)) return x - return [ - ...opened() - .filter((x) => x.pinned) - .map((x) => x.path), - relativePath, - ] + const recent = createMemo(() => store.recent.map(find).filter(Boolean)) + + return { + list, + current, + recent, + set(model: ModelKey | undefined, options?: { recent?: boolean }) { + batch(() => { + setStore("model", agent.current().name, model ?? fallback()) + if (options?.recent && model) { + const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 5) uniq.pop() + setStore("recent", uniq) + } + }) + }, + } + })() + + const file = (() => { + const [store, setStore] = createStore<{ + node: Record<string, LocalFile> + opened: string[] + active?: string + }>({ + node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])), + opened: [], }) - setStore("active", relativePath) - context.addActive() - if (options?.pinned) setStore("node", path, "pinned", true) - if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) - if (store.node[relativePath].loaded) return - return load(relativePath) - } - const list = async (path: string) => { - return sdk.file.list({ query: { path: path + "/" } }).then((x) => { - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) + const active = createMemo(() => { + if (!store.active) return undefined + return store.node[store.active] }) - } + const opened = createMemo(() => store.opened.map((x) => store.node[x])) + const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) + const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) + + // createEffect((prev: FileStatus[]) => { + // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) + // for (const p of removed) { + // setStore( + // "node", + // p.path, + // produce((draft) => { + // draft.status = undefined + // draft.view = "raw" + // }), + // ) + // load(p.path) + // } + // for (const p of sync.data.changes) { + // if (store.node[p.path] === undefined) { + // fetch(p.path).then(() => { + // if (store.node[p.path] === undefined) return + // setStore("node", p.path, "status", p) + // }) + // } else { + // setStore("node", p.path, "status", p) + // } + // } + // return sync.data.changes + // }, sync.data.changes) + + const changed = (path: string) => { + const node = store.node[path] + if (node?.status) return true + const set = changeset() + if (set.has(path)) return true + for (const p of set) { + if (p.startsWith(path ? path + "/" : "")) return true + } + return false + } - const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!) + const resetNode = (path: string) => { + setStore("node", path, { + loaded: undefined, + pinned: undefined, + content: undefined, + selection: undefined, + scrollTop: undefined, + folded: undefined, + view: undefined, + selectedChange: undefined, + }) + } - const bus = useEvent() - bus.listen((event) => { - switch (event.type) { - case "message.part.updated": - const part = event.properties.part - if (part.type === "tool" && part.state.status === "completed") { - switch (part.tool) { - case "read": - break - case "edit": - // load(part.state.input["filePath"] as string) - break - default: - break - } - } - break - case "file.watcher.updated": - setTimeout(sync.load.changes, 1000) - const relativePath = relative(event.properties.file) - if (relativePath.startsWith(".git/")) return - load(relativePath) - break + const relative = (path: string) => path.replace(sync.data.path.directory + "/", "") + + const load = async (path: string) => { + const relativePath = relative(path) + sdk.client.file.read({ query: { path: relativePath } }).then((x) => { + setStore( + "node", + relativePath, + produce((draft) => { + draft.loaded = true + draft.content = x.data + }), + ) + }) } - }) - - return { - active, - opened, - node: (path: string) => store.node[path], - update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), - open, - load, - init, - close(path: string) { - setStore("opened", (opened) => opened.filter((x) => x !== path)) - if (store.active === path) { - const index = store.opened.findIndex((f) => f === path) - const previous = store.opened[Math.max(0, index - 1)] - setStore("active", previous) + + const fetch = async (path: string) => { + const relativePath = relative(path) + const parent = relativePath.split("/").slice(0, -1).join("/") + if (parent) { + await list(parent) } - resetNode(path) - }, - expand(path: string) { - setStore("node", path, "expanded", true) - if (store.node[path].loaded) return - setStore("node", path, "loaded", true) - list(path) - }, - collapse(path: string) { - setStore("node", path, "expanded", false) - }, - select(path: string, selection: TextSelection | undefined) { - setStore("node", path, "selection", selection) - }, - scroll(path: string, scrollTop: number) { - setStore("node", path, "scrollTop", scrollTop) - }, - move(path: string, to: number) { - const index = store.opened.findIndex((f) => f === path) - if (index === -1) return - setStore( - "opened", - produce((opened) => { - opened.splice(to, 0, opened.splice(index, 1)[0]) - }), - ) - setStore("node", path, "pinned", true) - }, - view(path: string): View { - const n = store.node[path] - return n && n.view ? n.view : "raw" - }, - setView(path: string, view: View) { - setStore("node", path, "view", view) - }, - unfold(path: string, key: string) { - setStore("node", path, "folded", (xs) => { - const a = xs ?? [] - if (a.includes(key)) return a - return [...a, key] + } + + const init = async (path: string) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + if (store.node[relativePath].loaded) return + return load(relativePath) + } + + const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + setStore("opened", (x) => { + if (x.includes(relativePath)) return x + return [ + ...opened() + .filter((x) => x.pinned) + .map((x) => x.path), + relativePath, + ] }) - }, - fold(path: string, key: string) { - setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key)) - }, - folded(path: string) { - const n = store.node[path] - return n && n.folded ? n.folded : [] - }, - changeIndex(path: string) { - return store.node[path]?.selectedChange - }, - setChangeIndex(path: string, index: number | undefined) { - setStore("node", path, "selectedChange", index) - }, - changes, - changed, - children(path: string) { - return Object.values(store.node).filter( - (x) => - x.path.startsWith(path) && - x.path !== path && - !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"), - ) - }, - search, - relative, - } - })() - - const layout = (() => { - type PaneState = { size: number; visible: boolean } - type LayoutState = { panes: Record<string, PaneState>; order: string[] } - type PaneDefault = number | { size: number; visible?: boolean } - - const [store, setStore] = createStore<Record<string, LayoutState>>({}) - - const raw = localStorage.getItem("layout") - if (raw) { - const data = JSON.parse(raw) - if (data && typeof data === "object" && !Array.isArray(data)) { - const first = Object.values(data)[0] as LayoutState - if (first && typeof first === "object" && "panes" in first) { - setStore(() => data as Record<string, LayoutState>) + setStore("active", relativePath) + context.addActive() + if (options?.pinned) setStore("node", path, "pinned", true) + if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) + if (store.node[relativePath].loaded) return + return load(relativePath) + } + + const list = async (path: string) => { + return sdk.client.file.list({ query: { path: path + "/" } }).then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + } + + const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!) + + sdk.event.listen((e) => { + const event = e.details + switch (event.type) { + case "message.part.updated": + const part = event.properties.part + if (part.type === "tool" && part.state.status === "completed") { + switch (part.tool) { + case "read": + break + case "edit": + // load(part.state.input["filePath"] as string) + break + default: + break + } + } + break + case "file.watcher.updated": + setTimeout(sync.load.changes, 1000) + const relativePath = relative(event.properties.file) + if (relativePath.startsWith(".git/")) return + load(relativePath) + break } + }) + + return { + active, + opened, + node: (path: string) => store.node[path], + update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), + open, + load, + init, + close(path: string) { + setStore("opened", (opened) => opened.filter((x) => x !== path)) + if (store.active === path) { + const index = store.opened.findIndex((f) => f === path) + const previous = store.opened[Math.max(0, index - 1)] + setStore("active", previous) + } + resetNode(path) + }, + expand(path: string) { + setStore("node", path, "expanded", true) + if (store.node[path].loaded) return + setStore("node", path, "loaded", true) + list(path) + }, + collapse(path: string) { + setStore("node", path, "expanded", false) + }, + select(path: string, selection: TextSelection | undefined) { + setStore("node", path, "selection", selection) + }, + scroll(path: string, scrollTop: number) { + setStore("node", path, "scrollTop", scrollTop) + }, + move(path: string, to: number) { + const index = store.opened.findIndex((f) => f === path) + if (index === -1) return + setStore( + "opened", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + setStore("node", path, "pinned", true) + }, + view(path: string): View { + const n = store.node[path] + return n && n.view ? n.view : "raw" + }, + setView(path: string, view: View) { + setStore("node", path, "view", view) + }, + unfold(path: string, key: string) { + setStore("node", path, "folded", (xs) => { + const a = xs ?? [] + if (a.includes(key)) return a + return [...a, key] + }) + }, + fold(path: string, key: string) { + setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key)) + }, + folded(path: string) { + const n = store.node[path] + return n && n.folded ? n.folded : [] + }, + changeIndex(path: string) { + return store.node[path]?.selectedChange + }, + setChangeIndex(path: string, index: number | undefined) { + setStore("node", path, "selectedChange", index) + }, + changes, + changed, + children(path: string) { + return Object.values(store.node).filter( + (x) => + x.path.startsWith(path) && + x.path !== path && + !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"), + ) + }, + search, + relative, } - } + })() - createEffect(() => { - localStorage.setItem("layout", JSON.stringify(store)) - }) + const session = (() => { + const [store, setStore] = createStore<{ + active?: string + activeMessage?: string + }>({}) - const normalize = (value: PaneDefault): PaneState => { - if (typeof value === "number") return { size: value, visible: true } - return { size: value.size, visible: value.visible ?? true } - } + const active = createMemo(() => { + if (!store.active) return undefined + return sync.session.get(store.active) + }) - const ensure = (id: string, defaults: Record<string, PaneDefault>) => { - const entries = Object.entries(defaults) - if (!entries.length) return - setStore(id, (current) => { - if (current) return current - return { - panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])), - order: entries.map(([pane]) => pane), - } + createEffect(() => { + if (!store.active) return + sync.session.sync(store.active) }) - for (const [pane, config] of entries) { - if (!store[id]?.panes[pane]) { - setStore(id, "panes", pane, () => normalize(config)) - } - if (!(store[id]?.order ?? []).includes(pane)) { - setStore(id, "order", (list) => [...list, pane]) + + const valid = (part: Part) => { + if (!part) return false + switch (part.type) { + case "step-start": + case "step-finish": + case "file": + case "patch": + return false + case "text": + return !part.synthetic && part.text.trim() + case "reasoning": + return part.text.trim() + case "tool": + switch (part.tool) { + case "todoread": + case "todowrite": + case "list": + case "grep": + return false + } + return true + default: + return true } } - } - const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => { - if (!store[id]) { - const value = normalize(fallback ?? { size: 0, visible: true }) - setStore(id, () => ({ - panes: { [pane]: value }, - order: [pane], - })) - return - } - if (!store[id].panes[pane]) { - const value = normalize(fallback ?? { size: 0, visible: true }) - setStore(id, "panes", pane, () => value) + const hasValidParts = (message: Message) => { + return sync.data.part[message.id]?.filter(valid).length > 0 } - if (!store[id].order.includes(pane)) { - setStore(id, "order", (list) => [...list, pane]) - } - } + // const hasTextPart = (message: Message) => { + // return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text") + // } + + const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : [])) + const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? []) + const userMessages = createMemo(() => + messages() + .filter((m) => m.role === "user") + .sort((a, b) => b.id.localeCompare(a.id)), + ) + + const working = createMemo(() => { + const last = messages()[messages().length - 1] + if (!last) return false + if (last.role === "user") return true + return !last.time.completed + }) - const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0 - const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false + const cost = createMemo(() => { + const total = pipe( + messages(), + sumBy((x) => (x.role === "assistant" ? x.cost : 0)), + ) + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(total) + }) - const setSize = (id: string, pane: string, value: number) => { - if (!store[id]?.panes[pane]) return - const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0 - setStore(id, "panes", pane, "size", next) - } + const last = createMemo(() => { + return messages().findLast((x) => x.role === "assistant") as AssistantMessage + }) - const setVisible = (id: string, pane: string, value: boolean) => { - if (!store[id]?.panes[pane]) return - setStore(id, "panes", pane, "visible", value) - } + const lastUserMessage = createMemo(() => { + return userMessages()?.at(0) + }) - const toggle = (id: string, pane: string) => { - setVisible(id, pane, !visible(id, pane)) - } + const activeMessage = createMemo(() => { + if (!store.active || !store.activeMessage) return lastUserMessage() + return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage) + }) - const show = (id: string, pane: string) => setVisible(id, pane, true) - const hide = (id: string, pane: string) => setVisible(id, pane, false) - const order = (id: string) => store[id]?.order ?? [] - - return { - ensure, - ensurePane, - size, - visible, - setSize, - setVisible, - toggle, - show, - hide, - order, - } - })() - - const session = (() => { - const [store, setStore] = createStore<{ - active?: string - }>({}) - - const active = createMemo(() => { - if (!store.active) return undefined - return sync.session.get(store.active) - }) - - createEffect(() => { - if (!store.active) return - sync.session.sync(store.active) - }) - - return { - active, - setActive(sessionId: string | undefined) { - setStore("active", sessionId) - }, - clearActive() { - setStore("active", undefined) - }, - } - })() - - const context = (() => { - const [store, setStore] = createStore<{ - activeTab: boolean - files: string[] - activeFile?: string - items: (ContextItem & { key: string })[] - }>({ - activeTab: true, - files: [], - items: [], - }) - const files = createMemo(() => store.files.map((x) => file.node(x))) - const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined)) - - return { - all() { - return store.items - }, - active() { - return store.activeTab ? file.active() : undefined - }, - addActive() { - setStore("activeTab", true) - }, - removeActive() { - setStore("activeTab", false) - }, - add(item: ContextItem) { - let key = item.type - switch (item.type) { - case "file": - key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}` - break - } - if (store.items.find((x) => x.key === key)) return - setStore("items", (x) => [...x, { key, ...item }]) - }, - remove(key: string) { - setStore("items", (x) => x.filter((x) => x.key !== key)) - }, - files, - openFile(path: string) { - file.init(path).then(() => { - setStore("files", (x) => [...x, path]) - setStore("activeFile", path) - }) - }, - activeFile, - setActiveFile(path: string | undefined) { - setStore("activeFile", path) - }, - } - })() - - const result = { - model, - agent, - file, - layout, - session, - context, - } - return result -} + const activeAssistantMessages = createMemo(() => { + if (!store.active || !activeMessage()) return [] + return sync.data.message[store.active]?.filter( + (m) => m.role === "assistant" && m.parentID == activeMessage()?.id, + ) + }) -type LocalContext = ReturnType<typeof init> + const activeAssistantMessagesWithText = createMemo(() => { + if (!store.active || !activeAssistantMessages()) return [] + return activeAssistantMessages()?.filter((m) => sync.data.part[m.id].find((p) => p.type === "text")) + }) -const ctx = createContext<LocalContext>() + const model = createMemo(() => { + if (!last()) return + const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] + return model + }) -export function LocalProvider(props: ParentProps) { - const value = init() - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} + const tokens = createMemo(() => { + if (!last()) return + const tokens = last().tokens + const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write + return new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + }).format(total) + }) -export function useLocal() { - const value = useContext(ctx) - if (!value) { - throw new Error("useLocal must be used within a LocalProvider") - } - return value -} + const context = createMemo(() => { + if (!last()) return + if (!model()?.limit.context) return 0 + const tokens = last().tokens + const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write + return Math.round((total / model()!.limit.context) * 100) + }) + + const getMessageText = (message: Message | Message[] | undefined): string => { + if (!message) return "" + if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ") + return sync.data.part[message.id] + ?.filter((p) => p.type === "text") + ?.filter((p) => !p.synthetic) + .map((p) => p.text) + .join(" ") + } + + return { + active, + activeMessage, + activeAssistantMessages, + activeAssistantMessagesWithText, + lastUserMessage, + cost, + last, + model, + tokens, + context, + messages, + messagesWithValidParts, + userMessages, + working, + getMessageText, + setActive(sessionId: string | undefined) { + setStore("active", sessionId) + setStore("activeMessage", undefined) + }, + clearActive() { + setStore("active", undefined) + setStore("activeMessage", undefined) + }, + setActiveMessage(messageId: string | undefined) { + setStore("activeMessage", messageId) + }, + clearActiveMessage() { + setStore("activeMessage", undefined) + }, + } + })() + + const context = (() => { + const [store, setStore] = createStore<{ + activeTab: boolean + files: string[] + activeFile?: string + items: (ContextItem & { key: string })[] + }>({ + activeTab: true, + files: [], + items: [], + }) + const files = createMemo(() => store.files.map((x) => file.node(x))) + const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined)) + + return { + all() { + return store.items + }, + active() { + return store.activeTab ? file.active() : undefined + }, + addActive() { + setStore("activeTab", true) + }, + removeActive() { + setStore("activeTab", false) + }, + add(item: ContextItem) { + let key = item.type + switch (item.type) { + case "file": + key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}` + break + } + if (store.items.find((x) => x.key === key)) return + setStore("items", (x) => [...x, { key, ...item }]) + }, + remove(key: string) { + setStore("items", (x) => x.filter((x) => x.key !== key)) + }, + files, + openFile(path: string) { + file.init(path).then(() => { + setStore("files", (x) => [...x, path]) + setStore("activeFile", path) + }) + }, + activeFile, + setActiveFile(path: string | undefined) { + setStore("activeFile", path) + }, + } + })() + + const result = { + model, + agent, + file, + session, + context, + } + return result + }, +}) diff --git a/packages/desktop/src/context/marked.tsx b/packages/desktop/src/context/marked.tsx index 550a0456a..18ce4280a 100644 --- a/packages/desktop/src/context/marked.tsx +++ b/packages/desktop/src/context/marked.tsx @@ -1,43 +1,30 @@ -import { createContext, useContext, type ParentProps } from "solid-js" -import { useShiki } from "@/context" import { marked } from "marked" import markedShiki from "marked-shiki" import { bundledLanguages, type BundledLanguage } from "shiki" -function init(highlighter: ReturnType<typeof useShiki>) { - return marked.use( - markedShiki({ - async highlight(code, lang) { - if (!(lang in bundledLanguages)) { - lang = "text" - } - if (!highlighter.getLoadedLanguages().includes(lang)) { - await highlighter.loadLanguage(lang as BundledLanguage) - } - return highlighter.codeToHtml(code, { - lang: lang || "text", - theme: "opencode", - tabindex: false, - }) - }, - }), - ) -} +import { createSimpleContext } from "./helper" +import { useShiki } from "./shiki" -type MarkedContext = ReturnType<typeof init> - -const ctx = createContext<MarkedContext>() - -export function MarkedProvider(props: ParentProps) { - const highlighter = useShiki() - const value = init(highlighter) - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} - -export function useMarked() { - const value = useContext(ctx) - if (!value) { - throw new Error("useMarked must be used within a MarkedProvider") - } - return value -} +export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({ + name: "Marked", + init: () => { + const highlighter = useShiki() + return marked.use( + markedShiki({ + async highlight(code, lang) { + if (!(lang in bundledLanguages)) { + lang = "text" + } + if (!highlighter.getLoadedLanguages().includes(lang)) { + await highlighter.loadLanguage(lang as BundledLanguage) + } + return highlighter.codeToHtml(code, { + lang: lang || "text", + theme: "opencode", + tabindex: false, + }) + }, + }), + ) + }, +}) diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx index 48595cf9d..7ffa30494 100644 --- a/packages/desktop/src/context/sdk.tsx +++ b/packages/desktop/src/context/sdk.tsx @@ -1,29 +1,37 @@ -import { createContext, useContext, type ParentProps } from "solid-js" -import { createOpencodeClient } from "@opencode-ai/sdk/client" +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" -const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" -const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" +export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ + name: "SDK", + init: (props: { url: string }) => { + const abort = new AbortController() + const sdk = createOpencodeClient({ + baseUrl: props.url, + signal: abort.signal, + fetch: (req) => { + // @ts-ignore + req.timeout = false + return fetch(req) + }, + }) -function init() { - const client = createOpencodeClient({ - baseUrl: `http://${host}:${port}`, - }) - return client -} + const emitter = createGlobalEmitter<{ + [key in Event["type"]]: Extract<Event, { type: key }> + }>() -type SDKContext = ReturnType<typeof init> + sdk.event.subscribe().then(async (events) => { + for await (const event of events.stream) { + console.log("event", event.type) + emitter.emit(event.type, event) + } + }) -const ctx = createContext<SDKContext>() + onCleanup(() => { + abort.abort() + }) -export function SDKProvider(props: ParentProps) { - const value = init() - return <ctx.Provider value={value}>{props.children}</ctx.Provider> -} - -export function useSDK() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSDK must be used within a SDKProvider") - } - return value -} + return { client: sdk, event: emitter } + }, +}) diff --git a/packages/desktop/src/context/shiki.tsx b/packages/desktop/src/context/shiki.tsx index 1930b907c..e70028419 100644 --- a/packages/desktop/src/context/shiki.tsx +++ b/packages/desktop/src/context/shiki.tsx @@ -1,5 +1,5 @@ +import { createSimpleContext } from "./helper" import { createHighlighter, type ThemeInput } from "shiki" -import { createContext, useContext, type ParentProps } from "solid-js" const theme: ThemeInput = { colors: { @@ -559,24 +559,14 @@ const theme: ThemeInput = { ], type: "dark", } - const highlighter = await createHighlighter({ themes: [theme], langs: [], }) -type ShikiContext = typeof highlighter - -const ctx = createContext<ShikiContext>() - -export function ShikiProvider(props: ParentProps) { - return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider> -} - -export function useShiki() { - const value = useContext(ctx) - if (!value) { - throw new Error("useShiki must be used within a ShikiProvider") - } - return value -} +export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({ + name: "Shiki", + init: () => { + return highlighter + }, +}) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 5ba6b1af2..0fea4a421 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -1,177 +1,162 @@ import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" -import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js" -import { useSDK, useEvent } from "@/context" +import { createMemo } from "solid-js" import { Binary } from "@/utils/binary" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" -function init() { - const [store, setStore] = createStore<{ - ready: boolean - provider: Provider[] - agent: Agent[] - project: Project - config: Config - path: Path - session: Session[] - 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: [], - message: {}, - part: {}, - node: [], - changes: [], - }) - - const bus = useEvent() - bus.listen((event) => { - 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 +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[] + message: { + [sessionID: string]: Message[] } - 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)) + 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: [], + message: {}, + part: {}, + node: [], + changes: [], + }) + + const sdk = useSDK() + sdk.event.listen((e) => { + 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 } - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - break - } - case "message.part.updated": { - const parts = store.part[event.properties.part.messageID] - if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + 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 } - const result = Binary.search(parts, event.properties.part.id, (p) => p.id) - if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + case "message.part.updated": { + const parts = store.part[event.properties.part.messageID] + if (!parts) { + setStore("part", event.properties.part.messageID, [event.properties.part]) + break + } + const result = Binary.search(parts, event.properties.part.id, (p) => p.id) + if (result.found) { + setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + break + } + setStore( + "part", + event.properties.part.messageID, + produce((draft) => { + draft.splice(result.index, 0, event.properties.part) + }), + ) break } - setStore( - "part", - event.properties.part.messageID, - produce((draft) => { - draft.splice(result.index, 0, event.properties.part) - }), - ) - break } - } - }) - - const sdk = useSDK() + }) - const load = { - project: () => sdk.project.current().then((x) => setStore("project", x.data!)), - provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), - path: () => sdk.path.get().then((x) => setStore("path", x.data!)), - agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), - session: () => - sdk.session.list().then((x) => - setStore( - "session", - (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), + const load = { + project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)), + provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)), + path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)), + agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), + session: () => + sdk.client.session.list().then((x) => + setStore( + "session", + (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), + ), ), - ), - config: () => sdk.config.get().then((x) => setStore("config", x.data!)), - changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), - node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), - } + config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)), + changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)), + node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), + } - Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) + Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) - const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) - const sanitize = (text: string) => text.replace(sanitizer(), "") - const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") + const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) + const sanitize = (text: string) => text.replace(sanitizer(), "") + const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - return { - data: store, - set: setStore, - session: { - get(sessionID: string) { - const match = Binary.search(store.session, sessionID, (s) => s.id) - if (match.found) return store.session[match.index] - return undefined + return { + data: store, + set: setStore, + get ready() { + return store.ready }, - async sync(sessionID: string) { - const [session, messages] = await Promise.all([ - sdk.session.get({ path: { id: sessionID } }), - sdk.session.messages({ path: { id: sessionID } }), - ]) - setStore( - produce((draft) => { - const match = Binary.search(draft.session, sessionID, (s) => s.id) - draft.session[match.index] = session.data! - draft.message[sessionID] = messages - .data!.map((x) => x.info) - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - for (const message of messages.data!) { - draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) - } - }), - ) + session: { + get(sessionID: string) { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + }, + async sync(sessionID: string) { + const [session, messages] = await Promise.all([ + sdk.client.session.get({ path: { id: sessionID } }), + sdk.client.session.messages({ path: { id: sessionID } }), + ]) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + draft.session[match.index] = session.data! + draft.message[sessionID] = messages + .data!.map((x) => x.info) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + for (const message of messages.data!) { + draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) + } + }), + ) + }, }, - }, - load, - absolute, - sanitize, - } -} - -type SyncContext = ReturnType<typeof init> - -const ctx = createContext<SyncContext>() - -export function SyncProvider(props: ParentProps) { - const value = init() - return ( - <Show when={value.data.ready}> - <ctx.Provider value={value}>{props.children}</ctx.Provider> - </Show> - ) -} - -export function useSync() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSync must be used within a SyncProvider") - } - return value -} + load, + absolute, + sanitize, + } + }, +}) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5fa840f0d..b1c57bd6f 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -3,10 +3,17 @@ import "@/index.css" import { render } from "solid-js/web" import { Router, Route } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" -import { EventProvider, SDKProvider, SyncProvider, LocalProvider, ShikiProvider, MarkedProvider } from "@/context" import { Fonts } from "@opencode-ai/ui" +import { ShikiProvider } from "./context/shiki" +import { MarkedProvider } from "./context/marked" +import { SDKProvider } from "./context/sdk" +import { SyncProvider } from "./context/sync" +import { LocalProvider } from "./context/local" import Home from "@/pages" +const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" +const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error( @@ -18,19 +25,17 @@ render( () => ( <ShikiProvider> <MarkedProvider> - <SDKProvider> - <EventProvider> - <SyncProvider> - <LocalProvider> - <MetaProvider> - <Fonts /> - <Router> - <Route path="/" component={Home} /> - </Router> - </MetaProvider> - </LocalProvider> - </SyncProvider> - </EventProvider> + <SDKProvider url={`http://${host}:${port}`}> + <SyncProvider> + <LocalProvider> + <MetaProvider> + <Fonts /> + <Router> + <Route path="/" component={Home} /> + </Router> + </MetaProvider> + </LocalProvider> + </SyncProvider> </SDKProvider> </MarkedProvider> </ShikiProvider> diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 2ddf7c18a..e7f94ad8d 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -1,15 +1,26 @@ -import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui" +import { Button, List, SelectDialog, Tooltip, IconButton, Tabs } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" -import EditorPane from "@/components/editor-pane" -import { For, onCleanup, onMount, Show } from "solid-js" -import { useSync, useSDK, useLocal } from "@/context" -import type { LocalFile, TextSelection } from "@/context/local" -import SessionTimeline from "@/components/session-timeline" +import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect } from "solid-js" +import { useLocal, type LocalFile, type TextSelection } from "@/context/local" import { createStore } from "solid-js/store" import { getDirectory, getFilename } from "@/utils" import { ContentPart, PromptInput } from "@/components/prompt-input" import { DateTime } from "luxon" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + createSortable, + useDragDropContext, +} from "@thisbeyond/solid-dnd" +import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { JSX } from "solid-js" +import { Code } from "@/components/code" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" export default function Page() { const local = useLocal() @@ -17,10 +28,18 @@ export default function Page() { const sdk = useSDK() const [store, setStore] = createStore({ clickTimer: undefined as number | undefined, - modelSelectOpen: false, fileSelectOpen: false, }) let inputRef!: HTMLDivElement + let messageScrollElement!: HTMLDivElement + const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined) + + createEffect(() => { + if (!local.session.activeMessage()) return + if (!messageScrollElement) return + const element = messageScrollElement.querySelector(`[data-message="${local.session.activeMessage()?.id}"]`) + element?.scrollIntoView({ block: "start", behavior: "instant" }) + }) const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control" @@ -101,11 +120,50 @@ export default function Page() { } } + const navigateChange = (dir: 1 | -1) => { + const active = local.file.active() + if (!active) return + const current = local.file.changeIndex(active.path) + const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir + local.file.setChangeIndex(active.path, next) + } + + const handleTabChange = (path: string) => { + if (path === "chat" || path === "review") return + local.file.open(path) + } + + const handleTabClose = (file: LocalFile) => { + local.file.close(file.path) + } + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setActiveItem(id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (draggable && droppable) { + const currentFiles = local.file.opened().map((file) => file.path) + const fromIndex = currentFiles.indexOf(draggable.id.toString()) + const toIndex = currentFiles.indexOf(droppable.id.toString()) + if (fromIndex !== toIndex) { + local.file.move(draggable.id.toString(), toIndex) + } + } + } + + const handleDragEnd = () => { + setActiveItem(undefined) + } + const handlePromptSubmit = async (parts: ContentPart[]) => { const existingSession = local.session.active() let session = existingSession if (!session) { - const created = await sdk.session.create() + const created = await sdk.client.session.create() session = created.data ?? undefined } if (!session) return @@ -187,7 +245,7 @@ export default function Page() { } }) - await sdk.session.prompt({ + await sdk.client.session.prompt({ path: { id: session.id }, body: { agent: local.agent.current()!.name, @@ -211,6 +269,93 @@ export default function Page() { inputRef?.focus() } + const TabVisual = (props: { file: LocalFile }): JSX.Element => { + return ( + <div class="flex items-center gap-x-1.5"> + <FileIcon node={props.file} class="_grayscale-100" /> + <span + classList={{ + "text-14-medium": true, + "text-primary": !!props.file.status?.status, + italic: !props.file.pinned, + }} + > + {props.file.name} + </span> + <span class="hidden opacity-70"> + <Switch> + <Match when={props.file.status?.status === "modified"}> + <span class="text-primary">M</span> + </Match> + <Match when={props.file.status?.status === "added"}> + <span class="text-success">A</span> + </Match> + <Match when={props.file.status?.status === "deleted"}> + <span class="text-error">D</span> + </Match> + </Switch> + </span> + </div> + ) + } + + const SortableTab = (props: { + file: LocalFile + onTabClick: (file: LocalFile) => void + onTabClose: (file: LocalFile) => void + }): JSX.Element => { + const sortable = createSortable(props.file.path) + + return ( + // @ts-ignore + <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> + <Tooltip value={props.file.path} placement="bottom" class="h-full"> + <div class="relative h-full"> + <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}> + <TabVisual file={props.file} /> + </Tabs.Trigger> + <IconButton + icon="close" + class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100" + variant="ghost" + onClick={() => props.onTabClose(props.file)} + /> + </div> + </Tooltip> + </div> + ) + } + + const ConstrainDragYAxis = (): JSX.Element => { + const context = useDragDropContext() + if (!context) return <></> + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer: Transformer = { + id: "constrain-y-axis", + order: 100, + callback: (transform) => ({ ...transform, y: 0 }), + } + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return <></> + } + + const getDraggableId = (event: unknown): string | undefined => { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined + } + return ( <div class="relative h-screen flex flex-col"> <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header> @@ -253,22 +398,203 @@ export default function Page() { </List> </div> </div> - <div class="relative grid grid-cols-2 bg-background-base w-full"> - <div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center"> - <Show when={local.session.active()}> - {(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />} - </Show> - </div> - <div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar"> - <Show when={local.session.active()}> - <EditorPane onFileClick={handleFileClick} /> - </Show> - </div> + <div class="relative bg-background-base w-full h-full overflow-x-hidden"> + <DragDropProvider + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <Tabs onChange={handleTabChange}> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List> + <Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center"> + <div>Chat</div> + <Show when={local.session.active()}> + <div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong"> + {local.session.context()}% + </div> + </Show> + </Tabs.Trigger> + {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */} + <SortableProvider ids={local.file.opened().map((file) => file.path)}> + <For each={local.file.opened()}> + {(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />} + </For> + </SortableProvider> + <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3"> + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + onClick={() => setStore("fileSelectOpen", true)} + /> + </div> + </Tabs.List> + <div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40"> + <Show when={local.file.active() && local.file.active()!.content?.diff}> + {(() => { + const activeFile = local.file.active()! + const view = local.file.view(activeFile.path) + return ( + <div class="flex items-center gap-1"> + <Show when={view !== "raw"}> + <div class="mr-1 flex items-center gap-1"> + <Tooltip value="Previous change" placement="bottom"> + <IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} /> + </Tooltip> + <Tooltip value="Next change" placement="bottom"> + <IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} /> + </Tooltip> + </div> + </Show> + <Tooltip value="Raw" placement="bottom"> + <IconButton + icon="file-text" + variant="ghost" + classList={{ + "text-text": view === "raw", + "text-text-muted/70": view !== "raw", + "bg-background-element": view === "raw", + }} + onClick={() => local.file.setView(activeFile.path, "raw")} + /> + </Tooltip> + <Tooltip value="Unified diff" placement="bottom"> + <IconButton + icon="checklist" + variant="ghost" + classList={{ + "text-text": view === "diff-unified", + "text-text-muted/70": view !== "diff-unified", + "bg-background-element": view === "diff-unified", + }} + onClick={() => local.file.setView(activeFile.path, "diff-unified")} + /> + </Tooltip> + <Tooltip value="Split diff" placement="bottom"> + <IconButton + icon="columns" + variant="ghost" + classList={{ + "text-text": view === "diff-split", + "text-text-muted/70": view !== "diff-split", + "bg-background-element": view === "diff-split", + }} + onClick={() => local.file.setView(activeFile.path, "diff-split")} + /> + </Tooltip> + </div> + ) + })()} + </Show> + </div> + </div> + <Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0"> + <Show when={local.session.active()} fallback={<div>No active session</div>}> + {(activeSession) => ( + <div class="p-6 pt-12 max-w-[904px] mx-auto flex flex-col flex-1 min-h-0"> + <div class="py-3 flex flex-col flex-1 min-h-0"> + <div class="flex items-start gap-8 flex-1 min-h-0"> + <ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1"> + <For each={local.session.userMessages()}> + {(message) => ( + <li + class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default" + onClick={() => local.session.setActiveMessage(message.id)} + > + <div class="w-[18px] shrink-0"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none"> + <g> + <rect x="0" width="2" height="12" rx="1" fill="#CFCECD" /> + <rect x="4" width="2" height="12" rx="1" fill="#CFCECD" /> + <rect x="8" width="2" height="12" rx="1" fill="#CFCECD" /> + <rect x="12" width="2" height="12" rx="1" fill="#CFCECD" /> + <rect x="16" width="2" height="12" rx="1" fill="#CFCECD" /> + </g> + </svg> + </div> + <div + data-active={local.session.activeMessage()?.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, + }} + > + {local.session.getMessageText(message)} + </div> + </li> + )} + </For> + </ul> + <div + ref={messageScrollElement} + class="grow min-w-0 h-full overflow-y-auto no-scrollbar snap-y" + > + <div class="flex flex-col items-start gap-50 pb-[800px]"> + <For each={local.session.userMessages()}> + {(message) => ( + <div + data-message={message.id} + class="flex flex-col items-start self-stretch gap-8 pt-1.5 snap-start" + > + <div class="flex flex-col items-start gap-4"> + <div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0"> + {local.session.getMessageText(message)} + </div> + <div class="text-14-regular text-text-base"> + {message.summary?.text || + local.session.getMessageText(local.session.activeAssistantMessagesWithText())} + </div> + </div> + <div class=""></div> + </div> + )} + </For> + </div> + </div> + </div> + </div> + </div> + )} + </Show> + </Tabs.Content> + {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */} + <For each={local.file.opened()}> + {(file) => ( + <Tabs.Content value={file.path} class="select-text"> + {(() => { + const view = local.file.view(file.path) + const showRaw = view === "raw" || !file.content?.diff + const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") + return <Code path={file.path} code={code} class="[&_code]:pb-60" /> + })()} + </Tabs.Content> + )} + </For> + </Tabs> + <DragOverlay> + {(() => { + const id = activeItem() + if (!id) return null + const draggedFile = local.file.node(id) + if (!draggedFile) return null + return ( + <div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent"> + <TabVisual file={draggedFile} /> + </div> + ) + })()} + </DragOverlay> + </DragDropProvider> <div classList={{ - "absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true, - "bottom-8": !!local.session.active(), - "bottom-1/2 translate-y-1/2": !local.session.active(), + "absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true, + "bottom-8": true, + // "bottom-8": !!local.session.active(), + // "bottom-1/2 translate-y-1/2": !local.session.active(), }} > <PromptInput diff --git a/packages/ui/src/components/icon-button.tsx b/packages/ui/src/components/icon-button.tsx index f483f92a7..abc82609b 100644 --- a/packages/ui/src/components/icon-button.tsx +++ b/packages/ui/src/components/icon-button.tsx @@ -5,11 +5,12 @@ import { Icon, IconProps } from "./icon" export interface IconButtonProps { icon: IconProps["name"] size?: "normal" | "large" + iconSize?: IconProps["size"] variant?: "primary" | "secondary" | "ghost" } export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { - const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"]) + const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"]) return ( <Kobalte {...rest} @@ -21,7 +22,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { [split.class ?? ""]: !!split.class, }} > - <Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} /> + <Icon data-slot="icon" name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} /> </Kobalte> ) } diff --git a/packages/ui/src/components/icon.css b/packages/ui/src/components/icon.css index 59c644b70..7f1f18339 100644 --- a/packages/ui/src/components/icon.css +++ b/packages/ui/src/components/icon.css @@ -18,8 +18,8 @@ } &[data-size="large"] { - width: 32px; - height: 32px; + width: 24px; + height: 24px; } [data-slot="svg"] { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 70d7b03e1..29057fc87 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -3,14 +3,11 @@ height: 100%; display: flex; flex-direction: column; - border-width: 1px; - border-style: solid; - border-radius: var(--radius-sm); - border-color: var(--border-weak-base); background-color: var(--background-stronger); overflow: clip; [data-slot="list"] { + height: 40px; width: 100%; position: relative; display: flex; @@ -32,7 +29,6 @@ height: 100%; border-bottom: 1px solid var(--border-weak-base); background-color: var(--background-base); - border-top-right-radius: var(--radius-sm); } &:empty::after { @@ -42,19 +38,25 @@ [data-slot="trigger"] { position: relative; - height: 36px; - padding: 8px 12px; + height: 100%; + padding: 8px 24px; display: flex; align-items: center; - font-size: var(--text-sm); + color: var(--text-base); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; font-weight: var(--font-weight-medium); - color: var(--text-weak); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); white-space: nowrap; flex-shrink: 0; border-bottom: 1px solid var(--border-weak-base); border-right: 1px solid var(--border-weak-base); - background-color: var(--background-weak); + background-color: var(--background-base); transition: background-color 0.15s ease, color 0.15s ease; @@ -68,7 +70,7 @@ box-shadow: 0 0 0 2px var(--border-focus); } &[data-selected] { - color: var(--text-base); + color: var(--text-strong); background-color: transparent; border-bottom-color: transparent; } |
