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"

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,30 @@
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"
return marked.use( import { useShiki } from "./shiki"
markedShiki({
async highlight(code, lang) {
if (!(lang in bundledLanguages)) {
lang = "text"
}
if (!highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage)
}
return highlighter.codeToHtml(code, {
lang: lang || "text",
theme: "opencode",
tabindex: false,
})
},
}),
)
}
type MarkedContext = ReturnType<typeof init> export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
name: "Marked",
const ctx = createContext<MarkedContext>() init: () => {
const highlighter = useShiki()
export function MarkedProvider(props: ParentProps) { return marked.use(
const highlighter = useShiki() markedShiki({
const value = init(highlighter) async highlight(code, lang) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider> if (!(lang in bundledLanguages)) {
} lang = "text"
}
export function useMarked() { if (!highlighter.getLoadedLanguages().includes(lang)) {
const value = useContext(ctx) await highlighter.loadLanguage(lang as BundledLanguage)
if (!value) { }
throw new Error("useMarked must be used within a MarkedProvider") return highlighter.codeToHtml(code, {
} lang: lang || "text",
return value theme: "opencode",
} tabindex: false,
})
},
}),
)
},
})

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

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,177 +1,162 @@
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({
const [store, setStore] = createStore<{ name: "Sync",
ready: boolean init: () => {
provider: Provider[] const [store, setStore] = createStore<{
agent: Agent[] ready: boolean
project: Project provider: Provider[]
config: Config agent: Agent[]
path: Path project: Project
session: Session[] config: Config
message: { path: Path
[sessionID: string]: Message[] session: Session[]
} message: {
part: { [sessionID: string]: Message[]
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}>({
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
message: {},
part: {},
node: [],
changes: [],
})
const bus = useEvent()
bus.listen((event) => {
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
} }
case "message.updated": { part: {
const messages = store.message[event.properties.info.sessionID] [messageID: string]: Part[]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
} }
case "message.part.updated": { node: FileNode[]
const parts = store.part[event.properties.part.messageID] changes: File[]
if (!parts) { }>({
setStore("part", event.properties.part.messageID, [event.properties.part]) project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
config: {},
path: { state: "", config: "", worktree: "", directory: "" },
ready: false,
agent: [],
provider: [],
session: [],
message: {},
part: {},
node: [],
changes: [],
})
const sdk = useSDK()
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break break
} }
const result = Binary.search(parts, event.properties.part.id, (p) => p.id) case "message.updated": {
if (result.found) { const messages = store.message[event.properties.info.sessionID]
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break break
} }
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
} }
} })
})
const sdk = useSDK() const load = {
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
const load = { provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
project: () => sdk.project.current().then((x) => setStore("project", x.data!)), path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)), agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)), session: () =>
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), sdk.client.session.list().then((x) =>
session: () => setStore(
sdk.session.list().then((x) => "session",
setStore( (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
"session", ),
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
), ),
), config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)), changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), }
}
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
const sanitize = (text: string) => text.replace(sanitizer(), "") const sanitize = (text: string) => text.replace(sanitizer(), "")
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
return { return {
data: store, data: store,
set: setStore, set: setStore,
session: { get ready() {
get(sessionID: string) { return store.ready
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}, },
async sync(sessionID: string) { session: {
const [session, messages] = await Promise.all([ get(sessionID: string) {
sdk.session.get({ path: { id: sessionID } }), const match = Binary.search(store.session, sessionID, (s) => s.id)
sdk.session.messages({ path: { id: sessionID } }), if (match.found) return store.session[match.index]
]) return undefined
setStore( },
produce((draft) => { async sync(sessionID: string) {
const match = Binary.search(draft.session, sessionID, (s) => s.id) const [session, messages] = await Promise.all([
draft.session[match.index] = session.data! sdk.client.session.get({ path: { id: sessionID } }),
draft.message[sessionID] = messages sdk.client.session.messages({ path: { id: sessionID } }),
.data!.map((x) => x.info) ])
.slice() setStore(
.sort((a, b) => a.id.localeCompare(b.id)) produce((draft) => {
for (const message of messages.data!) { const match = Binary.search(draft.session, sessionID, (s) => s.id)
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) draft.session[match.index] = session.data!
} draft.message[sessionID] = messages
}), .data!.map((x) => x.info)
) .slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
}),
)
},
}, },
}, load,
load, 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,19 +25,17 @@ render(
() => ( () => (
<ShikiProvider> <ShikiProvider>
<MarkedProvider> <MarkedProvider>
<SDKProvider> <SDKProvider url={`http://${host}:${port}`}>
<EventProvider> <SyncProvider>
<SyncProvider> <LocalProvider>
<LocalProvider> <MetaProvider>
<MetaProvider> <Fonts />
<Fonts /> <Router>
<Router> <Route path="/" component={Home} />
<Route path="/" component={Home} /> </Router>
</Router> </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
<Show when={local.session.active()}> onDragStart={handleDragStart}
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />} onDragEnd={handleDragEnd}
</Show> onDragOver={handleDragOver}
</div> collisionDetector={closestCenter}
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar"> >
<Show when={local.session.active()}> <DragDropSensors />
<EditorPane onFileClick={handleFileClick} /> <ConstrainDragYAxis />
</Show> <Tabs onChange={handleTabChange}>
</div> <div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
<div>Chat</div>
<Show when={local.session.active()}>
<div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
{local.session.context()}%
</div>
</Show>
</Tabs.Trigger>
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => setStore("fileSelectOpen", true)}
/>
</div>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
icon="file-text"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
/>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
icon="checklist"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
/>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
icon="columns"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
/>
</Tooltip>
</div>
)
})()}
</Show>
</div>
</div>
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
<Show when={local.session.active()} fallback={<div>No active session</div>}>
{(activeSession) => (
<div class="p-6 pt-12 max-w-[904px] mx-auto flex flex-col flex-1 min-h-0">
<div class="py-3 flex flex-col flex-1 min-h-0">
<div class="flex items-start gap-8 flex-1 min-h-0">
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
<For each={local.session.userMessages()}>
{(message) => (
<li
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
onClick={() => local.session.setActiveMessage(message.id)}
>
<div class="w-[18px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
<g>
<rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
</g>
</svg>
</div>
<div
data-active={local.session.activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
{local.session.getMessageText(message)}
</div>
</li>
)}
</For>
</ul>
<div
ref={messageScrollElement}
class="grow min-w-0 h-full overflow-y-auto no-scrollbar snap-y"
>
<div class="flex flex-col items-start gap-50 pb-[800px]">
<For each={local.session.userMessages()}>
{(message) => (
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-8 pt-1.5 snap-start"
>
<div class="flex flex-col items-start gap-4">
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
{local.session.getMessageText(message)}
</div>
<div class="text-14-regular text-text-base">
{message.summary?.text ||
local.session.getMessageText(local.session.activeAssistantMessagesWithText())}
</div>
</div>
<div class=""></div>
</div>
)}
</For>
</div>
</div>
</div>
</div>
</div>
)}
</Show>
</Tabs.Content>
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
<div <div
classList={{ classList={{
"absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true, "absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true,
"bottom-8": !!local.session.active(), "bottom-8": true,
"bottom-1/2 translate-y-1/2": !local.session.active(), // "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;
} }