summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-23 15:27:31 -0500
committerAdam <[email protected]>2025-10-24 12:16:32 -0500
commit3eb2db98ed0a9c266e1bf00544e460cb0633b368 (patch)
treeeb04fea563b3a3b74a3d89ca9500e92cf4b908c8
parent35dec0649db8f46bffd7121af9cd301668e69e8c (diff)
downloadopencode-3eb2db98ed0a9c266e1bf00544e460cb0633b368.tar.gz
opencode-3eb2db98ed0a9c266e1bf00544e460cb0633b368.zip
wip: desktop work
-rw-r--r--packages/desktop/src/components/code.tsx4
-rw-r--r--packages/desktop/src/components/editor-pane.tsx252
-rw-r--r--packages/desktop/src/components/file-tree.tsx3
-rw-r--r--packages/desktop/src/components/markdown.tsx2
-rw-r--r--packages/desktop/src/components/prompt-input.tsx9
-rw-r--r--packages/desktop/src/components/session-timeline.tsx7
-rw-r--r--packages/desktop/src/context/event.tsx34
-rw-r--r--packages/desktop/src/context/helper.tsx25
-rw-r--r--packages/desktop/src/context/index.ts6
-rw-r--r--packages/desktop/src/context/local.tsx1054
-rw-r--r--packages/desktop/src/context/marked.tsx63
-rw-r--r--packages/desktop/src/context/sdk.tsx56
-rw-r--r--packages/desktop/src/context/shiki.tsx24
-rw-r--r--packages/desktop/src/context/sync.tsx301
-rw-r--r--packages/desktop/src/index.tsx33
-rw-r--r--packages/desktop/src/pages/index.tsx372
-rw-r--r--packages/ui/src/components/icon-button.tsx5
-rw-r--r--packages/ui/src/components/icon.css4
-rw-r--r--packages/ui/src/components/tabs.css24
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;
}