Compare commits

...

1 Commits

Author SHA1 Message Date
Adam
3ab23c45c3 chore: performance improvements 2026-02-09 19:08:36 -06:00
8 changed files with 151 additions and 49 deletions

View File

@@ -762,6 +762,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editor: () => editorRef,
isFocused,
isDialogActive: () => !!dialog.active,
draggingType: () => store.draggingType,
setDraggingType: (type) => setStore("draggingType", type),
focusEditor: () => {
editorRef.focus()

View File

@@ -11,6 +11,7 @@ type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined
isFocused: () => boolean
isDialogActive: () => boolean
draggingType: () => "image" | "@mention" | null
setDraggingType: (type: "image" | "@mention" | null) => void
focusEditor: () => void
addPart: (part: ContentPart) => void
@@ -21,6 +22,11 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const prompt = usePrompt()
const language = useLanguage()
const setDraggingType = (next: "image" | "@mention" | null) => {
if (input.draggingType() === next) return
input.setDraggingType(next)
}
const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
@@ -98,16 +104,20 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const hasFiles = event.dataTransfer?.types.includes("Files")
const hasText = event.dataTransfer?.types.includes("text/plain")
if (hasFiles) {
input.setDraggingType("image")
} else if (hasText) {
input.setDraggingType("@mention")
setDraggingType("image")
return
}
if (hasText) {
setDraggingType("@mention")
return
}
setDraggingType(null)
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (input.isDialogActive()) return
if (!event.relatedTarget) {
input.setDraggingType(null)
setDraggingType(null)
}
}
@@ -115,7 +125,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (input.isDialogActive()) return
event.preventDefault()
input.setDraggingType(null)
setDraggingType(null)
const plainText = event.dataTransfer?.getData("text/plain")
const filePrefix = "file:"

View File

@@ -1,7 +1,7 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js"
import { Component, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
@@ -209,6 +209,7 @@ interface ErrorPageProps {
export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform()
const language = useLanguage()
const details = createMemo(() => formatError(props.error, language.t))
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
@@ -237,7 +238,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
</div>
<TextField
value={formatError(props.error, language.t)}
value={details()}
readOnly
copyable
multiline

View File

@@ -24,14 +24,23 @@ export default function Home() {
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
return sync.data.project.reduce(
(list, project) => {
const time = project.time.updated ?? project.time.created
const index = list.findIndex((item) => (item.time.updated ?? item.time.created) < time)
if (index === -1) list.push(project)
else list.splice(index, 0, project)
if (list.length > 5) list.pop()
return list
},
sync.data.project.slice(0, 0),
)
})
function openProject(directory: string) {
function openProject(directory: string, doNavigate = true) {
layout.projects.open(directory)
server.projects.touch(directory)
if (!doNavigate) return
navigate(`/${base64Encode(directory)}`)
}
@@ -39,9 +48,14 @@ export default function Home() {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
for (const directory of result) {
openProject(directory)
openProject(directory, false)
}
} else if (result) {
const first = result[0]
if (!first) return
navigate(`/${base64Encode(first)}`)
return
}
if (result) {
openProject(result)
}
}

View File

@@ -563,9 +563,17 @@ export default function Layout(props: ParentProps) {
if (!pageReady()) return
if (!layoutReady()) return
const projects = layout.projects.list()
const byDirectory = new Map<string, LocalProject>()
for (const project of projects) {
byDirectory.set(project.worktree, project)
for (const directory of project.sandboxes ?? []) {
byDirectory.set(directory, project)
}
}
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
const project = byDirectory.get(directory)
if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false)
@@ -1473,6 +1481,16 @@ export default function Layout(props: ParentProps) {
globalSync.project.loadSessions(project.worktree)
})
const projectIndex = createMemo(() => {
const map = new Map<string, number>()
const projects = layout.projects.list()
for (let i = 0; i < projects.length; i++) {
const project = projects[i]
map.set(project.worktree, i)
}
return map
})
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
@@ -1483,10 +1501,10 @@ export default function Layout(props: ParentProps) {
function handleDragOver(event: DragEvent) {
const { draggable, droppable } = event
if (draggable && droppable) {
const projects = layout.projects.list()
const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== -1) {
const index = projectIndex()
const fromIndex = index.get(draggable.id.toString())
const toIndex = index.get(droppable.id.toString())
if (fromIndex !== undefined && toIndex !== undefined && fromIndex !== toIndex) {
layout.projects.move(draggable.id.toString(), toIndex)
}
}

View File

@@ -113,11 +113,16 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
})
const hoverMessages = createMemo(() =>
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
props.hoverSession() === props.session.id
? (sessionStore.message[props.session.id] ?? []).filter((message) => message.role === "user")
: [],
)
const hoverReady = createMemo(() =>
props.hoverSession() === props.session.id ? sessionStore.message[props.session.id] !== undefined : true,
)
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const hoverOpen = createMemo(() => props.hoverSession() === props.session.id)
const isActive = createMemo(() => props.session.id === params.id)
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -148,8 +153,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch}
onMouseEnter={scheduleHoverPrefetch}
onMouseLeave={cancelHoverPrefetch}
onFocus={() => props.prefetchSession(props.session, "high")}
onClick={() => {
props.setHoverSession(undefined)
@@ -213,7 +216,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
shift={-2}
trigger={item}
mount={!props.mobile ? props.nav() : undefined}
open={props.hoverSession() === props.session.id}
open={hoverOpen()}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
>
<Show

View File

@@ -21,17 +21,66 @@ type Input = {
export const pickVisibleId = (list: Visible[], line: number) => {
if (list.length === 0) return
const sorted = [...list].sort((a, b) => {
if (b.ratio !== a.ratio) return b.ratio - a.ratio
let best = list[0]
for (let i = 1; i < list.length; i++) {
const next = list[i]
if (next.ratio > best.ratio) {
best = next
continue
}
if (next.ratio < best.ratio) continue
const da = Math.abs(a.top - line)
const db = Math.abs(b.top - line)
if (da !== db) return da - db
const bestDistance = Math.abs(best.top - line)
const nextDistance = Math.abs(next.top - line)
if (nextDistance < bestDistance) {
best = next
continue
}
if (nextDistance > bestDistance) continue
return a.top - b.top
})
if (next.top < best.top) best = next
}
return sorted[0]?.id
return best.id
}
const pickVisibleMapId = (list: Map<string, { ratio: number; top: number }>, line: number) => {
let bestID: string | undefined
let bestRatio = 0
let bestTop = 0
for (const [id, item] of list) {
if (!bestID) {
bestID = id
bestRatio = item.ratio
bestTop = item.top
continue
}
if (item.ratio > bestRatio) {
bestID = id
bestRatio = item.ratio
bestTop = item.top
continue
}
if (item.ratio < bestRatio) continue
const bestDistance = Math.abs(bestTop - line)
const nextDistance = Math.abs(item.top - line)
if (nextDistance < bestDistance) {
bestID = id
bestTop = item.top
continue
}
if (nextDistance > bestDistance) continue
if (item.top < bestTop) {
bestID = id
bestTop = item.top
}
}
return bestID
}
export const pickOffsetId = (list: Offset[], cutoff: number) => {
@@ -107,16 +156,9 @@ export const createScrollSpy = (input: Input) => {
const el = root
if (!el) return
const line = el.getBoundingClientRect().top + 100
const line = el.scrollTop + 100
const next =
pickVisibleId(
[...visible].map(([k, v]) => ({
id: k,
ratio: v.ratio,
top: v.top,
})),
line,
) ??
pickVisibleMapId(visible, line) ??
(() => {
if (dirty) refreshOffset()
return pickOffsetId(offset, el.scrollTop + 100)
@@ -148,9 +190,13 @@ export const createScrollSpy = (input: Input) => {
continue
}
const rootTop = entry.rootBounds?.top
visible.set(key, {
ratio: entry.intersectionRatio,
top: entry.boundingClientRect.top,
top:
rootTop === undefined
? entry.boundingClientRect.top - el.getBoundingClientRect().top + el.scrollTop
: entry.boundingClientRect.top - rootTop + el.scrollTop,
})
}
@@ -188,7 +234,7 @@ export const createScrollSpy = (input: Input) => {
dirty = true
schedule()
})
mo.observe(el, { subtree: true, childList: true, characterData: true })
mo.observe(el, { subtree: true, childList: true })
}
dirty = true

View File

@@ -1,4 +1,4 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { UserMessage } from "@opencode-ai/sdk/v2"
export const messageIdFromHash = (hash: string) => {
@@ -35,6 +35,16 @@ export const useSessionHashScroll = (input: {
window.history.replaceState(null, "", `#${input.anchor(id)}`)
}
const visible = createMemo(() => {
const map = new Map<string, { message: UserMessage; index: number }>()
const list = input.visibleUserMessages()
for (let i = 0; i < list.length; i++) {
const message = list[i]
map.set(message.id, { message, index: i })
}
return map
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = input.scroller()
if (!root) return false
@@ -49,9 +59,8 @@ export const useSessionHashScroll = (input: {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
input.setActiveMessage(message)
const msgs = input.visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < input.turnStart()) {
const index = visible().get(message.id)?.index
if (index !== undefined && index < input.turnStart()) {
input.setTurnStart(index)
input.scheduleTurnBackfill()
@@ -107,7 +116,7 @@ export const useSessionHashScroll = (input: {
const messageId = messageIdFromHash(hash)
if (messageId) {
input.autoScroll.pause()
const msg = input.visibleUserMessages().find((m) => m.id === messageId)
const msg = visible().get(messageId)?.message
if (msg) {
scrollToMessage(msg, behavior)
return
@@ -144,14 +153,14 @@ export const useSessionHashScroll = (input: {
createEffect(() => {
if (!input.sessionID() || !input.messagesReady()) return
input.visibleUserMessages().length
const lookup = visible()
input.turnStart()
const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
const msg = input.visibleUserMessages().find((m) => m.id === targetId)
const msg = lookup.get(targetId)?.message
if (!msg) return
if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)