summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/message-progress.tsx11
-rw-r--r--packages/desktop/src/components/prompt-input.tsx223
-rw-r--r--packages/desktop/src/context/local.tsx176
-rw-r--r--packages/desktop/src/context/session.tsx213
-rw-r--r--packages/desktop/src/context/sync.tsx14
-rw-r--r--packages/desktop/src/index.tsx10
-rw-r--r--packages/desktop/src/pages/index.tsx857
-rw-r--r--packages/desktop/src/pages/layout.tsx75
-rw-r--r--packages/desktop/src/pages/session-layout.tsx12
-rw-r--r--packages/desktop/src/pages/session.tsx693
10 files changed, 1183 insertions, 1101 deletions
diff --git a/packages/desktop/src/components/message-progress.tsx b/packages/desktop/src/components/message-progress.tsx
index c0037f57c..a9be2ae5e 100644
--- a/packages/desktop/src/components/message-progress.tsx
+++ b/packages/desktop/src/components/message-progress.tsx
@@ -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
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 3d9622cbe..7dc67f6ae 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -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
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 8fc05c452..cef6c5555 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -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
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
new file mode 100644
index 000000000..77ab3bc25
--- /dev/null
+++ b/packages/desktop/src/context/session.tsx
@@ -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
+}
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 06fc05677..c60206b0b 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -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"
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 9fe5da2f6..de9994af6 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -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>
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
deleted file mode 100644
index 5f04c3dbe..000000000
--- a/packages/desktop/src/pages/index.tsx
+++ /dev/null
@@ -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&nbsp;
- <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)}&lrm;
- </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>
- )
-}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
new file mode 100644
index 000000000..afec9ee3c
--- /dev/null
+++ b/packages/desktop/src/pages/layout.tsx
@@ -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>
+ )
+}
diff --git a/packages/desktop/src/pages/session-layout.tsx b/packages/desktop/src/pages/session-layout.tsx
new file mode 100644
index 000000000..9a24608f0
--- /dev/null
+++ b/packages/desktop/src/pages/session-layout.tsx
@@ -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>
+ )
+}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
new file mode 100644
index 000000000..9c633f4f7
--- /dev/null
+++ b/packages/desktop/src/pages/session.tsx
@@ -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&nbsp;
+ <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)}&lrm;
+ </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>
+ )
+}