mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-08 10:24:52 +01:00
feat(desktop): session router, interrupt agent, visual cleanup
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solid-primitives/scroll": "2.1.3",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.3",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
|
||||
@@ -70,9 +70,8 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
|
||||
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const defaultStatus = "Working..."
|
||||
const last = lastPart()
|
||||
if (!last) return defaultStatus
|
||||
if (!last) return undefined
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
@@ -102,7 +101,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts..."
|
||||
}
|
||||
return defaultStatus
|
||||
return undefined
|
||||
})
|
||||
|
||||
const [status, setStatus] = createSignal(rawStatus())
|
||||
@@ -111,11 +110,11 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
|
||||
createEffect(() => {
|
||||
const newStatus = rawStatus()
|
||||
if (newStatus === status()) return
|
||||
if (newStatus === status() || !newStatus) return
|
||||
|
||||
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||
|
||||
if (timeSinceLastChange >= 1000) {
|
||||
if (timeSinceLastChange >= 1500) {
|
||||
setStatus(newStatus)
|
||||
lastStatusChange = Date.now()
|
||||
if (statusTimeout) {
|
||||
@@ -145,7 +144,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
{/* )} */}
|
||||
{/* </Show> */}
|
||||
<div class="flex items-center gap-x-5 pl-3 border border-transparent text-text-base">
|
||||
<Spinner /> <span class="text-12-medium">{status()}</span>
|
||||
<Spinner /> <span class="text-12-medium">{status() ?? "Considering next steps..."}</span>
|
||||
</div>
|
||||
<Show when={eligibleItems().length > 0}>
|
||||
<div
|
||||
|
||||
@@ -1,51 +1,41 @@
|
||||
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js"
|
||||
import { createEffect, on, Component, Show, For, onMount, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@/ui"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { TextSelection, useLocal } from "@/context/local"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart
|
||||
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
interface PromptInputProps {
|
||||
onSubmit: (parts: ContentPart[]) => void
|
||||
class?: string
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
const session = useSession()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
|
||||
const [store, setStore] = createStore<{
|
||||
contentParts: ContentPart[]
|
||||
popoverIsOpen: boolean
|
||||
}>({
|
||||
contentParts: defaultParts,
|
||||
popoverIsOpen: false,
|
||||
})
|
||||
|
||||
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
|
||||
createEffect(() => {
|
||||
session.id
|
||||
editorRef.focus()
|
||||
})
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
@@ -71,14 +61,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileSelect = (path: string | undefined) => {
|
||||
if (!path) return
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
|
||||
setStore("popoverIsOpen", false)
|
||||
}
|
||||
|
||||
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
|
||||
items: local.file.searchFilesAndDirectories,
|
||||
key: (x) => x,
|
||||
onSelect: (path) => {
|
||||
if (!path) return
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
|
||||
setStore("popoverIsOpen", false)
|
||||
},
|
||||
onSelect: handleFileSelect,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -88,10 +80,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => store.contentParts,
|
||||
() => session.prompt.current(),
|
||||
(currentParts) => {
|
||||
const domParts = parseFromDOM()
|
||||
if (isEqual(currentParts, domParts)) return
|
||||
if (isPromptEqual(currentParts, domParts)) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
let cursorPosition: number | null = null
|
||||
@@ -122,8 +114,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
),
|
||||
)
|
||||
|
||||
const parseFromDOM = (): ContentPart[] => {
|
||||
const newParts: ContentPart[] = []
|
||||
createEffect(
|
||||
on(
|
||||
() => session.prompt.cursor(),
|
||||
(cursor) => {
|
||||
if (cursor === undefined) return
|
||||
queueMicrotask(() => setCursorPosition(editorRef, cursor))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const parseFromDOM = (): Prompt => {
|
||||
const newParts: Prompt = []
|
||||
let position = 0
|
||||
editorRef.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
@@ -150,7 +152,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
if (newParts.length === 0) newParts.push(...defaultParts)
|
||||
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
|
||||
return newParts
|
||||
}
|
||||
|
||||
@@ -167,12 +169,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setStore("popoverIsOpen", false)
|
||||
}
|
||||
|
||||
setStore("contentParts", rawParts)
|
||||
session.prompt.set(rawParts, cursorPosition)
|
||||
}
|
||||
|
||||
const addPart = (part: ContentPart) => {
|
||||
const cursorPosition = getCursorPosition(editorRef)
|
||||
const rawText = store.contentParts.map((p) => p.content).join("")
|
||||
const rawText = session.prompt
|
||||
.current()
|
||||
.map((p) => p.content)
|
||||
.join("")
|
||||
const textBeforeCursor = rawText.substring(0, cursorPosition)
|
||||
const atMatch = textBeforeCursor.match(/@(\S*)$/)
|
||||
|
||||
@@ -198,7 +203,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
parts: nextParts,
|
||||
inserted,
|
||||
cursorPositionAfter,
|
||||
} = store.contentParts.reduce(
|
||||
} = session.prompt.current().reduce(
|
||||
(acc, item) => {
|
||||
if (acc.inserted) {
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
@@ -257,7 +262,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
)
|
||||
|
||||
if (!inserted) {
|
||||
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
|
||||
const baseParts = session.prompt.current().filter((item) => !(item.type === "text" && item.content === ""))
|
||||
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
|
||||
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
|
||||
if (part.type === "text") {
|
||||
@@ -270,20 +275,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
end: appendedAcc.runningIndex + part.content.length,
|
||||
})
|
||||
}
|
||||
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
|
||||
setStore("contentParts", next)
|
||||
setStore("popoverIsOpen", false)
|
||||
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : DEFAULT_PROMPT
|
||||
const nextCursor = rawText.length + part.content.length
|
||||
session.prompt.set(next, nextCursor)
|
||||
setStore("popoverIsOpen", false)
|
||||
queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
|
||||
return
|
||||
}
|
||||
|
||||
setStore("contentParts", nextParts)
|
||||
session.prompt.set(nextParts, cursorPositionAfter)
|
||||
setStore("popoverIsOpen", false)
|
||||
|
||||
queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
|
||||
}
|
||||
|
||||
const abort = () =>
|
||||
sdk.client.session.abort({
|
||||
path: {
|
||||
id: session.id!,
|
||||
},
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
onKeyDown(event)
|
||||
@@ -293,14 +305,101 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
handleSubmit(event)
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (store.popoverIsOpen) {
|
||||
setStore("popoverIsOpen", false)
|
||||
} else if (session.working()) {
|
||||
abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (event: Event) => {
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (store.contentParts.length > 0) {
|
||||
props.onSubmit([...store.contentParts])
|
||||
setStore("contentParts", defaultParts)
|
||||
const text = session.prompt
|
||||
.current()
|
||||
.map((part) => part.content)
|
||||
.join("")
|
||||
if (text.trim().length === 0) {
|
||||
if (session.working()) abort()
|
||||
return
|
||||
}
|
||||
|
||||
let existing = session.info()
|
||||
if (!existing) {
|
||||
const created = await sdk.client.session.create()
|
||||
existing = created.data ?? undefined
|
||||
}
|
||||
if (!existing) return
|
||||
|
||||
navigate(`/session/${existing.id}`)
|
||||
if (!session.id) {
|
||||
// session.layout.setOpenedTabs(
|
||||
// session.layout.copyTabs("", session.id)
|
||||
}
|
||||
session.layout.setActiveTab(undefined)
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
|
||||
const attachments = session.prompt.current().filter((part) => part.type === "file")
|
||||
|
||||
// const activeFile = local.context.active()
|
||||
// if (activeFile) {
|
||||
// registerAttachment(
|
||||
// activeFile.path,
|
||||
// activeFile.selection,
|
||||
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
// for (const contextFile of local.context.all()) {
|
||||
// registerAttachment(
|
||||
// contextFile.path,
|
||||
// contextFile.selection,
|
||||
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
const attachmentParts = attachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
const query = attachment.selection
|
||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||
: ""
|
||||
return {
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${absolute}${query}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path: absolute,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
session.prompt.set(DEFAULT_PROMPT, 0)
|
||||
|
||||
await sdk.client.session.prompt({
|
||||
path: { id: existing.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
...attachmentParts,
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -310,11 +409,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
|
||||
<For each={flat()}>
|
||||
{(i) => (
|
||||
<div
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
"bg-surface-raised-base-hover": active() === i,
|
||||
}}
|
||||
onClick={() => handleFileSelect(i)}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
@@ -326,7 +426,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
@@ -354,7 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
"[&>[data-type=file]]:text-icon-info-active": true,
|
||||
}}
|
||||
/>
|
||||
<Show when={isEmpty()}>
|
||||
<Show when={!session.prompt.dirty()}>
|
||||
<div class="absolute top-0 left-0 p-3 text-14-regular text-text-weak pointer-events-none">
|
||||
Plan and build anything
|
||||
</div>
|
||||
@@ -419,29 +519,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
)}
|
||||
</SelectDialog>
|
||||
</div>
|
||||
<IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
|
||||
<IconButton
|
||||
type="submit"
|
||||
disabled={!session.prompt.dirty() && !session.working()}
|
||||
icon={session.working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
|
||||
if (arrA.length !== arrB.length) return false
|
||||
for (let i = 0; i < arrA.length; i++) {
|
||||
const partA = arrA[i]
|
||||
const partB = arrB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function getCursorPosition(parent: HTMLElement): number {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return 0
|
||||
|
||||
@@ -195,18 +195,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const file = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
// opened: string[]
|
||||
// active?: string
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
// opened: [],
|
||||
})
|
||||
|
||||
// const active = createMemo(() => {
|
||||
// if (!store.active) return undefined
|
||||
// return store.node[store.active]
|
||||
// })
|
||||
// const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
@@ -247,18 +239,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return false
|
||||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
})
|
||||
}
|
||||
// const resetNode = (path: string) => {
|
||||
// setStore("node", path, {
|
||||
// loaded: undefined,
|
||||
// pinned: undefined,
|
||||
// content: undefined,
|
||||
// selection: undefined,
|
||||
// scrollTop: undefined,
|
||||
// folded: undefined,
|
||||
// view: undefined,
|
||||
// selectedChange: undefined,
|
||||
// })
|
||||
// }
|
||||
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
||||
@@ -333,31 +325,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
break
|
||||
case "edit":
|
||||
// load(part.state.input["filePath"] as string)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
// setTimeout(sync.load.changes, 1000)
|
||||
// const relativePath = relative(event.properties.file)
|
||||
// if (relativePath.startsWith(".git/")) return
|
||||
// load(relativePath)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
node: (path: string) => store.node[path],
|
||||
node: async (path: string) => {
|
||||
if (!store.node[path]) {
|
||||
await init(path)
|
||||
}
|
||||
return store.node[path]
|
||||
},
|
||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||
open,
|
||||
load,
|
||||
@@ -417,121 +399,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
searchFiles,
|
||||
searchFilesAndDirectories,
|
||||
relative,
|
||||
// active,
|
||||
// opened,
|
||||
// close(path: string) {
|
||||
// setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||
// if (store.active === path) {
|
||||
// const index = store.opened.findIndex((f) => f === path)
|
||||
// const previous = store.opened[Math.max(0, index - 1)]
|
||||
// setStore("active", previous)
|
||||
// }
|
||||
// resetNode(path)
|
||||
// },
|
||||
// move(path: string, to: number) {
|
||||
// const index = store.opened.findIndex((f) => f === path)
|
||||
// if (index === -1) return
|
||||
// setStore(
|
||||
// "opened",
|
||||
// produce((opened) => {
|
||||
// opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
// }),
|
||||
// )
|
||||
// setStore("node", path, "pinned", true)
|
||||
// },
|
||||
}
|
||||
})()
|
||||
|
||||
const session = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
active?: string
|
||||
tabs: Record<
|
||||
string,
|
||||
{
|
||||
active?: string
|
||||
opened: string[]
|
||||
}
|
||||
>
|
||||
}>({
|
||||
tabs: {
|
||||
"": {
|
||||
opened: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return sync.session.get(store.active)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.active) return
|
||||
sync.session.sync(store.active)
|
||||
|
||||
if (!store.tabs[store.active]) {
|
||||
setStore("tabs", store.active, {
|
||||
opened: [],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const tabs = createMemo(() => store.tabs[store.active ?? ""])
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
},
|
||||
clearActive() {
|
||||
setStore("active", undefined)
|
||||
},
|
||||
tabs,
|
||||
copyTabs(from: string, to: string) {
|
||||
setStore("tabs", to, {
|
||||
opened: store.tabs[from]?.opened ?? [],
|
||||
})
|
||||
},
|
||||
setActiveTab(tab: string | undefined) {
|
||||
setStore("tabs", store.active ?? "", "active", tab)
|
||||
},
|
||||
async open(tab: string) {
|
||||
if (tab !== "chat") {
|
||||
await file.open(tab)
|
||||
}
|
||||
if (!tabs()?.opened?.includes(tab)) {
|
||||
setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
|
||||
}
|
||||
setStore("tabs", store.active ?? "", "active", tab)
|
||||
},
|
||||
close(tab: string) {
|
||||
batch(() => {
|
||||
if (!tabs()) return
|
||||
setStore("tabs", store.active ?? "", {
|
||||
active: tabs()!.active,
|
||||
opened: tabs()!.opened.filter((x) => x !== tab),
|
||||
})
|
||||
if (tabs()!.active === tab) {
|
||||
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||
const previous = tabs()!.opened[Math.max(0, index - 1)]
|
||||
setStore("tabs", store.active ?? "", "active", previous)
|
||||
}
|
||||
})
|
||||
},
|
||||
move(tab: string, to: number) {
|
||||
if (!tabs()) return
|
||||
const index = tabs()!.opened.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"tabs",
|
||||
store.active ?? "",
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
// setStore("node", path, "pinned", true)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -593,7 +460,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
session,
|
||||
context,
|
||||
}
|
||||
return result
|
||||
|
||||
213
packages/desktop/src/context/session.tsx
Normal file
213
packages/desktop/src/context/session.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { TextSelection, useLocal } from "./local"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { AssistantMessage } from "@opencode-ai/sdk"
|
||||
|
||||
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
|
||||
name: "Session",
|
||||
init: (props: { sessionId?: string }) => {
|
||||
const sync = useSync()
|
||||
const local = useLocal()
|
||||
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<{
|
||||
prompt: Prompt
|
||||
cursorPosition?: number
|
||||
messageId?: string
|
||||
tabs: {
|
||||
active?: string
|
||||
opened: string[]
|
||||
}
|
||||
}>({
|
||||
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
|
||||
tabs: {
|
||||
opened: [],
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: props.sessionId ?? "new-session",
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.sessionId) return
|
||||
sync.session.sync(props.sessionId)
|
||||
})
|
||||
|
||||
const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
|
||||
const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(0)
|
||||
})
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
return userMessages()?.find((m) => m.id === store.messageId)
|
||||
})
|
||||
const working = createMemo(() => {
|
||||
if (!props.sessionId) return false
|
||||
const last = lastUserMessage()
|
||||
if (!last) return false
|
||||
const assistantMessages = sync.data.message[props.sessionId]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == last?.id,
|
||||
) as AssistantMessage[]
|
||||
const error = assistantMessages?.find((m) => m?.error)?.error
|
||||
return !last?.summary?.body && !error
|
||||
})
|
||||
|
||||
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(
|
||||
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
|
||||
)
|
||||
const model = createMemo(() =>
|
||||
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
||||
)
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const tokens = last().tokens
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const total = tokens()
|
||||
const limit = model()?.limit.context
|
||||
if (!total || !limit) return 0
|
||||
return Math.round((total / limit) * 100)
|
||||
})
|
||||
|
||||
return {
|
||||
id: props.sessionId,
|
||||
info,
|
||||
working,
|
||||
prompt: {
|
||||
current: createMemo(() => store.prompt),
|
||||
cursor: createMemo(() => store.cursorPosition),
|
||||
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
|
||||
set(prompt: Prompt, cursorPosition?: number) {
|
||||
batch(() => {
|
||||
setStore("prompt", prompt)
|
||||
if (cursorPosition !== undefined) setStore("cursorPosition", cursorPosition)
|
||||
})
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
all: messages,
|
||||
user: userMessages,
|
||||
last: lastUserMessage,
|
||||
active: activeMessage,
|
||||
setActive(id: string | undefined) {
|
||||
setStore("messageId", id)
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
tokens,
|
||||
cost,
|
||||
context,
|
||||
},
|
||||
layout: {
|
||||
tabs: store.tabs,
|
||||
setActiveTab(tab: string | undefined) {
|
||||
setStore("tabs", "active", tab)
|
||||
},
|
||||
setOpenedTabs(tabs: string[]) {
|
||||
setStore("tabs", "opened", tabs)
|
||||
},
|
||||
async openTab(tab: string) {
|
||||
if (tab === "chat") {
|
||||
setStore("tabs", "active", undefined)
|
||||
return
|
||||
}
|
||||
if (tab.startsWith("file://")) {
|
||||
await local.file.open(tab.replace("file://", ""))
|
||||
}
|
||||
if (!store.tabs.opened.includes(tab)) {
|
||||
setStore("tabs", "opened", [...store.tabs.opened, tab])
|
||||
}
|
||||
setStore("tabs", "active", tab)
|
||||
},
|
||||
closeTab(tab: string) {
|
||||
batch(() => {
|
||||
setStore(
|
||||
"tabs",
|
||||
"opened",
|
||||
store.tabs.opened.filter((x) => x !== tab),
|
||||
)
|
||||
if (store.tabs.active === tab) {
|
||||
const index = store.tabs.opened.findIndex((f) => f === tab)
|
||||
const previous = store.tabs.opened[Math.max(0, index - 1)]
|
||||
setStore("tabs", "active", previous)
|
||||
}
|
||||
})
|
||||
},
|
||||
moveTab(tab: string, to: number) {
|
||||
const index = store.tabs.opened.findIndex((f) => f === tab)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"tabs",
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
// setStore("node", path, "pinned", true)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
}
|
||||
|
||||
export type ContentPart = TextPart | FileAttachmentPart
|
||||
export type Prompt = ContentPart[]
|
||||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
|
||||
return false
|
||||
}
|
||||
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
import type {
|
||||
Message,
|
||||
Agent,
|
||||
Provider,
|
||||
Session,
|
||||
Part,
|
||||
Config,
|
||||
Path,
|
||||
File,
|
||||
FileNode,
|
||||
Project,
|
||||
Command,
|
||||
} 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 { createMemo } from "solid-js"
|
||||
import { Binary } from "@/utils/binary"
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
|
||||
import { SDKProvider } from "./context/sdk"
|
||||
import { SyncProvider } from "./context/sync"
|
||||
import { LocalProvider } from "./context/local"
|
||||
import Home from "@/pages"
|
||||
import Layout from "@/pages/layout"
|
||||
import SessionLayout from "@/pages/session-layout"
|
||||
import Session from "@/pages/session"
|
||||
|
||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
||||
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
||||
@@ -32,8 +34,10 @@ render(
|
||||
<LocalProvider>
|
||||
<MetaProvider>
|
||||
<Fonts />
|
||||
<Router>
|
||||
<Route path="/" component={Home} />
|
||||
<Router root={Layout}>
|
||||
<Route path={["/", "/session"]} component={SessionLayout}>
|
||||
<Route path="/:id?" component={Session} />
|
||||
</Route>
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
</LocalProvider>
|
||||
|
||||
@@ -1,857 +0,0 @@
|
||||
import {
|
||||
Button,
|
||||
List,
|
||||
SelectDialog,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Icon,
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
DiffChanges,
|
||||
Message,
|
||||
Typewriter,
|
||||
Card,
|
||||
Code,
|
||||
} from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { MessageProgress } from "@/components/message-progress"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import { Markdown } from "@opencode-ai/ui"
|
||||
import { Spinner } from "@/components/spinner"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
let inputRef!: HTMLDivElement
|
||||
let messageScrollElement!: HTMLDivElement
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
setStore("fileSelectOpen", true)
|
||||
return
|
||||
}
|
||||
|
||||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (event.key === "Escape") {
|
||||
inputRef?.blur()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// if (local.file.active()) {
|
||||
// const active = local.file.active()!
|
||||
// if (event.key === "Enter" && active.selection) {
|
||||
// local.context.add({
|
||||
// type: "file",
|
||||
// path: active.path,
|
||||
// selection: { ...active.selection },
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if (event.getModifierState(MOD)) {
|
||||
// if (event.key.toLowerCase() === "a") {
|
||||
// return
|
||||
// }
|
||||
// if (event.key.toLowerCase() === "c") {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
setStore("clickTimer", undefined)
|
||||
}
|
||||
|
||||
const startClickTimer = () => {
|
||||
const newClickTimer = setTimeout(() => {
|
||||
setStore("clickTimer", undefined)
|
||||
}, 300)
|
||||
setStore("clickTimer", newClickTimer as unknown as number)
|
||||
}
|
||||
|
||||
const handleFileClick = async (file: LocalFile) => {
|
||||
if (store.clickTimer) {
|
||||
resetClickTimer()
|
||||
local.file.update(file.path, { ...file, pinned: true })
|
||||
} else {
|
||||
local.file.open(file.path)
|
||||
startClickTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.session.setActiveTab(path)
|
||||
if (path === "chat") return
|
||||
local.session.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.session.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.session.tabs()?.opened.map((file) => file)
|
||||
const fromIndex = currentFiles?.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles?.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||
local.session.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
// const scrollDiffItem = (element: HTMLElement) => {
|
||||
// element.scrollIntoView({ block: "start", behavior: "instant" })
|
||||
// }
|
||||
|
||||
const handleDiffTriggerClick = (event: MouseEvent) => {
|
||||
// disabling scroll to diff for now
|
||||
return
|
||||
// const target = event.currentTarget as HTMLElement
|
||||
// queueMicrotask(() => {
|
||||
// if (target.getAttribute("aria-expanded") !== "true") return
|
||||
// const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
|
||||
// if (!item) return
|
||||
// scrollDiffItem(item)
|
||||
// })
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||
const existingSession = local.session.active()
|
||||
let session = existingSession
|
||||
if (!session) {
|
||||
const created = await sdk.client.session.create()
|
||||
session = created.data ?? undefined
|
||||
}
|
||||
if (!session) return
|
||||
|
||||
local.session.setActive(session.id)
|
||||
if (!existingSession) {
|
||||
local.session.copyTabs("", session.id)
|
||||
}
|
||||
local.session.setActiveTab(undefined)
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
|
||||
const text = parts.map((part) => part.content).join("")
|
||||
const attachments = parts.filter((part) => part.type === "file")
|
||||
|
||||
// const activeFile = local.context.active()
|
||||
// if (activeFile) {
|
||||
// registerAttachment(
|
||||
// activeFile.path,
|
||||
// activeFile.selection,
|
||||
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
// for (const contextFile of local.context.all()) {
|
||||
// registerAttachment(
|
||||
// contextFile.path,
|
||||
// contextFile.selection,
|
||||
// formatAttachmentLabel(contextFile.path, contextFile.selection),
|
||||
// )
|
||||
// }
|
||||
|
||||
const attachmentParts = attachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
const query = attachment.selection
|
||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||
: ""
|
||||
return {
|
||||
type: "file" as const,
|
||||
mime: "text/plain",
|
||||
url: `file://${absolute}${query}`,
|
||||
filename: getFilename(attachment.path),
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path: absolute,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
await sdk.client.session.prompt({
|
||||
path: { id: session.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
model: {
|
||||
modelID: local.model.current()!.id,
|
||||
providerID: local.model.current()!.provider.id,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
...attachmentParts,
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleNewSession = () => {
|
||||
local.session.setActive(undefined)
|
||||
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 group-data-[selected]/tab:grayscale-0" />
|
||||
<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="group/tab pl-3 pr-1"
|
||||
onClick={() => props.onTabClick(props.file)}
|
||||
>
|
||||
<TabVisual file={props.file} />
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
|
||||
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 group-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConstrainDragYAxis = (): JSX.Element => {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getDraggableId = (event: unknown): string | undefined => {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative h-screen flex flex-col">
|
||||
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
|
||||
<main class="h-[calc(100vh-0rem)] flex">
|
||||
<div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
|
||||
<div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
|
||||
<span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
|
||||
<Button class="w-full" size="large" onClick={handleNewSession} icon="edit-small-2">
|
||||
New Session
|
||||
</Button>
|
||||
<List
|
||||
data={sync.data.session}
|
||||
key={(x) => x.id}
|
||||
current={local.session.active()}
|
||||
onSelect={(s) => local.session.setActive(s?.id)}
|
||||
onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
|
||||
>
|
||||
{(session) => {
|
||||
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
||||
const filesChanged = createMemo(() => diffs().length)
|
||||
const updated = DateTime.fromMillis(session.time.updated)
|
||||
return (
|
||||
<Tooltip placement="right" value={session.title}>
|
||||
<div>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||
{session.title}
|
||||
</span>
|
||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||
{Math.abs(updated.diffNow().as("seconds")) < 60
|
||||
? "Now"
|
||||
: updated
|
||||
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
||||
?.replace(" ago", "")
|
||||
?.replace(/ days?/, "d")
|
||||
?.replace(" min.", "m")
|
||||
?.replace(" hr.", "h")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
|
||||
<DiffChanges diff={diffs()} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative bg-background-base w-full h-full overflow-x-hidden">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={local.session.tabs()?.active ?? "chat"} onChange={handleTabChange}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||
<div>Chat</div>
|
||||
{/* <Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5"> */}
|
||||
{/* <ProgressCircle percentage={local.session.context() ?? 0} /> */}
|
||||
{/* <div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div> */}
|
||||
{/* </Tooltip> */}
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||
<SortableProvider ids={local.session.tabs()?.opened ?? []}>
|
||||
<For each={local.session.tabs()?.opened ?? []}>
|
||||
{(file) => (
|
||||
<SortableTab
|
||||
file={local.file.node(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>
|
||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
<Show
|
||||
when={local.session.active()}
|
||||
fallback={
|
||||
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
<div class="text-20-medium text-text-weaker">New session</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
{getDirectory(sync.data.path.directory)}
|
||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="pencil-line" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
Last modified
|
||||
<span class="text-text-strong">
|
||||
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(session) => {
|
||||
const [store, setStore] = createStore<{
|
||||
messageId?: string
|
||||
}>()
|
||||
|
||||
const messages = createMemo(() => sync.data.message[session().id] ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(0)
|
||||
})
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
return userMessages()?.find((m) => m.id === store.messageId)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={userMessages().length > 1}>
|
||||
<ul
|
||||
role="list"
|
||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||
>
|
||||
<For each={userMessages()}>
|
||||
{(message) => {
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[session().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const working = createMemo(() => !message.summary?.body && !error())
|
||||
|
||||
return (
|
||||
<li class="group/li flex items-center self-stretch">
|
||||
<button
|
||||
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
||||
onClick={() => setStore("messageId", message.id)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges diff={diffs()} variant="bars" />
|
||||
</Match>
|
||||
</Switch>
|
||||
<div
|
||||
data-active={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,
|
||||
}}
|
||||
>
|
||||
<Show when={message.summary?.title} fallback="New message">
|
||||
{message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||
<For each={userMessages()}>
|
||||
{(message) => {
|
||||
const isActive = createMemo(() => activeMessage()?.id === message.id)
|
||||
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[session().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const parts = createMemo(() => sync.data.part[message.id])
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
const summary = createMemo(() => message.summary?.body)
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
const hasToolPart = createMemo(() =>
|
||||
assistantMessages()
|
||||
?.flatMap((m) => sync.data.part[m.id])
|
||||
.some((p) => p?.type === "tool"),
|
||||
)
|
||||
const working = createMemo(() => !summary() && !error())
|
||||
|
||||
// allowing time for the animations to finish
|
||||
createEffect(() => {
|
||||
title()
|
||||
setTimeout(() => setTitled(!!title()), 10_000)
|
||||
})
|
||||
createEffect(() => {
|
||||
const complete = !!summary() || !!error()
|
||||
setTimeout(() => setCompleted(complete), 1200)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isActive()}>
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<div class="w-full text-14-medium text-text-strong">
|
||||
<Show
|
||||
when={titled()}
|
||||
fallback={
|
||||
<Typewriter
|
||||
as="h1"
|
||||
text={title()}
|
||||
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
||||
{title()}
|
||||
</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="-mt-8">
|
||||
<Message message={message} parts={parts()} />
|
||||
</div>
|
||||
{/* Summary */}
|
||||
<Show when={completed()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">
|
||||
<Switch>
|
||||
<Match when={diffs().length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
classList={{ "[&>*]:fade-up-text": !diffs().length }}
|
||||
text={summary()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={diffs()}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
||||
<div class="flex items-center justify-between w-full gap-5">
|
||||
<div class="grow flex items-center gap-5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex grow min-w-0">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span class="text-text-base truncate-start">
|
||||
{getDirectory(diff.file)}‎
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-text-strong shrink-0">
|
||||
{getFilename(diff.file)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||
<DiffChanges diff={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<Diff
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !expanded()}>
|
||||
<Card variant="error" class="text-text-on-critical-base">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div class="w-full">
|
||||
<Switch>
|
||||
<Match when={!completed()}>
|
||||
<MessageProgress
|
||||
assistantMessages={assistantMessages}
|
||||
done={!working()}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={completed() && hasToolPart()}>
|
||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<div class="text-12-medium">
|
||||
<Switch>
|
||||
<Match when={expanded()}>Hide details</Match>
|
||||
<Match when={!expanded()}>Show details</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(
|
||||
() => sync.data.part[assistantMessage.id],
|
||||
)
|
||||
return <Message message={assistantMessage} parts={parts()} />
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="text-text-on-critical-base">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||
<For each={local.session.tabs()?.opened}>
|
||||
{(file) => (
|
||||
<Tabs.Content value={file} class="select-text">
|
||||
{(() => {
|
||||
{
|
||||
/* const view = local.file.view(file) */
|
||||
}
|
||||
{
|
||||
/* const showRaw = view === "raw" || !file.content?.diff */
|
||||
}
|
||||
{
|
||||
/* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
|
||||
}
|
||||
const node = local.file.node(file)
|
||||
return (
|
||||
<Code
|
||||
file={{ name: node.path, contents: node.content?.content ?? "" }}
|
||||
disableFileHeader
|
||||
overflow="scroll"
|
||||
class="pt-3 pb-40"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</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-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
||||
<TabVisual file={draggedFile} />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
onSubmit={handlePromptSubmit}
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
</div>
|
||||
<div class="hidden shrink-0 w-56 p-2">
|
||||
<Show
|
||||
when={local.file.changes().length}
|
||||
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
|
||||
>
|
||||
<ul class="">
|
||||
<For each={local.file.changes()}>
|
||||
{(path) => (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Show when={store.fileSelectOpen}>
|
||||
<SelectDialog
|
||||
defaultOpen
|
||||
title="Select file"
|
||||
placeholder="Search files"
|
||||
emptyMessage="No files found"
|
||||
items={local.file.searchFiles}
|
||||
key={(x) => x}
|
||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||
onSelect={(x) => (x ? local.session.open(x) : undefined)}
|
||||
>
|
||||
{(i) => (
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
</SelectDialog>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
packages/desktop/src/pages/layout.tsx
Normal file
75
packages/desktop/src/pages/layout.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Button, Tooltip, DiffChanges } from "@opencode-ai/ui"
|
||||
import { createMemo, ParentProps } from "solid-js"
|
||||
import { getFilename } from "@/utils"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { VList } from "virtua/solid"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
return (
|
||||
<div class="relative h-screen flex flex-col">
|
||||
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
|
||||
<div class="h-[calc(100vh-0rem)] flex">
|
||||
<div class="w-70 shrink-0 bg-background-weak border-r border-border-weak-base flex flex-col items-start">
|
||||
<div class="h-10 flex items-center self-stretch px-5 border-b border-border-weak-base">
|
||||
<span class="text-14-regular overflow-hidden text-ellipsis">{getFilename(sync.data.path.directory)}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-4 self-stretch flex-1 py-4 px-3">
|
||||
<A href="/session" class="w-full">
|
||||
<Button class="w-full" size="large" icon="edit-small-2">
|
||||
New Session
|
||||
</Button>
|
||||
</A>
|
||||
<VList data={sync.data.session} class="no-scrollbar">
|
||||
{(session) => {
|
||||
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
||||
const filesChanged = createMemo(() => diffs().length)
|
||||
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
|
||||
return (
|
||||
<A
|
||||
data-active={session.id === params.id}
|
||||
href={`/session/${session.id}`}
|
||||
class="group/session focus:outline-none"
|
||||
>
|
||||
<Tooltip placement="right" value={session.title}>
|
||||
<div
|
||||
class="w-full mb-1.5 px-3 py-1 rounded-md
|
||||
group-data-[active=true]/session:bg-surface-raised-base-hover
|
||||
group-hover/session:bg-surface-raised-base-hover
|
||||
group-focus/session:bg-surface-raised-base-hover"
|
||||
>
|
||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||
{session.title}
|
||||
</span>
|
||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||
{Math.abs(updated().diffNow().as("seconds")) < 60
|
||||
? "Now"
|
||||
: updated()
|
||||
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
||||
?.replace(" ago", "")
|
||||
?.replace(/ days?/, "d")
|
||||
?.replace(" min.", "m")
|
||||
?.replace(" hr.", "h")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
|
||||
<DiffChanges diff={diffs()} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</A>
|
||||
)
|
||||
}}
|
||||
</VList>
|
||||
</div>
|
||||
</div>
|
||||
<main class="size-full overflow-x-hidden">{props.children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
packages/desktop/src/pages/session-layout.tsx
Normal file
12
packages/desktop/src/pages/session-layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Show, type ParentProps } from "solid-js"
|
||||
import { SessionProvider } from "@/context/session"
|
||||
import { useParams } from "@solidjs/router"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
return (
|
||||
<Show when={params.id || true} keyed>
|
||||
<SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
693
packages/desktop/src/pages/session.tsx
Normal file
693
packages/desktop/src/pages/session.tsx
Normal file
@@ -0,0 +1,693 @@
|
||||
import {
|
||||
SelectDialog,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Icon,
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
DiffChanges,
|
||||
Message,
|
||||
Typewriter,
|
||||
Card,
|
||||
Code,
|
||||
Tooltip,
|
||||
ProgressCircle,
|
||||
} from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import { MessageProgress } from "@/components/message-progress"
|
||||
import {
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
createSignal,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
} from "solid-js"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import { Markdown } from "@opencode-ai/ui"
|
||||
import { Spinner } from "@/components/spinner"
|
||||
import { useSession } from "@/context/session"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const session = useSession()
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
fileSelectOpen: false,
|
||||
activeDraggable: undefined as string | undefined,
|
||||
})
|
||||
let inputRef!: HTMLDivElement
|
||||
let messageScrollElement!: HTMLDivElement
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
|
||||
event.preventDefault()
|
||||
setStore("fileSelectOpen", true)
|
||||
return
|
||||
}
|
||||
|
||||
const focused = document.activeElement === inputRef
|
||||
if (focused) {
|
||||
if (event.key === "Escape") {
|
||||
inputRef?.blur()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// if (local.file.active()) {
|
||||
// const active = local.file.active()!
|
||||
// if (event.key === "Enter" && active.selection) {
|
||||
// local.context.add({
|
||||
// type: "file",
|
||||
// path: active.path,
|
||||
// selection: { ...active.selection },
|
||||
// })
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if (event.getModifierState(MOD)) {
|
||||
// if (event.key.toLowerCase() === "a") {
|
||||
// return
|
||||
// }
|
||||
// if (event.key.toLowerCase() === "c") {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
setStore("clickTimer", undefined)
|
||||
}
|
||||
|
||||
const startClickTimer = () => {
|
||||
const newClickTimer = setTimeout(() => {
|
||||
setStore("clickTimer", undefined)
|
||||
}, 300)
|
||||
setStore("clickTimer", newClickTimer as unknown as number)
|
||||
}
|
||||
|
||||
const handleTabClick = async (tab: string) => {
|
||||
if (store.clickTimer) {
|
||||
resetClickTimer()
|
||||
// local.file.update(file.path, { ...file, pinned: true })
|
||||
} else {
|
||||
if (tab.startsWith("file://")) {
|
||||
local.file.open(tab.replace("file://", ""))
|
||||
}
|
||||
startClickTimer()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setStore("activeDraggable", id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentTabs = session.layout.tabs.opened
|
||||
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
|
||||
const toIndex = currentTabs?.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex && toIndex !== undefined) {
|
||||
session.layout.moveTab(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setStore("activeDraggable", undefined)
|
||||
}
|
||||
|
||||
const FileVisual = (props: { file: LocalFile }): JSX.Element => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
|
||||
<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: {
|
||||
tab: string
|
||||
onTabClick: (tab: string) => void
|
||||
onTabClose: (tab: string) => void
|
||||
}): JSX.Element => {
|
||||
const sortable = createSortable(props.tab)
|
||||
|
||||
const [file] = createResource(
|
||||
() => props.tab,
|
||||
async (tab) => {
|
||||
if (tab.startsWith("file://")) {
|
||||
return local.file.node(tab.replace("file://", ""))
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger value={props.tab} class="group/tab pl-3 pr-1" onClick={() => props.onTabClick(props.tab)}>
|
||||
<Switch>
|
||||
<Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
|
||||
</Switch>
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
|
||||
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 group-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
/>
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConstrainDragYAxis = (): JSX.Element => {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getDraggableId = (event: unknown): string | undefined => {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-base size-full overflow-x-hidden">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||
<div>Chat</div>
|
||||
<Tooltip
|
||||
value={`${new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(session.usage.tokens() ?? 0)} Tokens`}
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<ProgressCircle percentage={session.usage.context() ?? 0} />
|
||||
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
|
||||
</Tooltip>
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||
<SortableProvider ids={session.layout.tabs.opened ?? []}>
|
||||
<For each={session.layout.tabs.opened ?? []}>
|
||||
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />}
|
||||
</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>
|
||||
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
<Show
|
||||
when={session.id}
|
||||
fallback={
|
||||
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
<div class="text-20-medium text-text-weaker">New session</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
{getDirectory(sync.data.path.directory)}
|
||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="pencil-line" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
Last modified
|
||||
<span class="text-text-strong">
|
||||
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(_) => {
|
||||
return (
|
||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex-1 min-h-0">
|
||||
<Show when={session.messages.user().length > 1}>
|
||||
<ul
|
||||
role="list"
|
||||
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
|
||||
>
|
||||
<For each={session.messages.user()}>
|
||||
{(message) => {
|
||||
const assistantMessages = createMemo(() => {
|
||||
if (!session.id) return []
|
||||
return sync.data.message[session.id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const working = createMemo(() => !message.summary?.body && !error())
|
||||
|
||||
return (
|
||||
<li class="group/li flex items-center self-stretch">
|
||||
<button
|
||||
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
||||
onClick={() => session.messages.setActive(message.id)}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges diff={message.summary?.diffs ?? []} variant="bars" />
|
||||
</Match>
|
||||
</Switch>
|
||||
<div
|
||||
data-active={session.messages.active()?.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,
|
||||
}}
|
||||
>
|
||||
<Show when={message.summary?.title} fallback="New message">
|
||||
{message.summary?.title}
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||
<For each={session.messages.user()}>
|
||||
{(message) => {
|
||||
const isActive = createMemo(() => session.messages.active()?.id === message.id)
|
||||
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||
const assistantMessages = createMemo(() => {
|
||||
if (!session.id) return []
|
||||
return sync.data.message[session.id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
||||
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
||||
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
|
||||
const parts = createMemo(() => sync.data.part[message.id])
|
||||
const hasToolPart = createMemo(() =>
|
||||
assistantMessages()
|
||||
?.flatMap((m) => sync.data.part[m.id])
|
||||
.some((p) => p?.type === "tool"),
|
||||
)
|
||||
const working = createMemo(() => !message.summary?.body && !error())
|
||||
|
||||
// allowing time for the animations to finish
|
||||
createEffect(() => {
|
||||
const title = message.summary?.title
|
||||
setTimeout(() => setTitled(!!title), 10_000)
|
||||
})
|
||||
createEffect(() => {
|
||||
const summary = message.summary?.body
|
||||
const complete = !!summary || !!error()
|
||||
setTimeout(() => setCompleted(complete), 1200)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isActive()}>
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-8 pb-50"
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<div class="w-full text-14-medium text-text-strong">
|
||||
<Show
|
||||
when={titled()}
|
||||
fallback={
|
||||
<Typewriter
|
||||
as="h1"
|
||||
text={message.summary?.title}
|
||||
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
||||
{message.summary?.title}
|
||||
</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="-mt-8">
|
||||
<Message message={message} parts={parts()} />
|
||||
</div>
|
||||
{/* Summary */}
|
||||
<Show when={completed()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">
|
||||
<Switch>
|
||||
<Match when={message.summary?.diffs?.length}>Summary</Match>
|
||||
<Match when={true}>Response</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={message.summary?.body}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
classList={{
|
||||
"text-14-regular": !!message.summary?.diffs?.length,
|
||||
"[&>*]:fade-up-text": !message.summary?.diffs?.length,
|
||||
}}
|
||||
text={summary()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={message.summary?.diffs ?? []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger>
|
||||
<div class="flex items-center justify-between w-full gap-5">
|
||||
<div class="grow flex items-center gap-5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex grow min-w-0">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span class="text-text-base truncate-start">
|
||||
{getDirectory(diff.file)}‎
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-text-strong shrink-0">
|
||||
{getFilename(diff.file)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||
<DiffChanges diff={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content>
|
||||
<Diff
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
}}
|
||||
/>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !detailsExpanded()}>
|
||||
<Card variant="error" class="text-text-on-critical-base">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div class="w-full">
|
||||
<Switch>
|
||||
<Match when={!completed()}>
|
||||
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
|
||||
</Match>
|
||||
<Match when={completed() && hasToolPart()}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
open={detailsExpanded()}
|
||||
onOpenChange={setDetailsExpanded}
|
||||
>
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<div class="text-12-medium">
|
||||
<Switch>
|
||||
<Match when={detailsExpanded()}>Hide details</Match>
|
||||
<Match when={!detailsExpanded()}>Show details</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => sync.data.part[assistantMessage.id])
|
||||
return <Message message={assistantMessage} parts={parts()} />
|
||||
}}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="text-text-on-critical-base">
|
||||
{error()?.data?.message as string}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||
<For each={session.layout.tabs.opened}>
|
||||
{(tab) => {
|
||||
const [file] = createResource(
|
||||
() => tab,
|
||||
async (tab) => {
|
||||
if (tab.startsWith("file://")) {
|
||||
return local.file.node(tab.replace("file://", ""))
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
return (
|
||||
<Tabs.Content value={tab} class="select-text">
|
||||
<Switch>
|
||||
<Match when={file()}>
|
||||
{(f) => (
|
||||
<Code
|
||||
file={{ name: f().path, contents: f().content?.content ?? "" }}
|
||||
overflow="scroll"
|
||||
class="pt-3 pb-40"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedFile) => {
|
||||
const [file] = createResource(
|
||||
() => draggedFile(),
|
||||
async (tab) => {
|
||||
if (tab.startsWith("file://")) {
|
||||
return local.file.node(tab.replace("file://", ""))
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
return (
|
||||
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
||||
<Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
|
||||
{/* <FileTree path="" onFileClick={ handleTabClick} /> */}
|
||||
</div>
|
||||
<div class="hidden shrink-0 w-56 p-2">
|
||||
<Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}>
|
||||
<ul class="">
|
||||
<For each={local.file.changes()}>
|
||||
{(path) => (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
|
||||
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
|
||||
>
|
||||
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
|
||||
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
|
||||
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
|
||||
{getDirectory(path)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={store.fileSelectOpen}>
|
||||
<SelectDialog
|
||||
defaultOpen
|
||||
title="Select file"
|
||||
placeholder="Search files"
|
||||
emptyMessage="No files found"
|
||||
items={local.file.searchFiles}
|
||||
key={(x) => x}
|
||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||
onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
|
||||
>
|
||||
{(i) => (
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex items-center justify-between rounded-md": true,
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-x-2 grow min-w-0">
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
|
||||
</div>
|
||||
)}
|
||||
</SelectDialog>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user