summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-11-10 13:44:12 +0000
committerDavid Hill <[email protected]>2025-11-10 13:44:12 +0000
commitc6e830c954418808dc39284a1c073aa63a6d4d21 (patch)
tree9c3052e0509115188768a553c0be5a8441ebdd96 /packages/desktop/src
parent7088bfabd773e2f076aab1c9d2468c04feff0570 (diff)
parentfc78c28df64383a9f99382093f61fc28caf6569f (diff)
downloadopencode-c6e830c954418808dc39284a1c073aa63a6d4d21.tar.gz
opencode-c6e830c954418808dc39284a1c073aa63a6d4d21.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/code.tsx846
-rw-r--r--packages/desktop/src/components/file-tree.tsx4
-rw-r--r--packages/desktop/src/components/message-progress.tsx11
-rw-r--r--packages/desktop/src/components/prompt-input.tsx243
-rw-r--r--packages/desktop/src/components/theme.json558
-rw-r--r--packages/desktop/src/context/local.tsx226
-rw-r--r--packages/desktop/src/context/session.tsx217
-rw-r--r--packages/desktop/src/context/sync.tsx59
-rw-r--r--packages/desktop/src/index.tsx42
-rw-r--r--packages/desktop/src/pages/index.tsx857
-rw-r--r--packages/desktop/src/pages/layout.tsx121
-rw-r--r--packages/desktop/src/pages/session-layout.tsx12
-rw-r--r--packages/desktop/src/pages/session.tsx899
-rw-r--r--packages/desktop/src/sst-env.d.ts6
14 files changed, 1567 insertions, 2534 deletions
diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx
deleted file mode 100644
index 325f7b635..000000000
--- a/packages/desktop/src/components/code.tsx
+++ /dev/null
@@ -1,846 +0,0 @@
-import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
-import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
-import { useLocal, type TextSelection } from "@/context/local"
-import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
-import { useShiki } from "@opencode-ai/ui"
-
-type DefinedSelection = Exclude<TextSelection, undefined>
-
-interface Props extends ComponentProps<"div"> {
- code: string
- path: string
-}
-
-export function Code(props: Props) {
- const ctx = useLocal()
- const highlighter = useShiki()
- const [local, others] = splitProps(props, ["class", "classList", "code", "path"])
- const lang = createMemo(() => {
- const ext = getFileExtension(local.path)
- if (ext in bundledLanguages) return ext
- return "text"
- })
-
- let container: HTMLDivElement | undefined
- let isProgrammaticSelection = false
-
- const ranges = createMemo<DefinedSelection[]>(() => {
- const items = ctx.context.all() as Array<{ type: "file"; path: string; selection?: DefinedSelection }>
- const result: DefinedSelection[] = []
- for (const item of items) {
- if (item.path !== local.path) continue
- const selection = item.selection
- if (!selection) continue
- result.push(selection)
- }
- return result
- })
-
- const createLineNumberTransformer = (selections: DefinedSelection[]): ShikiTransformer => {
- const highlighted = new Set<number>()
- for (const selection of selections) {
- const startLine = selection.startLine
- const endLine = selection.endLine
- const start = Math.max(1, Math.min(startLine, endLine))
- const end = Math.max(start, Math.max(startLine, endLine))
- const count = end - start + 1
- if (count <= 0) continue
- const values = Array.from({ length: count }, (_, index) => start + index)
- for (const value of values) highlighted.add(value)
- }
- return {
- name: "line-number-highlight",
- line(node, index) {
- if (!highlighted.has(index)) return
- this.addClassToHast(node, "line-number-highlight")
- const children = node.children
- if (!Array.isArray(children)) return
- for (const child of children) {
- if (!child || typeof child !== "object") continue
- const element = child as { type?: string; properties?: { className?: string[] } }
- if (element.type !== "element") continue
- const className = element.properties?.className
- if (!Array.isArray(className)) continue
- const matches = className.includes("diff-oldln") || className.includes("diff-newln")
- if (!matches) continue
- if (className.includes("line-number-highlight")) continue
- className.push("line-number-highlight")
- }
- },
- }
- }
-
- const [html] = createResource(
- () => ranges(),
- async (activeRanges) => {
- if (!highlighter.getLoadedLanguages().includes(lang())) {
- await highlighter.loadLanguage(lang() as BundledLanguage)
- }
- return highlighter.codeToHtml(local.code || "", {
- lang: lang() && lang() in bundledLanguages ? lang() : "text",
- theme: "opencode",
- transformers: [transformerUnifiedDiff(), transformerDiffGroups(), createLineNumberTransformer(activeRanges)],
- }) as string
- },
- )
-
- onMount(() => {
- if (!container) return
-
- let ticking = false
- const onScroll = () => {
- if (!container) return
- // if (ctx.file.active()?.path !== local.path) return
- if (ticking) return
- ticking = true
- requestAnimationFrame(() => {
- ticking = false
- ctx.file.scroll(local.path, container!.scrollTop)
- })
- }
-
- const onSelectionChange = () => {
- if (!container) return
- if (isProgrammaticSelection) return
- // if (ctx.file.active()?.path !== local.path) return
- const d = getSelectionInContainer(container)
- if (!d) return
- const p = ctx.file.node(local.path)?.selection
- if (p && p.startLine === d.sl && p.endLine === d.el && p.startChar === d.sch && p.endChar === d.ech) return
- ctx.file.select(local.path, { startLine: d.sl, startChar: d.sch, endLine: d.el, endChar: d.ech })
- }
-
- const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
- const onKeyDown = (e: KeyboardEvent) => {
- // if (ctx.file.active()?.path !== local.path) return
- const ae = document.activeElement as HTMLElement | undefined
- const tag = (ae?.tagName || "").toLowerCase()
- const inputFocused = !!ae && (tag === "input" || tag === "textarea" || ae.isContentEditable)
- if (inputFocused) return
- if (e.getModifierState(MOD) && e.key.toLowerCase() === "a") {
- e.preventDefault()
- if (!container) return
- const element = container.querySelector("code") as HTMLElement | undefined
- if (!element) return
- const lines = Array.from(element.querySelectorAll(".line"))
- if (!lines.length) return
- const r = document.createRange()
- const last = lines[lines.length - 1]
- r.selectNodeContents(last)
- const lastLen = r.toString().length
- ctx.file.select(local.path, { startLine: 1, startChar: 0, endLine: lines.length, endChar: lastLen })
- }
- }
-
- container.addEventListener("scroll", onScroll)
- document.addEventListener("selectionchange", onSelectionChange)
- document.addEventListener("keydown", onKeyDown)
-
- onCleanup(() => {
- container?.removeEventListener("scroll", onScroll)
- document.removeEventListener("selectionchange", onSelectionChange)
- document.removeEventListener("keydown", onKeyDown)
- })
- })
-
- // Restore scroll position from store when content is ready
- createEffect(() => {
- const content = html()
- if (!container || !content) return
- const top = ctx.file.node(local.path)?.scrollTop
- if (top !== undefined && container.scrollTop !== top) container.scrollTop = top
- })
-
- // Sync selection from store -> DOM
- createEffect(() => {
- const content = html()
- if (!container || !content) return
- // if (ctx.file.active()?.path !== local.path) return
- const codeEl = container.querySelector("code") as HTMLElement | undefined
- if (!codeEl) return
- const target = ctx.file.node(local.path)?.selection
- const current = getSelectionInContainer(container)
- const sel = window.getSelection()
- if (!sel) return
- if (!target) {
- if (current) {
- isProgrammaticSelection = true
- sel.removeAllRanges()
- queueMicrotask(() => {
- isProgrammaticSelection = false
- })
- }
- return
- }
- const matches = !!(
- current &&
- current.sl === target.startLine &&
- current.sch === target.startChar &&
- current.el === target.endLine &&
- current.ech === target.endChar
- )
- if (matches) return
- const lines = Array.from(codeEl.querySelectorAll(".line"))
- if (lines.length === 0) return
- let sIdx = Math.max(0, target.startLine - 1)
- let eIdx = Math.max(0, target.endLine - 1)
- let sChar = Math.max(0, target.startChar || 0)
- let eChar = Math.max(0, target.endChar || 0)
- if (sIdx > eIdx || (sIdx === eIdx && sChar > eChar)) {
- const ti = sIdx
- sIdx = eIdx
- eIdx = ti
- const tc = sChar
- sChar = eChar
- eChar = tc
- }
- if (eChar === 0 && eIdx > sIdx) {
- eIdx = eIdx - 1
- eChar = Number.POSITIVE_INFINITY
- }
- if (sIdx >= lines.length) return
- if (eIdx >= lines.length) eIdx = lines.length - 1
- const s = getNodeOffsetInLine(lines[sIdx], sChar) ?? { node: lines[sIdx], offset: 0 }
- const e = getNodeOffsetInLine(lines[eIdx], eChar) ?? { node: lines[eIdx], offset: lines[eIdx].childNodes.length }
- const range = document.createRange()
- range.setStart(s.node, s.offset)
- range.setEnd(e.node, e.offset)
- isProgrammaticSelection = true
- sel.removeAllRanges()
- sel.addRange(range)
- queueMicrotask(() => {
- isProgrammaticSelection = false
- })
- })
-
- // Build/toggle split layout and apply folding (both unified and split)
- createEffect(() => {
- const content = html()
- if (!container || !content) return
- const view = ctx.file.view(local.path)
-
- const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre"))
- if (pres.length === 0) return
- const originalPre = pres[0]
-
- const split = container.querySelector<HTMLElement>(".diff-split")
- if (view === "diff-split") {
- applySplitDiff(container)
- const next = container.querySelector<HTMLElement>(".diff-split")
- if (next) next.style.display = ""
- originalPre.style.display = "none"
- } else {
- if (split) split.style.display = "none"
- originalPre.style.display = ""
- }
-
- const expanded = ctx.file.folded(local.path)
- if (view === "diff-split") {
- const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code")
- const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code")
- if (left)
- applyDiffFolding(left, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "left" })
- if (right)
- applyDiffFolding(right, 3, { expanded, onExpand: (key) => ctx.file.unfold(local.path, key), side: "right" })
- } else {
- const code = container.querySelector<HTMLElement>("pre code")
- if (code)
- applyDiffFolding(code, 3, {
- expanded,
- onExpand: (key) => ctx.file.unfold(local.path, key),
- })
- }
- })
-
- // Highlight groups + scroll coupling
- const clearHighlights = () => {
- if (!container) return
- container.querySelectorAll<HTMLElement>(".diff-selected").forEach((el) => el.classList.remove("diff-selected"))
- }
-
- const applyHighlight = (idx: number, scroll?: boolean) => {
- if (!container) return
- const view = ctx.file.view(local.path)
- if (view === "raw") return
-
- clearHighlights()
-
- const nodes: HTMLElement[] = []
- if (view === "diff-split") {
- const left = container.querySelector<HTMLElement>(".diff-split pre:nth-child(1) code")
- const right = container.querySelector<HTMLElement>(".diff-split pre:nth-child(2) code")
- if (left)
- nodes.push(...Array.from(left.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="remove"]`)))
- if (right)
- nodes.push(...Array.from(right.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"][data-diff="add"]`)))
- } else {
- const code = container.querySelector<HTMLElement>("pre code")
- if (code) nodes.push(...Array.from(code.querySelectorAll<HTMLElement>(`[data-chgrp="${idx}"]`)))
- }
-
- for (const n of nodes) n.classList.add("diff-selected")
- if (scroll && nodes.length) nodes[0].scrollIntoView({ block: "center", behavior: "smooth" })
- }
-
- const countGroups = () => {
- if (!container) return 0
- const code = container.querySelector<HTMLElement>("pre code")
- if (!code) return 0
- const set = new Set<string>()
- for (const el of Array.from(code.querySelectorAll<HTMLElement>(".diff-line[data-chgrp]"))) {
- const v = el.getAttribute("data-chgrp")
- if (v != undefined) set.add(v)
- }
- return set.size
- }
-
- let lastIdx: number | undefined = undefined
- let lastView: string | undefined
- let lastContent: string | undefined
- let lastRawIdx: number | undefined = undefined
- createEffect(() => {
- const content = html()
- if (!container || !content) return
- const view = ctx.file.view(local.path)
- const raw = ctx.file.changeIndex(local.path)
- if (raw === undefined) return
- const total = countGroups()
- if (total <= 0) return
- const next = ((raw % total) + total) % total
-
- const navigated = lastRawIdx !== undefined && lastRawIdx !== raw
-
- if (next !== raw) {
- ctx.file.setChangeIndex(local.path, next)
- applyHighlight(next, true)
- } else {
- if (lastView !== view || lastContent !== content) applyHighlight(next)
- if ((lastIdx !== undefined && lastIdx !== next) || navigated) applyHighlight(next, true)
- }
-
- lastRawIdx = raw
- lastIdx = next
- lastView = view
- lastContent = content
- })
-
- return (
- <div
- ref={(el) => {
- container = el
- }}
- innerHTML={html()}
- class="
- font-mono text-xs tracking-wide overflow-y-auto h-full
- [&]:[counter-reset:line]
- [&_pre]:focus-visible:outline-none
- [&_pre]:overflow-x-auto [&_pre]:no-scrollbar
- [&_code]:min-w-full [&_code]:inline-block
- [&_.tab]:relative
- [&_.tab::before]:content['⇥']
- [&_.tab::before]:absolute
- [&_.tab::before]:opacity-0
- [&_.space]:relative
- [&_.space::before]:content-['·']
- [&_.space::before]:absolute
- [&_.space::before]:opacity-0
- [&_.line]:inline-block [&_.line]:w-full
- [&_.line]:hover:bg-background-element
- [&_.line::before]:sticky [&_.line::before]:left-0
- [&_.line::before]:w-12 [&_.line::before]:pr-4
- [&_.line::before]:z-10
- [&_.line::before]:bg-background-panel
- [&_.line::before]:text-text-muted/60
- [&_.line::before]:text-right [&_.line::before]:inline-block
- [&_.line::before]:select-none
- [&_.line::before]:[counter-increment:line]
- [&_.line::before]:content-[counter(line)]
- [&_.line-number-highlight]:bg-accent/20
- [&_.line-number-highlight::before]:bg-accent/40!
- [&_.line-number-highlight::before]:text-background-panel!
- [&_code.code-diff_.line::before]:content-['']
- [&_code.code-diff_.line::before]:w-0
- [&_code.code-diff_.line::before]:pr-0
- [&_.diff-split_code.code-diff::before]:w-10
- [&_.diff-split_.diff-newln]:left-0
- [&_.diff-oldln]:sticky [&_.diff-oldln]:left-0
- [&_.diff-oldln]:w-10 [&_.diff-oldln]:pr-2
- [&_.diff-oldln]:z-40
- [&_.diff-oldln]:text-text-muted/60
- [&_.diff-oldln]:text-right [&_.diff-oldln]:inline-block
- [&_.diff-oldln]:select-none
- [&_.diff-oldln]:bg-background-panel
- [&_.diff-newln]:sticky [&_.diff-newln]:left-10
- [&_.diff-newln]:w-10 [&_.diff-newln]:pr-2
- [&_.diff-newln]:z-40
- [&_.diff-newln]:text-text-muted/60
- [&_.diff-newln]:text-right [&_.diff-newln]:inline-block
- [&_.diff-newln]:select-none
- [&_.diff-newln]:bg-background-panel
- [&_.diff-add]:bg-success/20!
- [&_.diff-add.diff-selected]:bg-success/50!
- [&_.diff-add_.diff-oldln]:bg-success!
- [&_.diff-add_.diff-oldln]:text-background-panel!
- [&_.diff-add_.diff-newln]:bg-success!
- [&_.diff-add_.diff-newln]:text-background-panel!
- [&_.diff-remove]:bg-error/20!
- [&_.diff-remove.diff-selected]:bg-error/50!
- [&_.diff-remove_.diff-newln]:bg-error!
- [&_.diff-remove_.diff-newln]:text-background-panel!
- [&_.diff-remove_.diff-oldln]:bg-error!
- [&_.diff-remove_.diff-oldln]:text-background-panel!
- [&_.diff-sign]:inline-block [&_.diff-sign]:px-2 [&_.diff-sign]:select-none
- [&_.diff-blank]:bg-background-element
- [&_.diff-blank_.diff-oldln]:bg-background-element
- [&_.diff-blank_.diff-newln]:bg-background-element
- [&_.diff-collapsed]:block! [&_.diff-collapsed]:w-full [&_.diff-collapsed]:relative
- [&_.diff-collapsed]:select-none
- [&_.diff-collapsed]:bg-info/20 [&_.diff-collapsed]:hover:bg-info/40!
- [&_.diff-collapsed]:text-info/80 [&_.diff-collapsed]:hover:text-info
- [&_.diff-collapsed]:text-xs
- [&_.diff-collapsed_.diff-oldln]:bg-info!
- [&_.diff-collapsed_.diff-newln]:bg-info!
- "
- classList={{
- ...(local.classList || {}),
- [local.class ?? ""]: !!local.class,
- }}
- {...others}
- ></div>
- )
-}
-
-function transformerUnifiedDiff(): ShikiTransformer {
- const kinds = new Map<number, string>()
- const meta = new Map<number, { old?: number; new?: number; sign?: string }>()
- let isDiff = false
-
- return {
- name: "unified-diff",
- preprocess(input) {
- kinds.clear()
- meta.clear()
- isDiff = false
-
- const ls = input.split(/\r?\n/)
- const out: Array<string> = []
- let oldNo = 0
- let newNo = 0
- let inHunk = false
-
- for (let i = 0; i < ls.length; i++) {
- const s = ls[i]
-
- const m = s.match(/^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/)
- if (m) {
- isDiff = true
- inHunk = true
- oldNo = parseInt(m[1], 10)
- newNo = parseInt(m[3], 10)
- continue
- }
-
- if (
- /^diff --git /.test(s) ||
- /^Index: /.test(s) ||
- /^--- /.test(s) ||
- /^\+\+\+ /.test(s) ||
- /^[=]{3,}$/.test(s) ||
- /^\*{3,}$/.test(s) ||
- /^\\ No newline at end of file$/.test(s)
- ) {
- isDiff = true
- continue
- }
-
- if (!inHunk) {
- out.push(s)
- continue
- }
-
- if (/^\+/.test(s)) {
- out.push(s)
- const ln = out.length
- kinds.set(ln, "add")
- meta.set(ln, { new: newNo, sign: "+" })
- newNo++
- continue
- }
-
- if (/^-/.test(s)) {
- out.push(s)
- const ln = out.length
- kinds.set(ln, "remove")
- meta.set(ln, { old: oldNo, sign: "-" })
- oldNo++
- continue
- }
-
- if (/^ /.test(s)) {
- out.push(s)
- const ln = out.length
- kinds.set(ln, "context")
- meta.set(ln, { old: oldNo, new: newNo })
- oldNo++
- newNo++
- continue
- }
-
- // fallback in hunks
- out.push(s)
- }
-
- return out.join("\n").trimEnd()
- },
- code(node) {
- if (isDiff) this.addClassToHast(node, "code-diff")
- },
- pre(node) {
- if (isDiff) this.addClassToHast(node, "code-diff")
- },
- line(node, line) {
- if (!isDiff) return
- const kind = kinds.get(line)
- if (!kind) return
-
- const m = meta.get(line) || {}
-
- this.addClassToHast(node, "diff-line")
- this.addClassToHast(node, `diff-${kind}`)
- node.properties = node.properties || {}
- ;(node.properties as any)["data-diff"] = kind
- if (m.old != undefined) (node.properties as any)["data-old"] = String(m.old)
- if (m.new != undefined) (node.properties as any)["data-new"] = String(m.new)
-
- const oldSpan = {
- type: "element",
- tagName: "span",
- properties: { className: ["diff-oldln"] },
- children: [{ type: "text", value: m.old != undefined ? String(m.old) : " " }],
- }
- const newSpan = {
- type: "element",
- tagName: "span",
- properties: { className: ["diff-newln"] },
- children: [{ type: "text", value: m.new != undefined ? String(m.new) : " " }],
- }
-
- if (kind === "add" || kind === "remove" || kind === "context") {
- const first = (node.children && (node.children as any[])[0]) as any
- if (first && first.type === "element" && first.children && first.children.length > 0) {
- const t = first.children[0]
- if (t && t.type === "text" && typeof t.value === "string" && t.value.length > 0) {
- const ch = t.value[0]
- if (ch === "+" || ch === "-" || ch === " ") t.value = t.value.slice(1)
- }
- }
- }
-
- const signSpan = {
- type: "element",
- tagName: "span",
- properties: { className: ["diff-sign"] },
- children: [{ type: "text", value: (m as any).sign || " " }],
- }
-
- // @ts-expect-error hast typing across versions
- node.children = [oldSpan, newSpan, signSpan, ...(node.children || [])]
- },
- }
-}
-
-function transformerDiffGroups(): ShikiTransformer {
- let group = -1
- let inGroup = false
- return {
- name: "diff-groups",
- pre() {
- group = -1
- inGroup = false
- },
- line(node) {
- const props = (node.properties || {}) as any
- const kind = props["data-diff"] as string | undefined
- if (kind === "add" || kind === "remove") {
- if (!inGroup) {
- group += 1
- inGroup = true
- }
- ;(node.properties as any)["data-chgrp"] = String(group)
- } else {
- inGroup = false
- }
- },
- }
-}
-
-function applyDiffFolding(
- root: HTMLElement,
- context = 3,
- options?: { expanded?: string[]; onExpand?: (key: string) => void; side?: "left" | "right" },
-) {
- if (!root.classList.contains("code-diff")) return
-
- // Cleanup: unwrap previous collapsed blocks and remove toggles
- const blocks = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed-block"))
- for (const block of blocks) {
- const p = block.parentNode
- if (!p) {
- block.remove()
- continue
- }
- while (block.firstChild) p.insertBefore(block.firstChild, block)
- block.remove()
- }
- const toggles = Array.from(root.querySelectorAll<HTMLElement>(".diff-collapsed"))
- for (const t of toggles) t.remove()
-
- const lines = Array.from(root.querySelectorAll<HTMLElement>(".diff-line"))
- if (lines.length === 0) return
-
- const n = lines.length
- const isChange = lines.map((l) => l.dataset["diff"] === "add" || l.dataset["diff"] === "remove")
- const isContext = lines.map((l) => l.dataset["diff"] === "context")
- if (!isChange.some(Boolean)) return
-
- const visible = new Array(n).fill(false) as boolean[]
- for (let i = 0; i < n; i++) if (isChange[i]) visible[i] = true
- for (let i = 0; i < n; i++) {
- if (isChange[i]) {
- const s = Math.max(0, i - context)
- const e = Math.min(n - 1, i + context)
- for (let j = s; j <= e; j++) if (isContext[j]) visible[j] = true
- }
- }
-
- type Range = { start: number; end: number }
- const ranges: Range[] = []
- let i = 0
- while (i < n) {
- if (!visible[i] && isContext[i]) {
- let j = i
- while (j + 1 < n && !visible[j + 1] && isContext[j + 1]) j++
- ranges.push({ start: i, end: j })
- i = j + 1
- } else {
- i++
- }
- }
-
- for (const r of ranges) {
- const start = lines[r.start]
- const end = lines[r.end]
- const count = r.end - r.start + 1
- const minCollapse = 20
- if (count < minCollapse) {
- continue
- }
-
- // Wrap the entire collapsed chunk (including trailing newline) so it takes no space
- const block = document.createElement("span")
- block.className = "diff-collapsed-block"
- start.parentElement?.insertBefore(block, start)
-
- let cur: Node | undefined = start
- while (cur) {
- const next: Node | undefined = cur.nextSibling || undefined
- block.appendChild(cur)
- if (cur === end) {
- // Also move the newline after the last line into the block
- if (next && next.nodeType === Node.TEXT_NODE && (next.textContent || "").startsWith("\n")) {
- block.appendChild(next)
- }
- break
- }
- cur = next
- }
-
- block.style.display = "none"
- const row = document.createElement("span")
- row.className = "line diff-collapsed"
- row.setAttribute("data-kind", "collapsed")
- row.setAttribute("data-count", String(count))
- row.setAttribute("tabindex", "0")
- row.setAttribute("role", "button")
-
- const oldln = document.createElement("span")
- oldln.className = "diff-oldln"
- oldln.textContent = " "
-
- const newln = document.createElement("span")
- newln.className = "diff-newln"
- newln.textContent = " "
-
- const sign = document.createElement("span")
- sign.className = "diff-sign"
- sign.textContent = "…"
-
- const label = document.createElement("span")
- label.textContent = `show ${count} unchanged line${count > 1 ? "s" : ""}`
-
- const key = `o${start.dataset["old"] || ""}-${end.dataset["old"] || ""}:n${start.dataset["new"] || ""}-${end.dataset["new"] || ""}`
-
- const show = (record = true) => {
- if (record) options?.onExpand?.(key)
- const p = block.parentNode
- if (p) {
- while (block.firstChild) p.insertBefore(block.firstChild, block)
- block.remove()
- }
- row.remove()
- }
-
- row.addEventListener("click", () => show(true))
- row.addEventListener("keydown", (ev) => {
- if (ev.key === "Enter" || ev.key === " ") {
- ev.preventDefault()
- show(true)
- }
- })
-
- block.parentElement?.insertBefore(row, block)
- if (!options?.side || options.side === "left") row.appendChild(oldln)
- if (!options?.side || options.side === "right") row.appendChild(newln)
- row.appendChild(sign)
- row.appendChild(label)
-
- if (options?.expanded && options.expanded.includes(key)) {
- show(false)
- }
- }
-}
-
-function applySplitDiff(container: HTMLElement) {
- const pres = Array.from(container.querySelectorAll<HTMLPreElement>("pre"))
- if (pres.length === 0) return
- const originalPre = pres[0]
- const originalCode = originalPre.querySelector("code") as HTMLElement | undefined
- if (!originalCode || !originalCode.classList.contains("code-diff")) return
-
- // Rebuild split each time to match current content
- const existing = container.querySelector<HTMLElement>(".diff-split")
- if (existing) existing.remove()
-
- const grid = document.createElement("div")
- grid.className = "diff-split grid grid-cols-2 gap-x-6"
-
- const makeColumn = () => {
- const pre = document.createElement("pre")
- pre.className = originalPre.className
- const code = document.createElement("code")
- code.className = originalCode.className
- pre.appendChild(code)
- return { pre, code }
- }
-
- const left = makeColumn()
- const right = makeColumn()
-
- // Helpers
- const cloneSide = (line: HTMLElement, side: "old" | "new"): HTMLElement => {
- const clone = line.cloneNode(true) as HTMLElement
- const oldln = clone.querySelector(".diff-oldln")
- const newln = clone.querySelector(".diff-newln")
- if (side === "old") {
- if (newln) newln.remove()
- } else {
- if (oldln) oldln.remove()
- }
- return clone
- }
-
- const blankLine = (side: "old" | "new", kind: "add" | "remove"): HTMLElement => {
- const span = document.createElement("span")
- span.className = "line diff-line diff-blank"
- span.setAttribute("data-diff", kind)
- const ln = document.createElement("span")
- ln.className = side === "old" ? "diff-oldln" : "diff-newln"
- ln.textContent = " "
- span.appendChild(ln)
- return span
- }
-
- const lines = Array.from(originalCode.querySelectorAll<HTMLElement>(".diff-line"))
- let i = 0
- while (i < lines.length) {
- const cur = lines[i]
- const kind = cur.dataset["diff"]
-
- if (kind === "context") {
- left.code.appendChild(cloneSide(cur, "old"))
- left.code.appendChild(document.createTextNode("\n"))
- right.code.appendChild(cloneSide(cur, "new"))
- right.code.appendChild(document.createTextNode("\n"))
- i++
- continue
- }
-
- if (kind === "remove") {
- // Batch consecutive removes and following adds, then pair
- const removes: HTMLElement[] = []
- const adds: HTMLElement[] = []
- let j = i
- while (j < lines.length && lines[j].dataset["diff"] === "remove") {
- removes.push(lines[j])
- j++
- }
- let k = j
- while (k < lines.length && lines[k].dataset["diff"] === "add") {
- adds.push(lines[k])
- k++
- }
-
- const pairs = Math.min(removes.length, adds.length)
- for (let p = 0; p < pairs; p++) {
- left.code.appendChild(cloneSide(removes[p], "old"))
- left.code.appendChild(document.createTextNode("\n"))
- right.code.appendChild(cloneSide(adds[p], "new"))
- right.code.appendChild(document.createTextNode("\n"))
- }
- for (let p = pairs; p < removes.length; p++) {
- left.code.appendChild(cloneSide(removes[p], "old"))
- left.code.appendChild(document.createTextNode("\n"))
- right.code.appendChild(blankLine("new", "remove"))
- right.code.appendChild(document.createTextNode("\n"))
- }
- for (let p = pairs; p < adds.length; p++) {
- left.code.appendChild(blankLine("old", "add"))
- left.code.appendChild(document.createTextNode("\n"))
- right.code.appendChild(cloneSide(adds[p], "new"))
- right.code.appendChild(document.createTextNode("\n"))
- }
-
- i = k
- continue
- }
-
- if (kind === "add") {
- // Run of adds not preceded by removes
- const adds: HTMLElement[] = []
- let j = i
- while (j < lines.length && lines[j].dataset["diff"] === "add") {
- adds.push(lines[j])
- j++
- }
- for (let p = 0; p < adds.length; p++) {
- left.code.appendChild(blankLine("old", "add"))
- left.code.appendChild(document.createTextNode("\n"))
- right.code.appendChild(cloneSide(adds[p], "new"))
- right.code.appendChild(document.createTextNode("\n"))
- }
- i = j
- continue
- }
-
- // Any other kind: mirror as context
- left.code.appendChild(cloneSide(cur, "old"))
- left.code.appendChild(document.createTextNode("\n"))
- right.code.appendChild(cloneSide(cur, "new"))
- right.code.appendChild(document.createTextNode("\n"))
- i++
- }
-
- grid.appendChild(left.pre)
- grid.appendChild(right.pre)
- container.appendChild(grid)
-}
diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx
index a5d19f51e..1347ecae6 100644
--- a/packages/desktop/src/components/file-tree.tsx
+++ b/packages/desktop/src/components/file-tree.tsx
@@ -77,7 +77,7 @@ export default function FileTree(props: {
<Collapsible
class="w-full"
forceMount={false}
- open={local.file.node(node.path)?.expanded}
+ // open={local.file.node(node.path)?.expanded}
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
>
<Collapsible.Trigger>
@@ -85,7 +85,7 @@ export default function FileTree(props: {
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
<FileIcon
node={node}
- expanded={local.file.node(node.path).expanded}
+ // expanded={local.file.node(node.path).expanded}
class="text-text-muted/60 -ml-1"
/>
</Node>
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..5ae56f827 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 { Button, Icon, IconButton, Select, SelectDialog, Tooltip } 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, Switch, Match } 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,13 @@ 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 prompt = session.prompt.current()
+ const rawText = prompt.map((p) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
@@ -198,7 +201,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
parts: nextParts,
inserted,
cursorPositionAfter,
- } = store.contentParts.reduce(
+ } = prompt.reduce(
(acc, item) => {
if (acc.inserted) {
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
@@ -257,7 +260,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
if (!inserted) {
- const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
+ const baseParts = prompt.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 +273,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 +303,100 @@ 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 prompt = session.prompt.current()
+ const text = prompt.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) navigate(`/session/${existing.id}`)
+ }
+ if (!existing) return
+
+ // if (!session.id) {
+ // session.layout.setOpenedTabs(
+ // session.layout.copyTabs("", session.id)
+ // }
+
+ const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+ const attachments = prompt.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.layout.setActiveTab(undefined)
+ session.messages.setActive(undefined)
+ session.prompt.set(DEFAULT_PROMPT, 0)
+
+ 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 +406,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 +423,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 +451,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 +516,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)}
</SelectDialog>
</div>
- <IconButton type="submit" disabled={isEmpty()} icon="arrow-up" variant="primary" />
+ <Tooltip
+ placement="top"
+ value={
+ <Switch>
+ <Match when={session.working()}>
+ <div class="flex items-center gap-2">
+ <span>Stop</span>
+ <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
+ </div>
+ </Match>
+ <Match when={true}>
+ <div class="flex items-center gap-2">
+ <span>Send</span>
+ <Icon name="enter" size="small" class="text-icon-base" />
+ </div>
+ </Match>
+ </Switch>
+ }
+ >
+ <IconButton
+ type="submit"
+ disabled={!session.prompt.dirty() && !session.working()}
+ icon={session.working() ? "stop" : "arrow-up"}
+ variant="primary"
+ class="rounded-full"
+ />
+ </Tooltip>
</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/components/theme.json b/packages/desktop/src/components/theme.json
deleted file mode 100644
index 36a4d020d..000000000
--- a/packages/desktop/src/components/theme.json
+++ /dev/null
@@ -1,558 +0,0 @@
-{
- "colors": {
- "actionBar.toggledBackground": "var(--surface-raised-base)",
- "activityBarBadge.background": "var(--surface-brand-base)",
- "checkbox.border": "var(--border-base)",
- "editor.background": "transparent",
- "editor.foreground": "var(--text-base)",
- "editor.inactiveSelectionBackground": "var(--surface-raised-base)",
- "editor.selectionHighlightBackground": "var(--border-active)",
- "editorIndentGuide.activeBackground1": "var(--border-weak-base)",
- "editorIndentGuide.background1": "var(--border-weak-base)",
- "input.placeholderForeground": "var(--text-weak)",
- "list.activeSelectionIconForeground": "var(--text-base)",
- "list.dropBackground": "var(--surface-raised-base)",
- "menu.background": "var(--surface-base)",
- "menu.border": "var(--border-base)",
- "menu.foreground": "var(--text-base)",
- "menu.selectionBackground": "var(--surface-interactive-base)",
- "menu.separatorBackground": "var(--border-base)",
- "ports.iconRunningProcessForeground": "var(--icon-success-base)",
- "sideBarSectionHeader.background": "transparent",
- "sideBarSectionHeader.border": "var(--border-weak-base)",
- "sideBarTitle.foreground": "var(--text-weak)",
- "statusBarItem.remoteBackground": "var(--surface-success-base)",
- "statusBarItem.remoteForeground": "var(--text-base)",
- "tab.lastPinnedBorder": "var(--border-weak-base)",
- "tab.selectedBackground": "var(--surface-raised-base)",
- "tab.selectedForeground": "var(--text-weak)",
- "terminal.inactiveSelectionBackground": "var(--surface-raised-base)",
- "widget.border": "var(--border-base)"
- },
- "displayName": "opencode",
- "name": "opencode",
- "semanticHighlighting": true,
- "semanticTokenColors": {
- "customLiteral": "var(--syntax-function)",
- "newOperator": "var(--syntax-operator)",
- "numberLiteral": "var(--syntax-number)",
- "stringLiteral": "var(--syntax-string)"
- },
- "tokenColors": [
- {
- "scope": [
- "meta.embedded",
- "source.groovy.embedded",
- "string meta.image.inline.markdown",
- "variable.legacy.builtin.python"
- ],
- "settings": {
- "foreground": "var(--text-base)"
- }
- },
- {
- "scope": "emphasis",
- "settings": {
- "fontStyle": "italic"
- }
- },
- {
- "scope": "strong",
- "settings": {
- "fontStyle": "bold"
- }
- },
- {
- "scope": "header",
- "settings": {
- "foreground": "var(--markdown-heading)"
- }
- },
- {
- "scope": "comment",
- "settings": {
- "foreground": "var(--syntax-comment)"
- }
- },
- {
- "scope": "constant.language",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": [
- "constant.numeric",
- "variable.other.enummember",
- "keyword.operator.plus.exponent",
- "keyword.operator.minus.exponent"
- ],
- "settings": {
- "foreground": "var(--syntax-number)"
- }
- },
- {
- "scope": "constant.regexp",
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": "entity.name.tag",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": ["entity.name.tag.css", "entity.name.tag.less"],
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": "entity.other.attribute-name",
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": [
- "entity.other.attribute-name.class.css",
- "source.css entity.other.attribute-name.class",
- "entity.other.attribute-name.id.css",
- "entity.other.attribute-name.parent-selector.css",
- "entity.other.attribute-name.parent.less",
- "source.css entity.other.attribute-name.pseudo-class",
- "entity.other.attribute-name.pseudo-element.css",
- "source.css.less entity.other.attribute-name.id",
- "entity.other.attribute-name.scss"
- ],
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": "invalid",
- "settings": {
- "foreground": "var(--syntax-critical)"
- }
- },
- {
- "scope": "markup.underline",
- "settings": {
- "fontStyle": "underline"
- }
- },
- {
- "scope": "markup.bold",
- "settings": {
- "fontStyle": "bold",
- "foreground": "var(--markdown-strong)"
- }
- },
- {
- "scope": "markup.heading",
- "settings": {
- "fontStyle": "bold",
- "foreground": "var(--theme-markdown-heading)"
- }
- },
- {
- "scope": "markup.italic",
- "settings": {
- "fontStyle": "italic"
- }
- },
- {
- "scope": "markup.strikethrough",
- "settings": {
- "fontStyle": "strikethrough"
- }
- },
- {
- "scope": "markup.inserted",
- "settings": {
- "foreground": "var(--text-diff-add-base)"
- }
- },
- {
- "scope": "markup.deleted",
- "settings": {
- "foreground": "var(--text-diff-delete-base)"
- }
- },
- {
- "scope": "markup.changed",
- "settings": {
- "foreground": "var(--text-base)"
- }
- },
- {
- "scope": "punctuation.definition.quote.begin.markdown",
- "settings": {
- "foreground": "var(--markdown-block-quote)"
- }
- },
- {
- "scope": "punctuation.definition.list.begin.markdown",
- "settings": {
- "foreground": "var(--markdown-list-enumeration)"
- }
- },
- {
- "scope": "markup.inline.raw",
- "settings": {
- "foreground": "var(--markdown-code)"
- }
- },
- {
- "scope": "punctuation.definition.tag",
- "settings": {
- "foreground": "var(--syntax-punctuation)"
- }
- },
- {
- "scope": ["meta.preprocessor", "entity.name.function.preprocessor"],
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "meta.preprocessor.string",
- "settings": {
- "foreground": "var(--syntax-string)"
- }
- },
- {
- "scope": "meta.preprocessor.numeric",
- "settings": {
- "foreground": "var(--syntax-number)"
- }
- },
- {
- "scope": "meta.structure.dictionary.key.python",
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": "meta.diff.header",
- "settings": {
- "foreground": "var(--text-weak)"
- }
- },
- {
- "scope": "storage",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "storage.type",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": ["storage.modifier", "keyword.operator.noexcept"],
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": ["string", "meta.embedded.assembly"],
- "settings": {
- "foreground": "var(--syntax-string)"
- }
- },
- {
- "scope": "string.tag",
- "settings": {
- "foreground": "var(--syntax-string)"
- }
- },
- {
- "scope": "string.value",
- "settings": {
- "foreground": "var(--syntax-string)"
- }
- },
- {
- "scope": "string.regexp",
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": [
- "punctuation.definition.template-expression.begin",
- "punctuation.definition.template-expression.end",
- "punctuation.section.embedded"
- ],
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": ["meta.template.expression"],
- "settings": {
- "foreground": "var(--text-base)"
- }
- },
- {
- "scope": [
- "support.type.vendored.property-name",
- "support.type.property-name",
- "source.css variable",
- "source.coffee.embedded"
- ],
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": "keyword",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "keyword.control",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "keyword.operator",
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": [
- "keyword.operator.new",
- "keyword.operator.expression",
- "keyword.operator.cast",
- "keyword.operator.sizeof",
- "keyword.operator.alignof",
- "keyword.operator.typeid",
- "keyword.operator.alignas",
- "keyword.operator.instanceof",
- "keyword.operator.logical.python",
- "keyword.operator.wordlike"
- ],
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "keyword.other.unit",
- "settings": {
- "foreground": "var(--syntax-number)"
- }
- },
- {
- "scope": ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "support.function.git-rebase",
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": "constant.sha.git-rebase",
- "settings": {
- "foreground": "var(--syntax-number)"
- }
- },
- {
- "scope": ["storage.modifier.import.java", "variable.language.wildcard.java", "storage.modifier.package.java"],
- "settings": {
- "foreground": "var(--text-base)"
- }
- },
- {
- "scope": "variable.language",
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": [
- "entity.name.function",
- "support.function",
- "support.constant.handlebars",
- "source.powershell variable.other.member",
- "entity.name.operator.custom-literal"
- ],
- "settings": {
- "foreground": "var(--syntax-function)"
- }
- },
- {
- "scope": [
- "support.class",
- "support.type",
- "entity.name.type",
- "entity.name.namespace",
- "entity.other.attribute",
- "entity.name.scope-resolution",
- "entity.name.class",
- "storage.type.numeric.go",
- "storage.type.byte.go",
- "storage.type.boolean.go",
- "storage.type.string.go",
- "storage.type.uintptr.go",
- "storage.type.error.go",
- "storage.type.rune.go",
- "storage.type.cs",
- "storage.type.generic.cs",
- "storage.type.modifier.cs",
- "storage.type.variable.cs",
- "storage.type.annotation.java",
- "storage.type.generic.java",
- "storage.type.java",
- "storage.type.object.array.java",
- "storage.type.primitive.array.java",
- "storage.type.primitive.java",
- "storage.type.token.java",
- "storage.type.groovy",
- "storage.type.annotation.groovy",
- "storage.type.parameters.groovy",
- "storage.type.generic.groovy",
- "storage.type.object.array.groovy",
- "storage.type.primitive.array.groovy",
- "storage.type.primitive.groovy"
- ],
- "settings": {
- "foreground": "var(--syntax-type)"
- }
- },
- {
- "scope": [
- "meta.type.cast.expr",
- "meta.type.new.expr",
- "support.constant.math",
- "support.constant.dom",
- "support.constant.json",
- "entity.other.inherited-class",
- "punctuation.separator.namespace.ruby"
- ],
- "settings": {
- "foreground": "var(--syntax-type)"
- }
- },
- {
- "scope": [
- "keyword.control",
- "source.cpp keyword.operator.new",
- "keyword.operator.delete",
- "keyword.other.using",
- "keyword.other.directive.using",
- "keyword.other.operator",
- "entity.name.operator"
- ],
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": [
- "variable",
- "meta.definition.variable.name",
- "support.variable",
- "entity.name.variable",
- "constant.other.placeholder"
- ],
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": ["variable.other.constant", "variable.other.enummember"],
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": ["meta.object-literal.key"],
- "settings": {
- "foreground": "var(--syntax-variable)"
- }
- },
- {
- "scope": [
- "support.constant.property-value",
- "support.constant.font-name",
- "support.constant.media-type",
- "support.constant.media",
- "constant.other.color.rgb-value",
- "constant.other.rgb-value",
- "support.constant.color"
- ],
- "settings": {
- "foreground": "var(--syntax-string)"
- }
- },
- {
- "scope": [
- "punctuation.definition.group.regexp",
- "punctuation.definition.group.assertion.regexp",
- "punctuation.definition.character-class.regexp",
- "punctuation.character.set.begin.regexp",
- "punctuation.character.set.end.regexp",
- "keyword.operator.negation.regexp",
- "support.other.parenthesis.regexp"
- ],
- "settings": {
- "foreground": "var(--syntax-string)"
- }
- },
- {
- "scope": [
- "constant.character.character-class.regexp",
- "constant.other.character-class.set.regexp",
- "constant.other.character-class.regexp",
- "constant.character.set.regexp"
- ],
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": "keyword.operator.quantifier.regexp",
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": ["constant.character", "constant.other.option"],
- "settings": {
- "foreground": "var(--syntax-keyword)"
- }
- },
- {
- "scope": "constant.character.escape",
- "settings": {
- "foreground": "var(--syntax-operator)"
- }
- },
- {
- "scope": "entity.name.label",
- "settings": {
- "foreground": "var(--text-weak)"
- }
- }
- ],
- "type": "dark"
-}
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 8fc05c452..1785bdf0c 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -5,6 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
+import { makePersisted } from "@solid-primitives/storage"
export type LocalFile = FileNode &
Partial<{
@@ -195,18 +196,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 +240,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 +326,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 +400,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)
- },
}
})()
@@ -589,12 +457,60 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})()
+ const layout = (() => {
+ const [store, setStore] = makePersisted(
+ createStore({
+ sidebar: {
+ opened: true,
+ width: 240,
+ },
+ review: {
+ state: "closed" as "open" | "closed" | "tab",
+ },
+ }),
+ {
+ name: "default-layout",
+ },
+ )
+
+ return {
+ sidebar: {
+ opened: createMemo(() => store.sidebar.opened),
+ open() {
+ setStore("sidebar", "opened", true)
+ },
+ close() {
+ setStore("sidebar", "opened", false)
+ },
+ toggle() {
+ setStore("sidebar", "opened", (x) => !x)
+ },
+ width: createMemo(() => store.sidebar.width),
+ resize(width: number) {
+ setStore("sidebar", "width", width)
+ },
+ },
+ review: {
+ state: createMemo(() => store.review?.state ?? "closed"),
+ open() {
+ setStore("review", "state", "open")
+ },
+ close() {
+ setStore("review", "state", "closed")
+ },
+ tab() {
+ setStore("review", "state", "tab")
+ },
+ },
+ }
+ })()
+
const result = {
model,
agent,
file,
- session,
context,
+ layout,
}
return result
},
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
new file mode 100644
index 000000000..61fed945e
--- /dev/null
+++ b/packages/desktop/src/context/session.tsx
@@ -0,0 +1,217 @@
+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 diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
+
+ 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,
+ diffs,
+ 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 (tab !== "review") {
+ 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..bc9491fd3 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -9,7 +9,8 @@ import type {
File,
FileNode,
Project,
- Command,
+ FileDiff,
+ Todo,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createMemo } from "solid-js"
@@ -28,6 +29,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
config: Config
path: Path
session: Session[]
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ limit: number
message: {
[sessionID: string]: Message[]
}
@@ -44,6 +52,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: [],
provider: [],
session: [],
+ session_diff: {},
+ todo: {},
+ limit: 10,
message: {},
part: {},
node: [],
@@ -68,6 +79,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
break
}
+ case "session.diff":
+ setStore("session_diff", event.properties.sessionID, event.properties.diff)
+ break
+ case "todo.updated":
+ setStore("todo", event.properties.sessionID, event.properties.todos)
+ break
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
@@ -118,12 +135,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
- sdk.client.session.list().then((x) =>
- setStore(
- "session",
- (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
- ),
- ),
+ sdk.client.session.list().then((x) => {
+ const sessions = (x.data ?? [])
+ .slice()
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, store.limit)
+ setStore("session", sessions)
+ }),
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
@@ -167,22 +185,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
- async sync(sessionID: string, isRetry = false) {
- const [session, messages] = await Promise.all([
- sdk.client.session.get({ path: { id: sessionID } }),
- sdk.client.session.messages({ path: { id: sessionID } }),
+ async sync(sessionID: string, _isRetry = false) {
+ const [session, messages, todo, diff] = await Promise.all([
+ sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
+ sdk.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }),
+ sdk.client.session.todo({ path: { id: sessionID } }),
+ sdk.client.session.diff({ path: { id: sessionID } }),
])
-
- // If no messages and this might be a new session, retry after a delay
- if (!isRetry && messages.data!.length === 0) {
- setTimeout(() => this.sync(sessionID, true), 500)
- return
- }
-
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
- draft.session[match.index] = session.data!
+ if (match.found) draft.session[match.index] = session.data!
+ if (!match.found) draft.session.splice(match.index, 0, session.data!)
+ draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages
.data!.map((x) => x.info)
.slice()
@@ -193,9 +208,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.map(sanitizePart)
.sort((a, b) => a.id.localeCompare(b.id))
}
+ draft.session_diff[sessionID] = diff.data ?? []
}),
)
},
+ fetch: async (count = 10) => {
+ setStore("limit", (x) => x + count)
+ await load.session()
+ },
+ more: createMemo(() => store.session.length === store.limit),
},
load,
absolute,
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 9fe5da2f6..63d96ae84 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -3,18 +3,22 @@ import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
-import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
+import { Fonts, 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"
const url =
new URLSearchParams(document.location.search).get("url") ||
- (location.hostname.includes("opencode.ai") ? `http://${host}:${port}` : "/")
+ (location.hostname.includes("opencode.ai") || location.hostname.includes("localhost")
+ ? `http://${host}:${port}`
+ : "/")
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -25,22 +29,22 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
render(
() => (
- <ShikiProvider>
- <MarkedProvider>
- <SDKProvider url={url}>
- <SyncProvider>
- <LocalProvider>
- <MetaProvider>
- <Fonts />
- <Router>
- <Route path="/" component={Home} />
- </Router>
- </MetaProvider>
- </LocalProvider>
- </SyncProvider>
- </SDKProvider>
- </MarkedProvider>
- </ShikiProvider>
+ <MarkedProvider>
+ <SDKProvider url={url}>
+ <SyncProvider>
+ <LocalProvider>
+ <MetaProvider>
+ <Fonts />
+ <Router root={Layout}>
+ <Route path={["/", "/session"]} component={SessionLayout}>
+ <Route path="/:id?" component={Session} />
+ </Route>
+ </Router>
+ </MetaProvider>
+ </LocalProvider>
+ </SyncProvider>
+ </SDKProvider>
+ </MarkedProvider>
),
root!,
)
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..d88564007
--- /dev/null
+++ b/packages/desktop/src/pages/layout.tsx
@@ -0,0 +1,121 @@
+import { Button, Tooltip, DiffChanges, IconButton } from "@opencode-ai/ui"
+import { createMemo, For, ParentProps, Show } from "solid-js"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { A, useParams } from "@solidjs/router"
+import { useLocal } from "@/context/local"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ const sync = useSync()
+ const local = useLocal()
+
+ 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
+ classList={{
+ "@container w-14 pb-4 shrink-0 bg-background-weak": true,
+ "flex flex-col items-start self-stretch justify-between": true,
+ "border-r border-border-weak-base": true,
+ "w-70": local.layout.sidebar.opened(),
+ }}
+ >
+ <div class="flex flex-col justify-center items-start gap-4 self-stretch py-2 overflow-hidden mx-auto @[4rem]:mx-0">
+ <div class="h-8 shrink-0 flex items-center self-stretch px-3">
+ <Tooltip placement="right" value="Collapse sidebar">
+ <IconButton icon="layout-left" variant="ghost" size="large" onClick={local.layout.sidebar.toggle} />
+ </Tooltip>
+ </div>
+ <div class="w-full px-3">
+ <Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
+ New Session
+ </Button>
+ <Tooltip placement="right" value="New session">
+ <IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
+ </Tooltip>
+ </div>
+ <div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
+ <nav class="w-full">
+ <For each={sync.data.session}>
+ {(session) => {
+ 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 cursor-default"
+ >
+ <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">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+ <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+ </div>
+ </div>
+ </Tooltip>
+ </A>
+ )
+ }}
+ </For>
+ </nav>
+ <Show when={sync.session.more()}>
+ <button
+ class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
+ onClick={() => sync.session.fetch()}
+ >
+ Show more
+ </button>
+ </Show>
+ </div>
+ </div>
+ <div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
+ <Button
+ as={"a"}
+ href="https://opencode.ai/desktop-feedback"
+ target="_blank"
+ class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
+ variant="ghost"
+ icon="speech-bubble"
+ >
+ Share feedback
+ </Button>
+ <Tooltip placement="right" value="Share feedback">
+ <IconButton
+ as={"a"}
+ href="https://opencode.ai/desktop-feedback"
+ target="_blank"
+ icon="speech-bubble"
+ variant="ghost"
+ size="large"
+ class="@[4rem]:hidden stroke-[1.5px]"
+ />
+ </Tooltip>
+ </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..23d44cfcb
--- /dev/null
+++ b/packages/desktop/src/pages/session.tsx
@@ -0,0 +1,899 @@
+import {
+ SelectDialog,
+ IconButton,
+ Tabs,
+ Icon,
+ Accordion,
+ Diff,
+ Collapsible,
+ DiffChanges,
+ Message,
+ Typewriter,
+ Card,
+ Code,
+ Tooltip,
+ ProgressCircle,
+ Button,
+} 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 group-data-[selected]/tab:opacity-100
+ hover:bg-transparent
+ 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>
+ <Show when={local.layout.review.state() === "tab" && session.diffs().length}>
+ <Tabs.Trigger value="review" class="flex gap-3 items-center group/tab pr-1">
+ <Show when={session.diffs()}>
+ <DiffChanges changes={session.diffs()} variant="bars" />
+ </Show>
+ <div class="flex items-center gap-1.5">
+ <div>Review</div>
+ <Show when={session.info()?.summary?.files}>
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+ {session.info()?.summary?.files ?? 0}
+ </div>
+ </Show>
+ <IconButton
+ icon="close"
+ class="mt-0.5 -ml-1 opacity-0 group-data-[selected]/tab:opacity-100
+ hover:bg-transparent hover:opacity-100 group-hover/tab:opacity-100"
+ variant="ghost"
+ onClick={local.layout.review.close}
+ />
+ </div>
+ </Tabs.Trigger>
+ </Show>
+ <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">
+ <Tooltip value="Open file" class="flex items-center">
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() => setStore("fileSelectOpen", true)}
+ />
+ </Tooltip>
+ </div>
+ </Tabs.List>
+ </div>
+ <Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
+ <div
+ classList={{
+ "w-full grid flex-1 min-h-0": true,
+ "grid-cols-2": local.layout.review.state() === "open",
+ }}
+ >
+ <div class="relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 max-w-2xl mx-auto">
+ <Switch>
+ <Match when={session.id}>
+ <div class="h-8 flex shrink-0 self-stretch items-center justify-end">
+ <Show when={local.layout.review.state() === "closed" && session.diffs().length}>
+ <Button icon="layout-right" onClick={local.layout.review.open}>
+ Review
+ </Button>
+ </Show>
+ </div>
+ <div
+ classList={{
+ "flex-1 min-h-0 pb-20": true,
+ "flex items-start justify-start": local.layout.review.state() === "open",
+ }}
+ >
+ <Show when={session.messages.user().length > 1}>
+ <ul
+ role="list"
+ classList={{
+ "mr-8 shrink-0 flex flex-col items-start": true,
+ "absolute right-full w-60 @7xl:gap-2": true, // local.layout.review.state() !== "open",
+ "": local.layout.review.state() === "open",
+ }}
+ >
+ <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())
+
+ const handleClick = () => session.messages.setActive(message.id)
+
+ return (
+ <li
+ classList={{
+ "group/li flex items-center self-stretch justify-end": true,
+ "@7xl:justify-start": local.layout.review.state() !== "open",
+ }}
+ >
+ <Tooltip
+ placement="right"
+ gutter={8}
+ value={
+ <div class="flex items-center gap-2">
+ <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+ {message.summary?.title}
+ </div>
+ }
+ >
+ <button
+ data-active={session.messages.active()?.id === message.id}
+ onClick={handleClick}
+ classList={{
+ "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
+ "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
+ "@7xl:hidden": local.layout.review.state() !== "open",
+ }}
+ >
+ <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
+ </button>
+ </Tooltip>
+ <button
+ classList={{
+ "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
+ "@7xl:flex": local.layout.review.state() !== "open",
+ }}
+ onClick={handleClick}
+ >
+ <Switch>
+ <Match when={working()}>
+ <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
+ </Match>
+ <Match when={true}>
+ <DiffChanges changes={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 w-full 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-20"
+ >
+ {/* Title */}
+ <div class="flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10 pb-1">
+ <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-9">
+ <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 changes={diff} />
+ <Icon name="chevron-grabber-vertical" size="small" />
+ </div>
+ </div>
+ </Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content class="max-h-[360px] overflow-y-auto no-scrollbar">
+ <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>
+ </Match>
+ <Match when={true}>
+ <div class="size-full 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>
+ </Match>
+ </Switch>
+ <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>
+ <Show when={local.layout.review.state() === "open"}>
+ <div
+ classList={{
+ "relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0 border-l border-border-weak-base": true,
+ }}
+ >
+ <div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch">
+ <div class="flex items-center gap-x-3">
+ <Tooltip value="Close">
+ <IconButton icon="align-right" variant="ghost" onClick={local.layout.review.close} />
+ </Tooltip>
+ <Tooltip value="Open in tab">
+ <IconButton
+ icon="expand"
+ variant="ghost"
+ onClick={() => {
+ local.layout.review.tab()
+ session.layout.setActiveTab("review")
+ }}
+ />
+ </Tooltip>
+ </div>
+ </div>
+ <div class="text-14-medium text-text-strong">All changes</div>
+ <div class="h-full pb-40 overflow-y-auto no-scrollbar">
+ <Accordion class="w-full" multiple>
+ <For each={session.diffs()}>
+ {(diff) => (
+ <Accordion.Item value={diff.file} defaultOpen>
+ <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 changes={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>
+ </div>
+ </Show>
+ </div>
+ </Tabs.Content>
+ <Show when={local.layout.review.state() === "tab" && session.diffs().length}>
+ <Tabs.Content value="review" class="select-text">
+ <div
+ classList={{
+ "relative px-6 py-2 w-full flex flex-col gap-6 flex-1 min-h-0": true,
+ }}
+ >
+ <div class="h-8 w-full flex items-center justify-between shrink-0 self-stretch sticky top-0 bg-background-stronger z-100">
+ <div class="flex items-center gap-x-3"></div>
+ </div>
+ <div class="text-14-medium text-text-strong">All changes</div>
+ <div class="h-full pb-40 overflow-y-auto no-scrollbar">
+ <Accordion class="w-full" multiple>
+ <For each={session.diffs()}>
+ {(diff) => (
+ <Accordion.Item value={diff.file} defaultOpen>
+ <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 changes={diff} />
+ <Icon name="chevron-grabber-vertical" size="small" />
+ </div>
+ </div>
+ </Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <Diff
+ diffStyle="split"
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ }}
+ />
+ </Accordion.Content>
+ </Accordion.Item>
+ )}
+ </For>
+ </Accordion>
+ </div>
+ </div>
+ </Tabs.Content>
+ </Show>
+ <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 mt-3">
+ <Switch>
+ <Match when={file()}>
+ {(f) => (
+ <Code
+ file={{ name: f().path, contents: f().content?.content ?? "" }}
+ overflow="scroll"
+ class="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>
+ <Show when={session.layout.tabs.active}>
+ <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>
+ </Show>
+ <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>
+ )
+}
diff --git a/packages/desktop/src/sst-env.d.ts b/packages/desktop/src/sst-env.d.ts
index 47a8fbec7..1b1683a1e 100644
--- a/packages/desktop/src/sst-env.d.ts
+++ b/packages/desktop/src/sst-env.d.ts
@@ -2,9 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/// <reference types="vite/client" />
-interface ImportMetaEnv {
-
-}
+interface ImportMetaEnv {}
interface ImportMeta {
readonly env: ImportMetaEnv
-} \ No newline at end of file
+}