mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 09:44:21 +01:00
wip: desktop work
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
25
packages/desktop/src/context/helper.tsx
Normal file
25
packages/desktop/src/context/helper.tsx
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
export function SDKProvider(props: ParentProps) {
|
console.log("event", event.type)
|
||||||
const value = init()
|
emitter.emit(event.type, event)
|
||||||
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
|
})
|
||||||
}
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { client: sdk, event: emitter }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&[data-size="large"] {
|
&[data-size="large"] {
|
||||||
width: 32px;
|
width: 24px;
|
||||||
height: 32px;
|
height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="svg"] {
|
[data-slot="svg"] {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user