wip: desktop work

This commit is contained in:
Adam
2025-10-23 15:27:31 -05:00
parent 35dec0649d
commit 3eb2db98ed
19 changed files with 1187 additions and 1115 deletions

View File

@@ -1,8 +1,8 @@
import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki" import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js" import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
import { useLocal, useShiki } from "@/context" import { useLocal, type TextSelection } from "@/context/local"
import type { TextSelection } from "@/context/local"
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils" import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
import { useShiki } from "@/context/shiki"
type DefinedSelection = Exclude<TextSelection, undefined> type DefinedSelection = Exclude<TextSelection, undefined>

View File

@@ -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
}

View File

@@ -1,5 +1,4 @@
import { useLocal } from "@/context" import { useLocal, type LocalFile } from "@/context/local"
import type { LocalFile } from "@/context/local"
import { Tooltip } from "@opencode-ai/ui" import { Tooltip } from "@opencode-ai/ui"
import { Collapsible, FileIcon } from "@/ui" import { Collapsible, FileIcon } from "@/ui"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js" import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"

View File

@@ -1,4 +1,4 @@
import { useMarked } from "@/context" import { useMarked } from "@/context/marked"
import { createResource } from "solid-js" import { createResource } from "solid-js"
function strip(text: string): string { function strip(text: string): string {

View File

@@ -1,12 +1,11 @@
import { useLocal } from "@/context" import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
import { useFilteredList } from "@opencode-ai/ui/hooks" 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 { createStore } from "solid-js/store"
import { FileIcon } from "@/ui" import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { TextSelection } from "@/context/local" import { TextSelection, useLocal } from "@/context/local"
import { DateTime } from "luxon" import { DateTime } from "luxon"
interface PartBase { interface PartBase {
@@ -245,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
return ( 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}> <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"> <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()}> <For each={flat()}>

View File

@@ -1,4 +1,3 @@
import { useLocal, useSync } from "@/context"
import { Icon, Tooltip } from "@opencode-ai/ui" import { Icon, Tooltip } from "@opencode-ai/ui"
import { Collapsible } from "@/ui" import { Collapsible } from "@/ui"
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk" 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 { createScrollPosition } from "@solid-primitives/scroll"
import { ProgressCircle } from "./progress-circle" import { ProgressCircle } from "./progress-circle"
import { pipe, sumBy } from "remeda" import { pipe, sumBy } from "remeda"
import { useSync } from "@/context/sync"
import { useLocal } from "@/context/local"
function Part(props: ParentProps & ComponentProps<"div">) { function Part(props: ParentProps & ComponentProps<"div">) {
const [local, others] = splitProps(props, ["class", "classList", "children"]) 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, [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"> <div class="flex items-center gap-6">
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5"> <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
<Show when={context()}> <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 class="text-14-regular text-text-strong text-right">{cost()}</div>
</div> </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()}> <For each={messagesWithValidParts()}>
{(message) => ( {(message) => (
<div <div

View File

@@ -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
}

View File

@@ -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
},
}
}

View File

@@ -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"

View File

@@ -1,8 +1,19 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js" import { batch, createEffect, createMemo } from "solid-js"
import { uniqueBy } from "remeda" import { pipe, sumBy, uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk" import type {
import { useSDK, useEvent, useSync } from "@/context" 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 & export type LocalFile = FileNode &
Partial<{ Partial<{
@@ -28,7 +39,9 @@ export type ModelKey = { providerID: string; modelID: string }
export type FileContext = { type: "file"; path: string; selection?: TextSelection } export type FileContext = { type: "file"; path: string; selection?: TextSelection }
export type ContextItem = FileContext export type ContextItem = FileContext
function init() { export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
@@ -185,7 +198,7 @@ function init() {
const load = async (path: string) => { const load = async (path: string) => {
const relativePath = relative(path) const relativePath = relative(path)
sdk.file.read({ query: { path: relativePath } }).then((x) => { sdk.client.file.read({ query: { path: relativePath } }).then((x) => {
setStore( setStore(
"node", "node",
relativePath, relativePath,
@@ -233,7 +246,7 @@ function init() {
} }
const list = async (path: string) => { const list = async (path: string) => {
return sdk.file.list({ query: { path: path + "/" } }).then((x) => { return sdk.client.file.list({ query: { path: path + "/" } }).then((x) => {
setStore( setStore(
"node", "node",
produce((draft) => { produce((draft) => {
@@ -246,10 +259,10 @@ function init() {
}) })
} }
const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!) const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
const bus = useEvent() sdk.event.listen((e) => {
bus.listen((event) => { const event = e.details
switch (event.type) { switch (event.type) {
case "message.part.updated": case "message.part.updated":
const part = event.properties.part const part = event.properties.part
@@ -359,110 +372,10 @@ function init() {
} }
})() })()
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>)
}
}
}
createEffect(() => {
localStorage.setItem("layout", JSON.stringify(store))
})
const normalize = (value: PaneDefault): PaneState => {
if (typeof value === "number") return { size: value, visible: true }
return { size: value.size, visible: value.visible ?? true }
}
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),
}
})
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 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)
}
if (!store[id].order.includes(pane)) {
setStore(id, "order", (list) => [...list, pane])
}
}
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 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 setVisible = (id: string, pane: string, value: boolean) => {
if (!store[id]?.panes[pane]) return
setStore(id, "panes", pane, "visible", value)
}
const toggle = (id: string, pane: string) => {
setVisible(id, pane, !visible(id, pane))
}
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 session = (() => {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
active?: string active?: string
activeMessage?: string
}>({}) }>({})
const active = createMemo(() => { const active = createMemo(() => {
@@ -475,13 +388,153 @@ function init() {
sync.session.sync(store.active) sync.session.sync(store.active)
}) })
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 hasValidParts = (message: Message) => {
return sync.data.part[message.id]?.filter(valid).length > 0
}
// 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 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 last = createMemo(() => {
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
})
const lastUserMessage = createMemo(() => {
return userMessages()?.at(0)
})
const activeMessage = createMemo(() => {
if (!store.active || !store.activeMessage) return lastUserMessage()
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
})
const activeAssistantMessages = createMemo(() => {
if (!store.active || !activeMessage()) return []
return sync.data.message[store.active]?.filter(
(m) => m.role === "assistant" && m.parentID == activeMessage()?.id,
)
})
const activeAssistantMessagesWithText = createMemo(() => {
if (!store.active || !activeAssistantMessages()) return []
return activeAssistantMessages()?.filter((m) => sync.data.part[m.id].find((p) => p.type === "text"))
})
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
return model
})
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)
})
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 { return {
active, active,
activeMessage,
activeAssistantMessages,
activeAssistantMessagesWithText,
lastUserMessage,
cost,
last,
model,
tokens,
context,
messages,
messagesWithValidParts,
userMessages,
working,
getMessageText,
setActive(sessionId: string | undefined) { setActive(sessionId: string | undefined) {
setStore("active", sessionId) setStore("active", sessionId)
setStore("activeMessage", undefined)
}, },
clearActive() { clearActive() {
setStore("active", undefined) setStore("active", undefined)
setStore("activeMessage", undefined)
},
setActiveMessage(messageId: string | undefined) {
setStore("activeMessage", messageId)
},
clearActiveMessage() {
setStore("activeMessage", undefined)
}, },
} }
})() })()
@@ -544,26 +597,9 @@ function init() {
model, model,
agent, agent,
file, file,
layout,
session, session,
context, context,
} }
return result return result
} },
})
type LocalContext = ReturnType<typeof init>
const ctx = createContext<LocalContext>()
export function LocalProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useLocal() {
const value = useContext(ctx)
if (!value) {
throw new Error("useLocal must be used within a LocalProvider")
}
return value
}

View File

@@ -1,10 +1,14 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { useShiki } from "@/context"
import { marked } from "marked" import { marked } from "marked"
import markedShiki from "marked-shiki" import markedShiki from "marked-shiki"
import { bundledLanguages, type BundledLanguage } from "shiki" import { bundledLanguages, type BundledLanguage } from "shiki"
function init(highlighter: ReturnType<typeof useShiki>) { import { createSimpleContext } from "./helper"
import { useShiki } from "./shiki"
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
name: "Marked",
init: () => {
const highlighter = useShiki()
return marked.use( return marked.use(
markedShiki({ markedShiki({
async highlight(code, lang) { async highlight(code, lang) {
@@ -22,22 +26,5 @@ function init(highlighter: ReturnType<typeof useShiki>) {
}, },
}), }),
) )
} },
})
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
}

View File

@@ -1,29 +1,37 @@
import { createContext, useContext, type ParentProps } from "solid-js" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createOpencodeClient } 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" export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" name: "SDK",
init: (props: { url: string }) => {
function init() { const abort = new AbortController()
const client = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${host}:${port}`, baseUrl: props.url,
signal: abort.signal,
fetch: (req) => {
// @ts-ignore
req.timeout = false
return fetch(req)
},
}) })
return client
}
type SDKContext = ReturnType<typeof init> const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
const ctx = createContext<SDKContext>() sdk.event.subscribe().then(async (events) => {
for await (const event of events.stream) {
console.log("event", event.type)
emitter.emit(event.type, event)
}
})
export function SDKProvider(props: ParentProps) { onCleanup(() => {
const value = init() abort.abort()
return <ctx.Provider value={value}>{props.children}</ctx.Provider> })
}
export function useSDK() { return { client: sdk, event: emitter }
const value = useContext(ctx) },
if (!value) { })
throw new Error("useSDK must be used within a SDKProvider")
}
return value
}

View File

@@ -1,5 +1,5 @@
import { createSimpleContext } from "./helper"
import { createHighlighter, type ThemeInput } from "shiki" import { createHighlighter, type ThemeInput } from "shiki"
import { createContext, useContext, type ParentProps } from "solid-js"
const theme: ThemeInput = { const theme: ThemeInput = {
colors: { colors: {
@@ -559,24 +559,14 @@ const theme: ThemeInput = {
], ],
type: "dark", type: "dark",
} }
const highlighter = await createHighlighter({ const highlighter = await createHighlighter({
themes: [theme], themes: [theme],
langs: [], langs: [],
}) })
type ShikiContext = typeof highlighter export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({
name: "Shiki",
const ctx = createContext<ShikiContext>() init: () => {
return highlighter
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
}

View File

@@ -1,10 +1,13 @@
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk" 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 { createStore, produce, reconcile } from "solid-js/store"
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js" import { createMemo } from "solid-js"
import { useSDK, useEvent } from "@/context"
import { Binary } from "@/utils/binary" import { Binary } from "@/utils/binary"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function init() { export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
ready: boolean ready: boolean
provider: Provider[] provider: Provider[]
@@ -35,8 +38,9 @@ function init() {
changes: [], changes: [],
}) })
const bus = useEvent() const sdk = useSDK()
bus.listen((event) => { sdk.event.listen((e) => {
const event = e.details
switch (event.type) { switch (event.type) {
case "session.updated": { case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
@@ -95,23 +99,21 @@ function init() {
} }
}) })
const sdk = useSDK()
const load = { const load = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!)), project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)), path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () => session: () =>
sdk.session.list().then((x) => sdk.client.session.list().then((x) =>
setStore( setStore(
"session", "session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
), ),
), ),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)), config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", 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))
@@ -123,6 +125,9 @@ function init() {
return { return {
data: store, data: store,
set: setStore, set: setStore,
get ready() {
return store.ready
},
session: { session: {
get(sessionID: string) { get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id) const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -131,8 +136,8 @@ function init() {
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
const [session, messages] = await Promise.all([ const [session, messages] = await Promise.all([
sdk.session.get({ path: { id: sessionID } }), sdk.client.session.get({ path: { id: sessionID } }),
sdk.session.messages({ path: { id: sessionID } }), sdk.client.session.messages({ path: { id: sessionID } }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -153,25 +158,5 @@ function init() {
absolute, absolute,
sanitize, 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
}

View File

@@ -3,10 +3,17 @@ import "@/index.css"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router" import { Router, Route } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { EventProvider, SDKProvider, SyncProvider, LocalProvider, ShikiProvider, MarkedProvider } from "@/context"
import { Fonts } from "@opencode-ai/ui" 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" 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") const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) { if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error( throw new Error(
@@ -18,8 +25,7 @@ render(
() => ( () => (
<ShikiProvider> <ShikiProvider>
<MarkedProvider> <MarkedProvider>
<SDKProvider> <SDKProvider url={`http://${host}:${port}`}>
<EventProvider>
<SyncProvider> <SyncProvider>
<LocalProvider> <LocalProvider>
<MetaProvider> <MetaProvider>
@@ -30,7 +36,6 @@ render(
</MetaProvider> </MetaProvider>
</LocalProvider> </LocalProvider>
</SyncProvider> </SyncProvider>
</EventProvider>
</SDKProvider> </SDKProvider>
</MarkedProvider> </MarkedProvider>
</ShikiProvider> </ShikiProvider>

View File

@@ -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 { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import EditorPane from "@/components/editor-pane" import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect } from "solid-js"
import { For, onCleanup, onMount, Show } from "solid-js" import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
import { useSync, useSDK, useLocal } from "@/context"
import type { LocalFile, TextSelection } from "@/context/local"
import SessionTimeline from "@/components/session-timeline"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils" import { getDirectory, getFilename } from "@/utils"
import { ContentPart, PromptInput } from "@/components/prompt-input" import { ContentPart, PromptInput } from "@/components/prompt-input"
import { DateTime } from "luxon" 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() { export default function Page() {
const local = useLocal() const local = useLocal()
@@ -17,10 +28,18 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
clickTimer: undefined as number | undefined, clickTimer: undefined as number | undefined,
modelSelectOpen: false,
fileSelectOpen: false, fileSelectOpen: false,
}) })
let inputRef!: HTMLDivElement 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" 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 handlePromptSubmit = async (parts: ContentPart[]) => {
const existingSession = local.session.active() const existingSession = local.session.active()
let session = existingSession let session = existingSession
if (!session) { if (!session) {
const created = await sdk.session.create() const created = await sdk.client.session.create()
session = created.data ?? undefined session = created.data ?? undefined
} }
if (!session) return if (!session) return
@@ -187,7 +245,7 @@ export default function Page() {
} }
}) })
await sdk.session.prompt({ await sdk.client.session.prompt({
path: { id: session.id }, path: { id: session.id },
body: { body: {
agent: local.agent.current()!.name, agent: local.agent.current()!.name,
@@ -211,6 +269,93 @@ export default function Page() {
inputRef?.focus() 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 ( return (
<div class="relative h-screen flex flex-col"> <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> <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> </List>
</div> </div>
</div> </div>
<div class="relative grid grid-cols-2 bg-background-base w-full"> <div class="relative bg-background-base w-full h-full overflow-x-hidden">
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center"> <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()}> <Show when={local.session.active()}>
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />} <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> </Show>
</div> </div>
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar"> </div>
<Show when={local.session.active()}> <Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
<EditorPane onFileClick={handleFileClick} /> <Show when={local.session.active()} fallback={<div>No active session</div>}>
</Show> {(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>
<div <div
data-active={local.session.activeMessage()?.id === message.id}
classList={{ classList={{
"absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true, "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"bottom-8": !!local.session.active(), "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
"bottom-1/2 translate-y-1/2": !local.session.active(), }}
>
{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-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 <PromptInput

View File

@@ -5,11 +5,12 @@ import { Icon, IconProps } from "./icon"
export interface IconButtonProps { export interface IconButtonProps {
icon: IconProps["name"] icon: IconProps["name"]
size?: "normal" | "large" size?: "normal" | "large"
iconSize?: IconProps["size"]
variant?: "primary" | "secondary" | "ghost" variant?: "primary" | "secondary" | "ghost"
} }
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { 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 ( return (
<Kobalte <Kobalte
{...rest} {...rest}
@@ -21,7 +22,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
[split.class ?? ""]: !!split.class, [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> </Kobalte>
) )
} }

View File

@@ -18,8 +18,8 @@
} }
&[data-size="large"] { &[data-size="large"] {
width: 32px; width: 24px;
height: 32px; height: 24px;
} }
[data-slot="svg"] { [data-slot="svg"] {

View File

@@ -3,14 +3,11 @@
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; 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); background-color: var(--background-stronger);
overflow: clip; overflow: clip;
[data-slot="list"] { [data-slot="list"] {
height: 40px;
width: 100%; width: 100%;
position: relative; position: relative;
display: flex; display: flex;
@@ -32,7 +29,6 @@
height: 100%; height: 100%;
border-bottom: 1px solid var(--border-weak-base); border-bottom: 1px solid var(--border-weak-base);
background-color: var(--background-base); background-color: var(--background-base);
border-top-right-radius: var(--radius-sm);
} }
&:empty::after { &:empty::after {
@@ -42,19 +38,25 @@
[data-slot="trigger"] { [data-slot="trigger"] {
position: relative; position: relative;
height: 36px; height: 100%;
padding: 8px 12px; padding: 8px 24px;
display: flex; display: flex;
align-items: center; 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); 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; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base); border-bottom: 1px solid var(--border-weak-base);
border-right: 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: transition:
background-color 0.15s ease, background-color 0.15s ease,
color 0.15s ease; color 0.15s ease;
@@ -68,7 +70,7 @@
box-shadow: 0 0 0 2px var(--border-focus); box-shadow: 0 0 0 2px var(--border-focus);
} }
&[data-selected] { &[data-selected] {
color: var(--text-base); color: var(--text-strong);
background-color: transparent; background-color: transparent;
border-bottom-color: transparent; border-bottom-color: transparent;
} }