summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz
opencode-a4bc883595df9ea0f752079519081bc602408553.zip
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/package.json3
-rw-r--r--packages/app/src/components/file-tree.test.ts77
-rw-r--r--packages/app/src/components/file-tree.tsx60
-rw-r--r--packages/app/src/components/prompt-input.tsx910
-rw-r--r--packages/app/src/components/prompt-input/attachments.ts132
-rw-r--r--packages/app/src/components/prompt-input/editor-dom.test.ts51
-rw-r--r--packages/app/src/components/prompt-input/editor-dom.ts135
-rw-r--r--packages/app/src/components/prompt-input/history.test.ts69
-rw-r--r--packages/app/src/components/prompt-input/history.ts160
-rw-r--r--packages/app/src/components/prompt-input/submit.ts587
-rw-r--r--packages/app/src/components/session-context-usage.tsx31
-rw-r--r--packages/app/src/components/session/session-context-metrics.test.ts93
-rw-r--r--packages/app/src/components/session/session-context-metrics.ts94
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx47
-rw-r--r--packages/app/src/context/comments.test.ts111
-rw-r--r--packages/app/src/context/comments.tsx152
-rw-r--r--packages/app/src/context/file-content-eviction-accounting.test.ts85
-rw-r--r--packages/app/src/context/file.tsx143
-rw-r--r--packages/app/src/context/layout.test.ts69
-rw-r--r--packages/app/src/context/layout.tsx94
-rw-r--r--packages/app/src/context/terminal.test.ts38
-rw-r--r--packages/app/src/context/terminal.tsx24
-rw-r--r--packages/app/src/pages/session.tsx503
-rw-r--r--packages/app/src/pages/session/helpers.test.ts61
-rw-r--r--packages/app/src/pages/session/helpers.ts38
-rw-r--r--packages/app/src/pages/session/scroll-spy.test.ts127
-rw-r--r--packages/app/src/pages/session/scroll-spy.ts274
-rw-r--r--packages/app/src/utils/scoped-cache.test.ts69
-rw-r--r--packages/app/src/utils/scoped-cache.ts104
29 files changed, 2941 insertions, 1400 deletions
diff --git a/packages/app/package.json b/packages/app/package.json
index bcdcece3a..12b805360 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -14,7 +14,8 @@
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
- "test": "playwright test",
+ "test": "bun run test:unit",
+ "test:unit": "bun test ./src",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui",
diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts
new file mode 100644
index 000000000..eb048e29e
--- /dev/null
+++ b/packages/app/src/components/file-tree.test.ts
@@ -0,0 +1,77 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+
+let shouldListRoot: typeof import("./file-tree").shouldListRoot
+let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
+let dirsToExpand: typeof import("./file-tree").dirsToExpand
+
+beforeAll(async () => {
+ mock.module("@solidjs/router", () => ({
+ useParams: () => ({}),
+ }))
+ mock.module("@/context/file", () => ({
+ useFile: () => ({
+ tree: {
+ state: () => undefined,
+ list: () => Promise.resolve(),
+ children: () => [],
+ expand: () => {},
+ collapse: () => {},
+ },
+ }),
+ }))
+ mock.module("@opencode-ai/ui/collapsible", () => ({
+ Collapsible: {
+ Trigger: (props: { children?: unknown }) => props.children,
+ Content: (props: { children?: unknown }) => props.children,
+ },
+ }))
+ mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
+ mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
+ mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
+ const mod = await import("./file-tree")
+ shouldListRoot = mod.shouldListRoot
+ shouldListExpanded = mod.shouldListExpanded
+ dirsToExpand = mod.dirsToExpand
+})
+
+describe("file tree fetch discipline", () => {
+ test("root lists on mount unless already loaded or loading", () => {
+ expect(shouldListRoot({ level: 0 })).toBe(true)
+ expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
+ expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
+ expect(shouldListRoot({ level: 1 })).toBe(false)
+ })
+
+ test("nested dirs list only when expanded and stale", () => {
+ expect(shouldListExpanded({ level: 1 })).toBe(false)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
+ expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
+ })
+
+ test("allowed auto-expand picks only collapsed dirs", () => {
+ const expanded = new Set<string>()
+ const filter = { dirs: new Set(["src", "src/components"]) }
+
+ const first = dirsToExpand({
+ level: 0,
+ filter,
+ expanded: (dir) => expanded.has(dir),
+ })
+
+ expect(first).toEqual(["src", "src/components"])
+
+ for (const dir of first) expanded.add(dir)
+
+ const second = dirsToExpand({
+ level: 0,
+ filter,
+ expanded: (dir) => expanded.has(dir),
+ })
+
+ expect(second).toEqual([])
+ expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
+ })
+})
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index 491a16de7..183c1555b 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/packages/app/src/components/file-tree.tsx
@@ -8,6 +8,7 @@ import {
createMemo,
For,
Match,
+ on,
Show,
splitProps,
Switch,
@@ -25,6 +26,34 @@ type Filter = {
dirs: Set<string>
}
+export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
+ if (input.level !== 0) return false
+ if (input.dir?.loaded) return false
+ if (input.dir?.loading) return false
+ return true
+}
+
+export function shouldListExpanded(input: {
+ level: number
+ dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
+}) {
+ if (input.level === 0) return false
+ if (!input.dir?.expanded) return false
+ if (input.dir.loaded) return false
+ if (input.dir.loading) return false
+ return true
+}
+
+export function dirsToExpand(input: {
+ level: number
+ filter?: { dirs: Set<string> }
+ expanded: (dir: string) => boolean
+}) {
+ if (input.level !== 0) return []
+ if (!input.filter) return []
+ return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
+}
+
export default function FileTree(props: {
path: string
class?: string
@@ -111,19 +140,30 @@ export default function FileTree(props: {
createEffect(() => {
const current = filter()
- if (!current) return
- if (level !== 0) return
-
- for (const dir of current.dirs) {
- const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
- if (expanded) continue
- file.tree.expand(dir)
- }
+ const dirs = dirsToExpand({
+ level,
+ filter: current,
+ expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
+ })
+ for (const dir of dirs) file.tree.expand(dir)
})
+ createEffect(
+ on(
+ () => props.path,
+ (path) => {
+ const dir = untrack(() => file.tree.state(path))
+ if (!shouldListRoot({ level, dir })) return
+ void file.tree.list(path)
+ },
+ { defer: false },
+ ),
+ )
+
createEffect(() => {
- const path = props.path
- untrack(() => void file.tree.list(path))
+ const dir = file.tree.state(props.path)
+ if (!shouldListExpanded({ level, dir })) return
+ void file.tree.list(props.path)
})
const nodes = createMemo(() => {
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 9186dcfa3..3f0ba314e 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1,21 +1,9 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
-import {
- createEffect,
- on,
- Component,
- Show,
- For,
- onMount,
- onCleanup,
- Switch,
- Match,
- createMemo,
- createSignal,
-} from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
-import { useFile, type FileSelection } from "@/context/file"
+import { useFile } from "@/context/file"
import {
ContentPart,
DEFAULT_PROMPT,
@@ -28,7 +16,7 @@ import {
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon"
@@ -47,27 +35,13 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
-import { Identifier } from "@/utils/id"
-import { Worktree as WorktreeState } from "@/utils/worktree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
-import { useGlobalSync } from "@/context/global-sync"
-import { usePlatform } from "@/context/platform"
-import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
-import { Binary } from "@opencode-ai/util/binary"
-import { showToast } from "@opencode-ai/ui/toast"
-import { base64Encode } from "@opencode-ai/util/encode"
-
-const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
-const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
-
-type PendingPrompt = {
- abort: AbortController
- cleanup: VoidFunction
-}
-
-const pending = new Map<string, PendingPrompt>()
+import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
+import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
+import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
+import { createPromptSubmit } from "./prompt-input/submit"
interface PromptInputProps {
class?: string
@@ -116,11 +90,8 @@ interface SlashCommand {
}
export const PromptInput: Component<PromptInputProps> = (props) => {
- const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
- const globalSync = useGlobalSync()
- const platform = usePlatform()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
@@ -272,20 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
- const clonePromptParts = (prompt: Prompt): Prompt =>
- prompt.map((part) => {
- if (part.type === "text") return { ...part }
- if (part.type === "image") return { ...part }
- if (part.type === "agent") return { ...part }
- return {
- ...part,
- selection: part.selection ? { ...part.selection } : undefined,
- }
- })
-
- const promptLength = (prompt: Prompt) =>
- prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
-
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
@@ -329,110 +286,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
- const addImageAttachment = async (file: File) => {
- if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
-
- const reader = new FileReader()
- reader.onload = () => {
- const dataUrl = reader.result as string
- const attachment: ImageAttachmentPart = {
- type: "image",
- id: crypto.randomUUID(),
- filename: file.name,
- mime: file.type,
- dataUrl,
- }
- const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
- prompt.set([...prompt.current(), attachment], cursorPosition)
- }
- reader.readAsDataURL(file)
- }
-
- const removeImageAttachment = (id: string) => {
- const current = prompt.current()
- const next = current.filter((part) => part.type !== "image" || part.id !== id)
- prompt.set(next, prompt.cursor())
- }
-
- const handlePaste = async (event: ClipboardEvent) => {
- if (!isFocused()) return
- const clipboardData = event.clipboardData
- if (!clipboardData) return
-
- event.preventDefault()
- event.stopPropagation()
-
- const items = Array.from(clipboardData.items)
- const fileItems = items.filter((item) => item.kind === "file")
- const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
-
- if (imageItems.length > 0) {
- for (const item of imageItems) {
- const file = item.getAsFile()
- if (file) await addImageAttachment(file)
- }
- return
- }
-
- if (fileItems.length > 0) {
- showToast({
- title: language.t("prompt.toast.pasteUnsupported.title"),
- description: language.t("prompt.toast.pasteUnsupported.description"),
- })
- return
- }
-
- const plainText = clipboardData.getData("text/plain") ?? ""
- if (!plainText) return
- addPart({ type: "text", content: plainText, start: 0, end: 0 })
- }
-
- const handleGlobalDragOver = (event: DragEvent) => {
- if (dialog.active) return
-
- event.preventDefault()
- const hasFiles = event.dataTransfer?.types.includes("Files")
- if (hasFiles) {
- setStore("dragging", true)
- }
- }
-
- const handleGlobalDragLeave = (event: DragEvent) => {
- if (dialog.active) return
-
- // relatedTarget is null when leaving the document window
- if (!event.relatedTarget) {
- setStore("dragging", false)
- }
- }
-
- const handleGlobalDrop = async (event: DragEvent) => {
- if (dialog.active) return
-
- event.preventDefault()
- setStore("dragging", false)
-
- const dropped = event.dataTransfer?.files
- if (!dropped) return
-
- for (const file of Array.from(dropped)) {
- if (ACCEPTED_FILE_TYPES.includes(file.type)) {
- await addImageAttachment(file)
- }
- }
- }
-
- onMount(() => {
- document.addEventListener("dragover", handleGlobalDragOver)
- document.addEventListener("dragleave", handleGlobalDragLeave)
- document.addEventListener("drop", handleGlobalDrop)
- })
- onCleanup(() => {
- document.removeEventListener("dragover", handleGlobalDragOver)
- document.removeEventListener("dragleave", handleGlobalDragLeave)
- document.removeEventListener("drop", handleGlobalDrop)
- })
-
createEffect(() => {
if (!isFocused()) setStore("popover", null)
})
@@ -826,36 +679,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
queueScroll()
}
- const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
- let remaining = offset
- const nodes = Array.from(editorRef.childNodes)
-
- for (const node of nodes) {
- const length = getNodeLength(node)
- const isText = node.nodeType === Node.TEXT_NODE
- const isPill =
- node.nodeType === Node.ELEMENT_NODE &&
- ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
- if (isText && remaining <= length) {
- if (edge === "start") range.setStart(node, remaining)
- if (edge === "end") range.setEnd(node, remaining)
- return
- }
-
- if ((isPill || isBreak) && remaining <= length) {
- if (edge === "start" && remaining === 0) range.setStartBefore(node)
- if (edge === "start" && remaining > 0) range.setStartAfter(node)
- if (edge === "end" && remaining === 0) range.setEndBefore(node)
- if (edge === "end" && remaining > 0) range.setEndAfter(node)
- return
- }
-
- remaining -= length
- }
- }
-
const addPart = (part: ContentPart) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
@@ -873,8 +696,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
- setRangeEdge(range, "start", start)
- setRangeEdge(range, "end", cursorPosition)
+ setRangeEdge(editorRef, range, "start", start)
+ setRangeEdge(editorRef, range, "end", cursorPosition)
}
range.deleteContents()
@@ -913,81 +736,57 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popover", null)
}
- const abort = async () => {
- const sessionID = params.id
- if (!sessionID) return Promise.resolve()
- const queued = pending.get(sessionID)
- if (queued) {
- queued.abort.abort()
- queued.cleanup()
- pending.delete(sessionID)
- return Promise.resolve()
- }
- return sdk.client.session
- .abort({
- sessionID,
- })
- .catch(() => {})
- }
-
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
- const text = prompt
- .map((p) => ("content" in p ? p.content : ""))
- .join("")
- .trim()
- const hasImages = prompt.some((part) => part.type === "image")
- if (!text && !hasImages) return
-
- const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
- const lastEntry = currentHistory.entries[0]
- if (lastEntry && isPromptEqual(lastEntry, entry)) return
-
- setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
+ const next = prependHistoryEntry(currentHistory.entries, prompt)
+ if (next === currentHistory.entries) return
+ setCurrentHistory("entries", next)
}
const navigateHistory = (direction: "up" | "down") => {
- const entries = store.mode === "shell" ? shellHistory.entries : history.entries
- const current = store.historyIndex
-
- if (direction === "up") {
- if (entries.length === 0) return false
- if (current === -1) {
- setStore("savedPrompt", clonePromptParts(prompt.current()))
- setStore("historyIndex", 0)
- applyHistoryPrompt(entries[0], "start")
- return true
- }
- if (current < entries.length - 1) {
- const next = current + 1
- setStore("historyIndex", next)
- applyHistoryPrompt(entries[next], "start")
- return true
- }
- return false
- }
+ const result = navigatePromptHistory({
+ direction,
+ entries: store.mode === "shell" ? shellHistory.entries : history.entries,
+ historyIndex: store.historyIndex,
+ currentPrompt: prompt.current(),
+ savedPrompt: store.savedPrompt,
+ })
+ if (!result.handled) return false
+ setStore("historyIndex", result.historyIndex)
+ setStore("savedPrompt", result.savedPrompt)
+ applyHistoryPrompt(result.prompt, result.cursor)
+ return true
+ }
- if (current > 0) {
- const next = current - 1
- setStore("historyIndex", next)
- applyHistoryPrompt(entries[next], "end")
- return true
- }
- if (current === 0) {
- setStore("historyIndex", -1)
- const saved = store.savedPrompt
- if (saved) {
- applyHistoryPrompt(saved, "end")
- setStore("savedPrompt", null)
- return true
- }
- applyHistoryPrompt(DEFAULT_PROMPT, "end")
- return true
- }
+ const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
+ editor: () => editorRef,
+ isFocused,
+ isDialogActive: () => !!dialog.active,
+ setDragging: (value) => setStore("dragging", value),
+ addPart,
+ })
- return false
- }
+ const { abort, handleSubmit } = createPromptSubmit({
+ info,
+ imageAttachments,
+ commentCount,
+ mode: () => store.mode,
+ working,
+ editor: () => editorRef,
+ queueScroll,
+ promptLength,
+ addToHistory,
+ resetHistoryNavigation: () => {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ },
+ setMode: (mode) => setStore("mode", mode),
+ setPopover: (popover) => setStore("popover", popover),
+ newSessionWorktree: props.newSessionWorktree,
+ onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+ onSubmit: props.onSubmit,
+ })
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Backspace") {
@@ -1127,503 +926,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
- const handleSubmit = async (event: Event) => {
- event.preventDefault()
-
- const currentPrompt = prompt.current()
- const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
- const images = imageAttachments().slice()
- const mode = store.mode
-
- if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
- if (working()) abort()
- return
- }
-
- const currentModel = local.model.current()
- const currentAgent = local.agent.current()
- if (!currentModel || !currentAgent) {
- showToast({
- title: language.t("prompt.toast.modelAgentRequired.title"),
- description: language.t("prompt.toast.modelAgentRequired.description"),
- })
- return
- }
-
- const errorMessage = (err: unknown) => {
- if (err && typeof err === "object" && "data" in err) {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message
- }
- if (err instanceof Error) return err.message
- return language.t("common.requestFailed")
- }
-
- addToHistory(currentPrompt, mode)
- setStore("historyIndex", -1)
- setStore("savedPrompt", null)
-
- const projectDirectory = sdk.directory
- const isNewSession = !params.id
- const worktreeSelection = props.newSessionWorktree ?? "main"
-
- let sessionDirectory = projectDirectory
- let client = sdk.client
-
- if (isNewSession) {
- if (worktreeSelection === "create") {
- const createdWorktree = await client.worktree
- .create({ directory: projectDirectory })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.worktreeCreateFailed.title"),
- description: errorMessage(err),
- })
- return undefined
- })
-
- if (!createdWorktree?.directory) {
- showToast({
- title: language.t("prompt.toast.worktreeCreateFailed.title"),
- description: language.t("common.requestFailed"),
- })
- return
- }
- WorktreeState.pending(createdWorktree.directory)
- sessionDirectory = createdWorktree.directory
- }
-
- if (worktreeSelection !== "main" && worktreeSelection !== "create") {
- sessionDirectory = worktreeSelection
- }
-
- if (sessionDirectory !== projectDirectory) {
- client = createOpencodeClient({
- baseUrl: sdk.url,
- fetch: platform.fetch,
- directory: sessionDirectory,
- throwOnError: true,
- })
- globalSync.child(sessionDirectory)
- }
-
- props.onNewSessionWorktreeReset?.()
- }
-
- let session = info()
- if (!session && isNewSession) {
- session = await client.session
- .create()
- .then((x) => x.data ?? undefined)
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.sessionCreateFailed.title"),
- description: errorMessage(err),
- })
- return undefined
- })
- if (session) {
- layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
- navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
- }
- }
- if (!session) return
-
- props.onSubmit?.()
-
- const model = {
- modelID: currentModel.id,
- providerID: currentModel.provider.id,
- }
- const agent = currentAgent.name
- const variant = local.model.variant.current()
-
- const clearInput = () => {
- prompt.reset()
- setStore("mode", "normal")
- setStore("popover", null)
- }
-
- const restoreInput = () => {
- prompt.set(currentPrompt, promptLength(currentPrompt))
- setStore("mode", mode)
- setStore("popover", null)
- requestAnimationFrame(() => {
- editorRef.focus()
- setCursorPosition(editorRef, promptLength(currentPrompt))
- queueScroll()
- })
- }
-
- if (mode === "shell") {
- clearInput()
- client.session
- .shell({
- sessionID: session.id,
- agent,
- model,
- command: text,
- })
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.shellSendFailed.title"),
- description: errorMessage(err),
- })
- restoreInput()
- })
- return
- }
-
- if (text.startsWith("/")) {
- const [cmdName, ...args] = text.split(" ")
- const commandName = cmdName.slice(1)
- const customCommand = sync.data.command.find((c) => c.name === commandName)
- if (customCommand) {
- clearInput()
- client.session
- .command({
- sessionID: session.id,
- command: commandName,
- arguments: args.join(" "),
- agent,
- model: `${model.providerID}/${model.modelID}`,
- variant,
- parts: images.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: attachment.mime,
- url: attachment.dataUrl,
- filename: attachment.filename,
- })),
- })
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.commandSendFailed.title"),
- description: errorMessage(err),
- })
- restoreInput()
- })
- return
- }
- }
-
- const toAbsolutePath = (path: string) =>
- path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
-
- const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
- const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
-
- const fileAttachmentParts = fileAttachments.map((attachment) => {
- const absolute = toAbsolutePath(attachment.path)
- const query = attachment.selection
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
- : ""
- return {
- id: Identifier.ascending("part"),
- 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,
- },
- }
- })
-
- const agentAttachmentParts = agentAttachments.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "agent" as const,
- name: attachment.name,
- source: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- }))
-
- const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
-
- const context = prompt.context.items().slice()
-
- const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
-
- const contextParts: Array<
- | {
- id: string
- type: "text"
- text: string
- synthetic?: boolean
- }
- | {
- id: string
- type: "file"
- mime: string
- url: string
- filename?: string
- }
- > = []
-
- const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
- const range =
- start === undefined || end === undefined
- ? "this file"
- : start === end
- ? `line ${start}`
- : `lines ${start} through ${end}`
-
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
- }
-
- const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => {
- const absolute = toAbsolutePath(input.path)
- const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : ""
- const url = `file://${absolute}${query}`
-
- const comment = input.comment?.trim()
- if (!comment && usedUrls.has(url)) return
- usedUrls.add(url)
-
- if (comment) {
- contextParts.push({
- id: Identifier.ascending("part"),
- type: "text",
- text: commentNote(input.path, input.selection, comment),
- synthetic: true,
- })
- }
-
- contextParts.push({
- id: Identifier.ascending("part"),
- type: "file",
- mime: "text/plain",
- url,
- filename: getFilename(input.path),
- })
- }
-
- for (const item of context) {
- if (item.type !== "file") continue
- addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
- }
-
- const imageAttachmentParts = images.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: attachment.mime,
- url: attachment.dataUrl,
- filename: attachment.filename,
- }))
-
- const messageID = Identifier.ascending("message")
- const textPart = {
- id: Identifier.ascending("part"),
- type: "text" as const,
- text,
- }
- const requestParts = [
- textPart,
- ...fileAttachmentParts,
- ...contextParts,
- ...agentAttachmentParts,
- ...imageAttachmentParts,
- ]
-
- const optimisticParts = requestParts.map((part) => ({
- ...part,
- sessionID: session.id,
- messageID,
- })) as unknown as Part[]
-
- const optimisticMessage: Message = {
- id: messageID,
- sessionID: session.id,
- role: "user",
- time: { created: Date.now() },
- agent,
- model,
- }
-
- const addOptimisticMessage = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set(
- produce((draft) => {
- const messages = draft.message[session.id]
- if (!messages) {
- draft.message[session.id] = [optimisticMessage]
- } else {
- const result = Binary.search(messages, messageID, (m) => m.id)
- messages.splice(result.index, 0, optimisticMessage)
- }
- draft.part[messageID] = optimisticParts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }),
- )
- return
- }
-
- globalSync.child(sessionDirectory)[1](
- produce((draft) => {
- const messages = draft.message[session.id]
- if (!messages) {
- draft.message[session.id] = [optimisticMessage]
- } else {
- const result = Binary.search(messages, messageID, (m) => m.id)
- messages.splice(result.index, 0, optimisticMessage)
- }
- draft.part[messageID] = optimisticParts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
- }),
- )
- }
-
- const removeOptimisticMessage = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set(
- produce((draft) => {
- const messages = draft.message[session.id]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) messages.splice(result.index, 1)
- }
- delete draft.part[messageID]
- }),
- )
- return
- }
-
- globalSync.child(sessionDirectory)[1](
- produce((draft) => {
- const messages = draft.message[session.id]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) messages.splice(result.index, 1)
- }
- delete draft.part[messageID]
- }),
- )
- }
-
- for (const item of commentItems) {
- prompt.context.remove(item.key)
- }
-
- clearInput()
- addOptimisticMessage()
-
- const waitForWorktree = async () => {
- const worktree = WorktreeState.get(sessionDirectory)
- if (!worktree || worktree.status !== "pending") return true
-
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "busy" })
- }
-
- const controller = new AbortController()
-
- const cleanup = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
- }
- removeOptimisticMessage()
- for (const item of commentItems) {
- prompt.context.add({
- type: "file",
- path: item.path,
- selection: item.selection,
- comment: item.comment,
- commentID: item.commentID,
- commentOrigin: item.commentOrigin,
- preview: item.preview,
- })
- }
- restoreInput()
- }
-
- pending.set(session.id, { abort: controller, cleanup })
-
- const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
- if (controller.signal.aborted) {
- resolve({ status: "failed", message: "aborted" })
- return
- }
- controller.signal.addEventListener(
- "abort",
- () => {
- resolve({ status: "failed", message: "aborted" })
- },
- { once: true },
- )
- })
-
- const timeoutMs = 5 * 60 * 1000
- const timer = { id: undefined as number | undefined }
- const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
- timer.id = window.setTimeout(() => {
- resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
- }, timeoutMs)
- })
-
- const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => {
- if (timer.id === undefined) return
- clearTimeout(timer.id)
- })
- pending.delete(session.id)
- if (controller.signal.aborted) return false
- if (result.status === "failed") throw new Error(result.message)
- return true
- }
-
- const send = async () => {
- const ok = await waitForWorktree()
- if (!ok) return
- await client.session.prompt({
- sessionID: session.id,
- agent,
- model,
- messageID,
- parts: requestParts,
- variant,
- })
- }
-
- void send().catch((err) => {
- pending.delete(session.id)
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
- }
- showToast({
- title: language.t("prompt.toast.promptSendFailed.title"),
- description: errorMessage(err),
- })
- removeOptimisticMessage()
- for (const item of commentItems) {
- prompt.context.add({
- type: "file",
- path: item.path,
- selection: item.selection,
- comment: item.comment,
- commentID: item.commentID,
- commentOrigin: item.commentOrigin,
- preview: item.preview,
- })
- }
- restoreInput()
- })
- }
-
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
@@ -2087,109 +1389,3 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
)
}
-
-function createTextFragment(content: string): DocumentFragment {
- const fragment = document.createDocumentFragment()
- const segments = content.split("\n")
- segments.forEach((segment, index) => {
- if (segment) {
- fragment.appendChild(document.createTextNode(segment))
- } else if (segments.length > 1) {
- fragment.appendChild(document.createTextNode("\u200B"))
- }
- if (index < segments.length - 1) {
- fragment.appendChild(document.createElement("br"))
- }
- })
- return fragment
-}
-
-function getNodeLength(node: Node): number {
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
- return (node.textContent ?? "").replace(/\u200B/g, "").length
-}
-
-function getTextLength(node: Node): number {
- if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
- let length = 0
- for (const child of Array.from(node.childNodes)) {
- length += getTextLength(child)
- }
- return length
-}
-
-function getCursorPosition(parent: HTMLElement): number {
- const selection = window.getSelection()
- if (!selection || selection.rangeCount === 0) return 0
- const range = selection.getRangeAt(0)
- if (!parent.contains(range.startContainer)) return 0
- const preCaretRange = range.cloneRange()
- preCaretRange.selectNodeContents(parent)
- preCaretRange.setEnd(range.startContainer, range.startOffset)
- return getTextLength(preCaretRange.cloneContents())
-}
-
-function setCursorPosition(parent: HTMLElement, position: number) {
- let remaining = position
- let node = parent.firstChild
- while (node) {
- const length = getNodeLength(node)
- const isText = node.nodeType === Node.TEXT_NODE
- const isPill =
- node.nodeType === Node.ELEMENT_NODE &&
- ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
- if (isText && remaining <= length) {
- const range = document.createRange()
- const selection = window.getSelection()
- range.setStart(node, remaining)
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- return
- }
-
- if ((isPill || isBreak) && remaining <= length) {
- const range = document.createRange()
- const selection = window.getSelection()
- if (remaining === 0) {
- range.setStartBefore(node)
- }
- if (remaining > 0 && isPill) {
- range.setStartAfter(node)
- }
- if (remaining > 0 && isBreak) {
- const next = node.nextSibling
- if (next && next.nodeType === Node.TEXT_NODE) {
- range.setStart(next, 0)
- }
- if (!next || next.nodeType !== Node.TEXT_NODE) {
- range.setStartAfter(node)
- }
- }
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- return
- }
-
- remaining -= length
- node = node.nextSibling
- }
-
- const fallbackRange = document.createRange()
- const fallbackSelection = window.getSelection()
- const last = parent.lastChild
- if (last && last.nodeType === Node.TEXT_NODE) {
- const len = last.textContent ? last.textContent.length : 0
- fallbackRange.setStart(last, len)
- }
- if (!last || last.nodeType !== Node.TEXT_NODE) {
- fallbackRange.selectNodeContents(parent)
- }
- fallbackRange.collapse(false)
- fallbackSelection?.removeAllRanges()
- fallbackSelection?.addRange(fallbackRange)
-}
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
new file mode 100644
index 000000000..4ea2cfb90
--- /dev/null
+++ b/packages/app/src/components/prompt-input/attachments.ts
@@ -0,0 +1,132 @@
+import { onCleanup, onMount } from "solid-js"
+import { showToast } from "@opencode-ai/ui/toast"
+import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
+import { useLanguage } from "@/context/language"
+import { getCursorPosition } from "./editor-dom"
+
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
+type PromptAttachmentsInput = {
+ editor: () => HTMLDivElement | undefined
+ isFocused: () => boolean
+ isDialogActive: () => boolean
+ setDragging: (value: boolean) => void
+ addPart: (part: ContentPart) => void
+}
+
+export function createPromptAttachments(input: PromptAttachmentsInput) {
+ const prompt = usePrompt()
+ const language = useLanguage()
+
+ const addImageAttachment = async (file: File) => {
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const editor = input.editor()
+ if (!editor) return
+ const dataUrl = reader.result as string
+ const attachment: ImageAttachmentPart = {
+ type: "image",
+ id: crypto.randomUUID(),
+ filename: file.name,
+ mime: file.type,
+ dataUrl,
+ }
+ const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
+ prompt.set([...prompt.current(), attachment], cursorPosition)
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const removeImageAttachment = (id: string) => {
+ const current = prompt.current()
+ const next = current.filter((part) => part.type !== "image" || part.id !== id)
+ prompt.set(next, prompt.cursor())
+ }
+
+ const handlePaste = async (event: ClipboardEvent) => {
+ if (!input.isFocused()) return
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const items = Array.from(clipboardData.items)
+ const fileItems = items.filter((item) => item.kind === "file")
+ const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+ if (imageItems.length > 0) {
+ for (const item of imageItems) {
+ const file = item.getAsFile()
+ if (file) await addImageAttachment(file)
+ }
+ return
+ }
+
+ if (fileItems.length > 0) {
+ showToast({
+ title: language.t("prompt.toast.pasteUnsupported.title"),
+ description: language.t("prompt.toast.pasteUnsupported.description"),
+ })
+ return
+ }
+
+ const plainText = clipboardData.getData("text/plain") ?? ""
+ if (!plainText) return
+ input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
+ }
+
+ const handleGlobalDragOver = (event: DragEvent) => {
+ if (input.isDialogActive()) return
+
+ event.preventDefault()
+ const hasFiles = event.dataTransfer?.types.includes("Files")
+ if (hasFiles) {
+ input.setDragging(true)
+ }
+ }
+
+ const handleGlobalDragLeave = (event: DragEvent) => {
+ if (input.isDialogActive()) return
+ if (!event.relatedTarget) {
+ input.setDragging(false)
+ }
+ }
+
+ const handleGlobalDrop = async (event: DragEvent) => {
+ if (input.isDialogActive()) return
+
+ event.preventDefault()
+ input.setDragging(false)
+
+ const dropped = event.dataTransfer?.files
+ if (!dropped) return
+
+ for (const file of Array.from(dropped)) {
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+ await addImageAttachment(file)
+ }
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("dragover", handleGlobalDragOver)
+ document.addEventListener("dragleave", handleGlobalDragLeave)
+ document.addEventListener("drop", handleGlobalDrop)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("dragover", handleGlobalDragOver)
+ document.removeEventListener("dragleave", handleGlobalDragLeave)
+ document.removeEventListener("drop", handleGlobalDrop)
+ })
+
+ return {
+ addImageAttachment,
+ removeImageAttachment,
+ handlePaste,
+ }
+}
diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts
new file mode 100644
index 000000000..fce8b4b95
--- /dev/null
+++ b/packages/app/src/components/prompt-input/editor-dom.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, test } from "bun:test"
+import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
+
+describe("prompt-input editor dom", () => {
+ test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
+ const fragment = createTextFragment("foo\n\nbar")
+ const container = document.createElement("div")
+ container.appendChild(fragment)
+
+ expect(container.childNodes.length).toBe(5)
+ expect(container.childNodes[0]?.textContent).toBe("foo")
+ expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
+ expect(container.childNodes[2]?.textContent).toBe("\u200B")
+ expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
+ expect(container.childNodes[4]?.textContent).toBe("bar")
+ })
+
+ test("length helpers treat breaks as one char and ignore zero-width chars", () => {
+ const container = document.createElement("div")
+ container.appendChild(document.createTextNode("ab\u200B"))
+ container.appendChild(document.createElement("br"))
+ container.appendChild(document.createTextNode("cd"))
+
+ expect(getNodeLength(container.childNodes[0]!)).toBe(2)
+ expect(getNodeLength(container.childNodes[1]!)).toBe(1)
+ expect(getTextLength(container)).toBe(5)
+ })
+
+ test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
+ const container = document.createElement("div")
+ const pill = document.createElement("span")
+ pill.dataset.type = "file"
+ pill.textContent = "@file"
+ container.appendChild(document.createTextNode("ab"))
+ container.appendChild(pill)
+ container.appendChild(document.createElement("br"))
+ container.appendChild(document.createTextNode("cd"))
+ document.body.appendChild(container)
+
+ setCursorPosition(container, 2)
+ expect(getCursorPosition(container)).toBe(2)
+
+ setCursorPosition(container, 7)
+ expect(getCursorPosition(container)).toBe(7)
+
+ setCursorPosition(container, 8)
+ expect(getCursorPosition(container)).toBe(8)
+
+ container.remove()
+ })
+})
diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts
new file mode 100644
index 000000000..3116ceb12
--- /dev/null
+++ b/packages/app/src/components/prompt-input/editor-dom.ts
@@ -0,0 +1,135 @@
+export function createTextFragment(content: string): DocumentFragment {
+ const fragment = document.createDocumentFragment()
+ const segments = content.split("\n")
+ segments.forEach((segment, index) => {
+ if (segment) {
+ fragment.appendChild(document.createTextNode(segment))
+ } else if (segments.length > 1) {
+ fragment.appendChild(document.createTextNode("\u200B"))
+ }
+ if (index < segments.length - 1) {
+ fragment.appendChild(document.createElement("br"))
+ }
+ })
+ return fragment
+}
+
+export function getNodeLength(node: Node): number {
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+export function getTextLength(node: Node): number {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ let length = 0
+ for (const child of Array.from(node.childNodes)) {
+ length += getTextLength(child)
+ }
+ return length
+}
+
+export function getCursorPosition(parent: HTMLElement): number {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+ const range = selection.getRangeAt(0)
+ if (!parent.contains(range.startContainer)) return 0
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(parent)
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
+ return getTextLength(preCaretRange.cloneContents())
+}
+
+export function setCursorPosition(parent: HTMLElement, position: number) {
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ if (remaining === 0) {
+ range.setStartBefore(node)
+ }
+ if (remaining > 0 && isPill) {
+ range.setStartAfter(node)
+ }
+ if (remaining > 0 && isBreak) {
+ const next = node.nextSibling
+ if (next && next.nodeType === Node.TEXT_NODE) {
+ range.setStart(next, 0)
+ }
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(node)
+ }
+ }
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
+}
+
+export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
+ let remaining = offset
+ const nodes = Array.from(parent.childNodes)
+
+ for (const node of nodes) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ if (edge === "start") range.setStart(node, remaining)
+ if (edge === "end") range.setEnd(node, remaining)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ if (edge === "start" && remaining === 0) range.setStartBefore(node)
+ if (edge === "start" && remaining > 0) range.setStartAfter(node)
+ if (edge === "end" && remaining === 0) range.setEndBefore(node)
+ if (edge === "end" && remaining > 0) range.setEndAfter(node)
+ return
+ }
+
+ remaining -= length
+ }
+}
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
new file mode 100644
index 000000000..54be9cb75
--- /dev/null
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+
+describe("prompt-input history", () => {
+ test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
+ const first = prependHistoryEntry([], DEFAULT_PROMPT)
+ expect(first).toEqual([])
+
+ const withOne = prependHistoryEntry([], text("hello"))
+ expect(withOne).toHaveLength(1)
+
+ const deduped = prependHistoryEntry(withOne, text("hello"))
+ expect(deduped).toBe(withOne)
+ })
+
+ test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
+ const entries = [text("third"), text("second"), text("first")]
+ const up = navigatePromptHistory({
+ direction: "up",
+ entries,
+ historyIndex: -1,
+ currentPrompt: text("draft"),
+ savedPrompt: null,
+ })
+ expect(up.handled).toBe(true)
+ if (!up.handled) throw new Error("expected handled")
+ expect(up.historyIndex).toBe(0)
+ expect(up.cursor).toBe("start")
+
+ const down = navigatePromptHistory({
+ direction: "down",
+ entries,
+ historyIndex: up.historyIndex,
+ currentPrompt: text("ignored"),
+ savedPrompt: up.savedPrompt,
+ })
+ expect(down.handled).toBe(true)
+ if (!down.handled) throw new Error("expected handled")
+ expect(down.historyIndex).toBe(-1)
+ expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+ })
+
+ test("helpers clone prompt and count text content length", () => {
+ const original: Prompt = [
+ { type: "text", content: "one", start: 0, end: 3 },
+ {
+ type: "file",
+ path: "src/a.ts",
+ content: "@src/a.ts",
+ start: 3,
+ end: 12,
+ selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
+ },
+ { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
+ ]
+ const copy = clonePromptParts(original)
+ expect(copy).not.toBe(original)
+ expect(promptLength(copy)).toBe(12)
+ if (copy[1]?.type !== "file") throw new Error("expected file")
+ copy[1].selection!.startLine = 9
+ if (original[1]?.type !== "file") throw new Error("expected file")
+ expect(original[1].selection?.startLine).toBe(1)
+ })
+})
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
new file mode 100644
index 000000000..63164f0ba
--- /dev/null
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -0,0 +1,160 @@
+import type { Prompt } from "@/context/prompt"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export const MAX_HISTORY = 100
+
+export function clonePromptParts(prompt: Prompt): Prompt {
+ return prompt.map((part) => {
+ if (part.type === "text") return { ...part }
+ if (part.type === "image") return { ...part }
+ if (part.type === "agent") return { ...part }
+ return {
+ ...part,
+ selection: part.selection ? { ...part.selection } : undefined,
+ }
+ })
+}
+
+export function promptLength(prompt: Prompt) {
+ return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
+}
+
+export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+ const text = prompt
+ .map((part) => ("content" in part ? part.content : ""))
+ .join("")
+ .trim()
+ const hasImages = prompt.some((part) => part.type === "image")
+ if (!text && !hasImages) return entries
+
+ const entry = clonePromptParts(prompt)
+ const last = entries[0]
+ if (last && isPromptEqual(last, entry)) return entries
+ return [entry, ...entries].slice(0, max)
+}
+
+function isPromptEqual(promptA: Prompt, promptB: Prompt) {
+ 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.type === "text" ? partB.content : "")) return false
+ if (partA.type === "file") {
+ if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
+ const a = partA.selection
+ const b = partB.type === "file" ? partB.selection : undefined
+ const sameSelection =
+ (!a && !b) ||
+ (!!a &&
+ !!b &&
+ a.startLine === b.startLine &&
+ a.startChar === b.startChar &&
+ a.endLine === b.endLine &&
+ a.endChar === b.endChar)
+ if (!sameSelection) return false
+ }
+ if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
+ if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
+ }
+ return true
+}
+
+type HistoryNavInput = {
+ direction: "up" | "down"
+ entries: Prompt[]
+ historyIndex: number
+ currentPrompt: Prompt
+ savedPrompt: Prompt | null
+}
+
+type HistoryNavResult =
+ | {
+ handled: false
+ historyIndex: number
+ savedPrompt: Prompt | null
+ }
+ | {
+ handled: true
+ historyIndex: number
+ savedPrompt: Prompt | null
+ prompt: Prompt
+ cursor: "start" | "end"
+ }
+
+export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
+ if (input.direction === "up") {
+ if (input.entries.length === 0) {
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+ }
+
+ if (input.historyIndex === -1) {
+ return {
+ handled: true,
+ historyIndex: 0,
+ savedPrompt: clonePromptParts(input.currentPrompt),
+ prompt: input.entries[0],
+ cursor: "start",
+ }
+ }
+
+ if (input.historyIndex < input.entries.length - 1) {
+ const next = input.historyIndex + 1
+ return {
+ handled: true,
+ historyIndex: next,
+ savedPrompt: input.savedPrompt,
+ prompt: input.entries[next],
+ cursor: "start",
+ }
+ }
+
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+ }
+
+ if (input.historyIndex > 0) {
+ const next = input.historyIndex - 1
+ return {
+ handled: true,
+ historyIndex: next,
+ savedPrompt: input.savedPrompt,
+ prompt: input.entries[next],
+ cursor: "end",
+ }
+ }
+
+ if (input.historyIndex === 0) {
+ if (input.savedPrompt) {
+ return {
+ handled: true,
+ historyIndex: -1,
+ savedPrompt: null,
+ prompt: input.savedPrompt,
+ cursor: "end",
+ }
+ }
+
+ return {
+ handled: true,
+ historyIndex: -1,
+ savedPrompt: null,
+ prompt: DEFAULT_PROMPT,
+ cursor: "end",
+ }
+ }
+
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+}
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
new file mode 100644
index 000000000..1e5ebe4cb
--- /dev/null
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -0,0 +1,587 @@
+import { Accessor } from "solid-js"
+import { produce } from "solid-js/store"
+import { useNavigate, useParams } from "@solidjs/router"
+import { getFilename } from "@opencode-ai/util/path"
+import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
+import { Binary } from "@opencode-ai/util/binary"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { useLocal } from "@/context/local"
+import {
+ usePrompt,
+ type AgentPart,
+ type FileAttachmentPart,
+ type ImageAttachmentPart,
+ type Prompt,
+} from "@/context/prompt"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { useLanguage } from "@/context/language"
+import { Identifier } from "@/utils/id"
+import { Worktree as WorktreeState } from "@/utils/worktree"
+import type { FileSelection } from "@/context/file"
+import { setCursorPosition } from "./editor-dom"
+
+type PendingPrompt = {
+ abort: AbortController
+ cleanup: VoidFunction
+}
+
+const pending = new Map<string, PendingPrompt>()
+
+type PromptSubmitInput = {
+ info: Accessor<{ id: string } | undefined>
+ imageAttachments: Accessor<ImageAttachmentPart[]>
+ commentCount: Accessor<number>
+ mode: Accessor<"normal" | "shell">
+ working: Accessor<boolean>
+ editor: () => HTMLDivElement | undefined
+ queueScroll: () => void
+ promptLength: (prompt: Prompt) => number
+ addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
+ resetHistoryNavigation: () => void
+ setMode: (mode: "normal" | "shell") => void
+ setPopover: (popover: "at" | "slash" | null) => void
+ newSessionWorktree?: string
+ onNewSessionWorktreeReset?: () => void
+ onSubmit?: () => void
+}
+
+type CommentItem = {
+ path: string
+ selection?: FileSelection
+ comment?: string
+ commentID?: string
+ commentOrigin?: "review" | "file"
+ preview?: string
+}
+
+export function createPromptSubmit(input: PromptSubmitInput) {
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+ const platform = usePlatform()
+ const local = useLocal()
+ const prompt = usePrompt()
+ const layout = useLayout()
+ const language = useLanguage()
+ const params = useParams()
+
+ const errorMessage = (err: unknown) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return language.t("common.requestFailed")
+ }
+
+ const abort = async () => {
+ const sessionID = params.id
+ if (!sessionID) return Promise.resolve()
+ const queued = pending.get(sessionID)
+ if (queued) {
+ queued.abort.abort()
+ queued.cleanup()
+ pending.delete(sessionID)
+ return Promise.resolve()
+ }
+ return sdk.client.session
+ .abort({
+ sessionID,
+ })
+ .catch(() => {})
+ }
+
+ const restoreCommentItems = (items: CommentItem[]) => {
+ for (const item of items) {
+ prompt.context.add({
+ type: "file",
+ path: item.path,
+ selection: item.selection,
+ comment: item.comment,
+ commentID: item.commentID,
+ commentOrigin: item.commentOrigin,
+ preview: item.preview,
+ })
+ }
+ }
+
+ const removeCommentItems = (items: { key: string }[]) => {
+ for (const item of items) {
+ prompt.context.remove(item.key)
+ }
+ }
+
+ const handleSubmit = async (event: Event) => {
+ event.preventDefault()
+
+ const currentPrompt = prompt.current()
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+ const images = input.imageAttachments().slice()
+ const mode = input.mode()
+
+ if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
+ if (input.working()) abort()
+ return
+ }
+
+ const currentModel = local.model.current()
+ const currentAgent = local.agent.current()
+ if (!currentModel || !currentAgent) {
+ showToast({
+ title: language.t("prompt.toast.modelAgentRequired.title"),
+ description: language.t("prompt.toast.modelAgentRequired.description"),
+ })
+ return
+ }
+
+ input.addToHistory(currentPrompt, mode)
+ input.resetHistoryNavigation()
+
+ const projectDirectory = sdk.directory
+ const isNewSession = !params.id
+ const worktreeSelection = input.newSessionWorktree ?? "main"
+
+ let sessionDirectory = projectDirectory
+ let client = sdk.client
+
+ if (isNewSession) {
+ if (worktreeSelection === "create") {
+ const createdWorktree = await client.worktree
+ .create({ directory: projectDirectory })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
+ description: errorMessage(err),
+ })
+ return undefined
+ })
+
+ if (!createdWorktree?.directory) {
+ showToast({
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
+ description: language.t("common.requestFailed"),
+ })
+ return
+ }
+ WorktreeState.pending(createdWorktree.directory)
+ sessionDirectory = createdWorktree.directory
+ }
+
+ if (worktreeSelection !== "main" && worktreeSelection !== "create") {
+ sessionDirectory = worktreeSelection
+ }
+
+ if (sessionDirectory !== projectDirectory) {
+ client = createOpencodeClient({
+ baseUrl: sdk.url,
+ fetch: platform.fetch,
+ directory: sessionDirectory,
+ throwOnError: true,
+ })
+ globalSync.child(sessionDirectory)
+ }
+
+ input.onNewSessionWorktreeReset?.()
+ }
+
+ let session = input.info()
+ if (!session && isNewSession) {
+ session = await client.session
+ .create()
+ .then((x) => x.data ?? undefined)
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.sessionCreateFailed.title"),
+ description: errorMessage(err),
+ })
+ return undefined
+ })
+ if (session) {
+ layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+ navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+ }
+ }
+ if (!session) return
+
+ input.onSubmit?.()
+
+ const model = {
+ modelID: currentModel.id,
+ providerID: currentModel.provider.id,
+ }
+ const agent = currentAgent.name
+ const variant = local.model.variant.current()
+
+ const clearInput = () => {
+ prompt.reset()
+ input.setMode("normal")
+ input.setPopover(null)
+ }
+
+ const restoreInput = () => {
+ prompt.set(currentPrompt, input.promptLength(currentPrompt))
+ input.setMode(mode)
+ input.setPopover(null)
+ requestAnimationFrame(() => {
+ const editor = input.editor()
+ if (!editor) return
+ editor.focus()
+ setCursorPosition(editor, input.promptLength(currentPrompt))
+ input.queueScroll()
+ })
+ }
+
+ if (mode === "shell") {
+ clearInput()
+ client.session
+ .shell({
+ sessionID: session.id,
+ agent,
+ model,
+ command: text,
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.shellSendFailed.title"),
+ description: errorMessage(err),
+ })
+ restoreInput()
+ })
+ return
+ }
+
+ if (text.startsWith("/")) {
+ const [cmdName, ...args] = text.split(" ")
+ const commandName = cmdName.slice(1)
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
+ if (customCommand) {
+ clearInput()
+ client.session
+ .command({
+ sessionID: session.id,
+ command: commandName,
+ arguments: args.join(" "),
+ agent,
+ model: `${model.providerID}/${model.modelID}`,
+ variant,
+ parts: images.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ })),
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.commandSendFailed.title"),
+ description: errorMessage(err),
+ })
+ restoreInput()
+ })
+ return
+ }
+ }
+
+ const toAbsolutePath = (path: string) =>
+ path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
+
+ const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
+ const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
+
+ const fileAttachmentParts = fileAttachments.map((attachment) => {
+ const absolute = toAbsolutePath(attachment.path)
+ const query = attachment.selection
+ ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
+ : ""
+ return {
+ id: Identifier.ascending("part"),
+ 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,
+ },
+ }
+ })
+
+ const agentAttachmentParts = agentAttachments.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "agent" as const,
+ name: attachment.name,
+ source: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ }))
+
+ const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
+
+ const context = prompt.context.items().slice()
+ const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
+
+ const contextParts: Array<
+ | {
+ id: string
+ type: "text"
+ text: string
+ synthetic?: boolean
+ }
+ | {
+ id: string
+ type: "file"
+ mime: string
+ url: string
+ filename?: string
+ }
+ > = []
+
+ const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+ const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+ const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+
+ return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+ }
+
+ const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => {
+ const absolute = toAbsolutePath(item.path)
+ const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : ""
+ const url = `file://${absolute}${query}`
+
+ const comment = item.comment?.trim()
+ if (!comment && usedUrls.has(url)) return
+ usedUrls.add(url)
+
+ if (comment) {
+ contextParts.push({
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: commentNote(item.path, item.selection, comment),
+ synthetic: true,
+ })
+ }
+
+ contextParts.push({
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url,
+ filename: getFilename(item.path),
+ })
+ }
+
+ for (const item of context) {
+ if (item.type !== "file") continue
+ addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
+ }
+
+ const imageAttachmentParts = images.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ }))
+
+ const messageID = Identifier.ascending("message")
+ const requestParts = [
+ {
+ id: Identifier.ascending("part"),
+ type: "text" as const,
+ text,
+ },
+ ...fileAttachmentParts,
+ ...contextParts,
+ ...agentAttachmentParts,
+ ...imageAttachmentParts,
+ ]
+
+ const optimisticParts = requestParts.map((part) => ({
+ ...part,
+ sessionID: session.id,
+ messageID,
+ })) as unknown as Part[]
+
+ const optimisticMessage: Message = {
+ id: messageID,
+ sessionID: session.id,
+ role: "user",
+ time: { created: Date.now() },
+ agent,
+ model,
+ }
+
+ const addOptimisticMessage = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set(
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (!messages) {
+ draft.message[session.id] = [optimisticMessage]
+ } else {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ messages.splice(result.index, 0, optimisticMessage)
+ }
+ draft.part[messageID] = optimisticParts
+ .filter((part) => !!part?.id)
+ .slice()
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ }),
+ )
+ return
+ }
+
+ globalSync.child(sessionDirectory)[1](
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (!messages) {
+ draft.message[session.id] = [optimisticMessage]
+ } else {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ messages.splice(result.index, 0, optimisticMessage)
+ }
+ draft.part[messageID] = optimisticParts
+ .filter((part) => !!part?.id)
+ .slice()
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+ }),
+ )
+ }
+
+ const removeOptimisticMessage = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set(
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (messages) {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[messageID]
+ }),
+ )
+ return
+ }
+
+ globalSync.child(sessionDirectory)[1](
+ produce((draft) => {
+ const messages = draft.message[session.id]
+ if (messages) {
+ const result = Binary.search(messages, messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[messageID]
+ }),
+ )
+ }
+
+ removeCommentItems(commentItems)
+ clearInput()
+ addOptimisticMessage()
+
+ const waitForWorktree = async () => {
+ const worktree = WorktreeState.get(sessionDirectory)
+ if (!worktree || worktree.status !== "pending") return true
+
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "busy" })
+ }
+
+ const controller = new AbortController()
+ const cleanup = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "idle" })
+ }
+ removeOptimisticMessage()
+ restoreCommentItems(commentItems)
+ restoreInput()
+ }
+
+ pending.set(session.id, { abort: controller, cleanup })
+
+ const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+ if (controller.signal.aborted) {
+ resolve({ status: "failed", message: "aborted" })
+ return
+ }
+ controller.signal.addEventListener(
+ "abort",
+ () => {
+ resolve({ status: "failed", message: "aborted" })
+ },
+ { once: true },
+ )
+ })
+
+ const timeoutMs = 5 * 60 * 1000
+ const timer = { id: undefined as number | undefined }
+ const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+ timer.id = window.setTimeout(() => {
+ resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
+ }, timeoutMs)
+ })
+
+ const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
+ if (timer.id === undefined) return
+ clearTimeout(timer.id)
+ })
+ pending.delete(session.id)
+ if (controller.signal.aborted) return false
+ if (result.status === "failed") throw new Error(result.message)
+ return true
+ }
+
+ const send = async () => {
+ const ok = await waitForWorktree()
+ if (!ok) return
+ await client.session.prompt({
+ sessionID: session.id,
+ agent,
+ model,
+ messageID,
+ parts: requestParts,
+ variant,
+ })
+ }
+
+ void send().catch((err) => {
+ pending.delete(session.id)
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "idle" })
+ }
+ showToast({
+ title: language.t("prompt.toast.promptSendFailed.title"),
+ description: errorMessage(err),
+ })
+ removeOptimisticMessage()
+ restoreCommentItems(commentItems)
+ restoreInput()
+ })
+ }
+
+ return {
+ abort,
+ handleSubmit,
+ }
+}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index c6256395f..4e5dae139 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
-import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
-import { findLast } from "@opencode-ai/util/array"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -34,26 +33,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
+ const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+ const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
- const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
- return usd().format(total)
- })
-
- const context = createMemo(() => {
- const locale = language.locale()
- const last = findLast(messages(), (x) => {
- if (x.role !== "assistant") return false
- const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
- return total > 0
- }) as AssistantMessage
- if (!last) return
- const total =
- last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
- const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
- return {
- tokens: total.toLocaleString(locale),
- percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
- }
+ return usd().format(metrics().totalCost)
})
const openContext = () => {
@@ -67,7 +50,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const circle = () => (
<div class="flex items-center justify-center">
- <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
+ <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
</div>
)
@@ -77,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => (
<>
<div class="flex items-center gap-2">
- <span class="text-text-invert-strong">{ctx().tokens}</span>
+ <span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
<div class="flex items-center gap-2">
- <span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
+ <span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts
new file mode 100644
index 000000000..68903a455
--- /dev/null
+++ b/packages/app/src/components/session/session-context-metrics.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, test } from "bun:test"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+import { getSessionContextMetrics } from "./session-context-metrics"
+
+const assistant = (
+ id: string,
+ tokens: { input: number; output: number; reasoning: number; read: number; write: number },
+ cost: number,
+ providerID = "openai",
+ modelID = "gpt-4.1",
+) => {
+ return {
+ id,
+ role: "assistant",
+ providerID,
+ modelID,
+ cost,
+ tokens: {
+ input: tokens.input,
+ output: tokens.output,
+ reasoning: tokens.reasoning,
+ cache: {
+ read: tokens.read,
+ write: tokens.write,
+ },
+ },
+ time: { created: 1 },
+ } as unknown as Message
+}
+
+const user = (id: string) => {
+ return {
+ id,
+ role: "user",
+ cost: 0,
+ time: { created: 1 },
+ } as unknown as Message
+}
+
+describe("getSessionContextMetrics", () => {
+ test("computes totals and usage from latest assistant with tokens", () => {
+ const messages = [
+ user("u1"),
+ assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
+ assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
+ ]
+ const providers = [
+ {
+ id: "openai",
+ name: "OpenAI",
+ models: {
+ "gpt-4.1": {
+ name: "GPT-4.1",
+ limit: { context: 1000 },
+ },
+ },
+ },
+ ]
+
+ const metrics = getSessionContextMetrics(messages, providers)
+
+ expect(metrics.totalCost).toBe(1.75)
+ expect(metrics.context?.message.id).toBe("a2")
+ expect(metrics.context?.total).toBe(500)
+ expect(metrics.context?.usage).toBe(50)
+ expect(metrics.context?.providerLabel).toBe("OpenAI")
+ expect(metrics.context?.modelLabel).toBe("GPT-4.1")
+ })
+
+ test("preserves fallback labels and null usage when model metadata is missing", () => {
+ const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
+ const providers = [{ id: "p-1", models: {} }]
+
+ const metrics = getSessionContextMetrics(messages, providers)
+
+ expect(metrics.context?.providerLabel).toBe("p-1")
+ expect(metrics.context?.modelLabel).toBe("m-1")
+ expect(metrics.context?.limit).toBeUndefined()
+ expect(metrics.context?.usage).toBeNull()
+ })
+
+ test("memoizes by message and provider array identity", () => {
+ const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
+ const providers = [{ id: "openai", models: {} }]
+
+ const one = getSessionContextMetrics(messages, providers)
+ const two = getSessionContextMetrics(messages, providers)
+ const three = getSessionContextMetrics([...messages], providers)
+
+ expect(two).toBe(one)
+ expect(three).not.toBe(one)
+ })
+})
diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts
new file mode 100644
index 000000000..2b6edbd95
--- /dev/null
+++ b/packages/app/src/components/session/session-context-metrics.ts
@@ -0,0 +1,94 @@
+import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
+
+type Provider = {
+ id: string
+ name?: string
+ models: Record<string, Model | undefined>
+}
+
+type Model = {
+ name?: string
+ limit: {
+ context: number
+ }
+}
+
+type Context = {
+ message: AssistantMessage
+ provider?: Provider
+ model?: Model
+ providerLabel: string
+ modelLabel: string
+ limit: number | undefined
+ input: number
+ output: number
+ reasoning: number
+ cacheRead: number
+ cacheWrite: number
+ total: number
+ usage: number | null
+}
+
+type Metrics = {
+ totalCost: number
+ context: Context | undefined
+}
+
+const cache = new WeakMap<Message[], WeakMap<Provider[], Metrics>>()
+
+const tokenTotal = (msg: AssistantMessage) => {
+ return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
+}
+
+const lastAssistantWithTokens = (messages: Message[]) => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]
+ if (msg.role !== "assistant") continue
+ if (tokenTotal(msg) <= 0) continue
+ return msg
+ }
+}
+
+const build = (messages: Message[], providers: Provider[]): Metrics => {
+ const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
+ const message = lastAssistantWithTokens(messages)
+ if (!message) return { totalCost, context: undefined }
+
+ const provider = providers.find((item) => item.id === message.providerID)
+ const model = provider?.models[message.modelID]
+ const limit = model?.limit.context
+ const total = tokenTotal(message)
+
+ return {
+ totalCost,
+ context: {
+ message,
+ provider,
+ model,
+ providerLabel: provider?.name ?? message.providerID,
+ modelLabel: model?.name ?? message.modelID,
+ limit,
+ input: message.tokens.input,
+ output: message.tokens.output,
+ reasoning: message.tokens.reasoning,
+ cacheRead: message.tokens.cache.read,
+ cacheWrite: message.tokens.cache.write,
+ total,
+ usage: limit ? Math.round((total / limit) * 100) : null,
+ },
+ }
+}
+
+export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
+ const byProvider = cache.get(messages)
+ if (byProvider) {
+ const hit = byProvider.get(providers)
+ if (hit) return hit
+ }
+
+ const value = build(messages, providers)
+ const next = byProvider ?? new WeakMap<Provider[], Metrics>()
+ next.set(providers, value)
+ if (!byProvider) cache.set(messages, next)
+ return value
+}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 37733caff..8aae44863 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
-import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "./session-context-metrics"
interface SessionContextTabProps {
messages: () => Message[]
@@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)
- const ctx = createMemo(() => {
- const last = findLast(props.messages(), (x) => {
- if (x.role !== "assistant") return false
- const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
- return total > 0
- }) as AssistantMessage
- if (!last) return
-
- const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
- const model = provider?.models[last.modelID]
- const limit = model?.limit.context
-
- const input = last.tokens.input
- const output = last.tokens.output
- const reasoning = last.tokens.reasoning
- const cacheRead = last.tokens.cache.read
- const cacheWrite = last.tokens.cache.write
- const total = input + output + reasoning + cacheRead + cacheWrite
- const usage = limit ? Math.round((total / limit) * 100) : null
-
- return {
- message: last,
- provider,
- model,
- limit,
- input,
- output,
- reasoning,
- cacheRead,
- cacheWrite,
- total,
- usage,
- }
- })
+ const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
+ const ctx = createMemo(() => metrics().context)
const cost = createMemo(() => {
- const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
- return usd().format(total)
+ return usd().format(metrics().totalCost)
})
const counts = createMemo(() => {
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
- return c.provider?.name ?? c.message.providerID
+ return c.providerLabel
})
const modelLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
- if (c.model?.name) return c.model.name
- return c.message.modelID
+ return c.modelLabel
})
const breakdown = createMemo(
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts
new file mode 100644
index 000000000..13cb132c4
--- /dev/null
+++ b/packages/app/src/context/comments.test.ts
@@ -0,0 +1,111 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+import { createRoot } from "solid-js"
+import type { LineComment } from "./comments"
+
+let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
+
+beforeAll(async () => {
+ mock.module("@solidjs/router", () => ({
+ useParams: () => ({}),
+ }))
+ mock.module("@opencode-ai/ui/context", () => ({
+ createSimpleContext: () => ({
+ use: () => undefined,
+ provider: () => undefined,
+ }),
+ }))
+ const mod = await import("./comments")
+ createCommentSessionForTest = mod.createCommentSessionForTest
+})
+
+function line(file: string, id: string, time: number): LineComment {
+ return {
+ id,
+ file,
+ comment: id,
+ time,
+ selection: { start: 1, end: 1 },
+ }
+}
+
+describe("comments session indexing", () => {
+ test("keeps file list behavior and aggregate chronological order", () => {
+ createRoot((dispose) => {
+ const now = Date.now()
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
+ "b.ts": [line("b.ts", "b-mid", now + 10_000)],
+ })
+
+ expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
+ expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
+
+ const next = comments.add({
+ file: "b.ts",
+ comment: "next",
+ selection: { start: 2, end: 2 },
+ })
+
+ expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
+ expect(comments.all().map((item) => item.time)).toEqual(
+ comments
+ .all()
+ .map((item) => item.time)
+ .slice()
+ .sort((a, b) => a - b),
+ )
+
+ dispose()
+ })
+ })
+
+ test("remove updates file and aggregate indexes consistently", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
+ "b.ts": [line("b.ts", "shared", 30)],
+ })
+
+ comments.setFocus({ file: "a.ts", id: "shared" })
+ comments.setActive({ file: "a.ts", id: "shared" })
+ comments.remove("a.ts", "shared")
+
+ expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
+ expect(
+ comments
+ .all()
+ .filter((item) => item.id === "shared")
+ .map((item) => item.file),
+ ).toEqual(["b.ts"])
+ expect(comments.focus()).toBeNull()
+ expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
+
+ dispose()
+ })
+ })
+
+ test("clear resets file and aggregate indexes plus focus state", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10)],
+ })
+
+ const next = comments.add({
+ file: "b.ts",
+ comment: "next",
+ selection: { start: 2, end: 2 },
+ })
+
+ comments.setActive({ file: "b.ts", id: next.id })
+ comments.clear()
+
+ expect(comments.list("a.ts")).toEqual([])
+ expect(comments.list("b.ts")).toEqual([])
+ expect(comments.all()).toEqual([])
+ expect(comments.focus()).toBeNull()
+ expect(comments.active()).toBeNull()
+
+ dispose()
+ })
+ })
+})
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
index d51c16352..d43f3705b 100644
--- a/packages/app/src/context/comments.tsx
+++ b/packages/app/src/context/comments.tsx
@@ -1,8 +1,9 @@
-import { batch, createMemo, createRoot, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
+import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
import type { SelectedLineRange } from "@/context/file"
export type LineComment = {
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20
-type CommentSession = ReturnType<typeof createCommentSession>
-
-type CommentCacheEntry = {
- value: CommentSession
- dispose: VoidFunction
+type CommentStore = {
+ comments: Record<string, LineComment[]>
}
-function createCommentSession(dir: string, id: string | undefined) {
- const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+function aggregate(comments: Record<string, LineComment[]>) {
+ return Object.keys(comments)
+ .flatMap((file) => comments[file] ?? [])
+ .slice()
+ .sort((a, b) => a.time - b.time)
+}
- const [store, setStore, _, ready] = persisted(
- Persist.scoped(dir, id, "comments", [legacy]),
- createStore<{
- comments: Record<string, LineComment[]>
- }>({
- comments: {},
- }),
- )
+function insert(items: LineComment[], next: LineComment) {
+ const index = items.findIndex((item) => item.time > next.time)
+ if (index < 0) return [...items, next]
+ return [...items.slice(0, index), next, ...items.slice(index)]
+}
+function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
active: null as CommentFocus | null,
+ all: aggregate(store.comments),
})
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
@@ -59,6 +60,7 @@ function createCommentSession(dir: string, id: string | undefined) {
batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next])
+ setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id })
})
@@ -66,37 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
}
const remove = (file: string, id: string) => {
- setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
- setFocus((current) => (current?.id === id ? null : current))
+ batch(() => {
+ setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
+ setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
+ setFocus((current) => (current?.id === id ? null : current))
+ })
}
const clear = () => {
batch(() => {
- setStore("comments", {})
+ setStore("comments", reconcile({}))
+ setState("all", [])
setFocus(null)
setActive(null)
})
}
- const all = createMemo(() => {
- const files = Object.keys(store.comments)
- const items = files.flatMap((file) => store.comments[file] ?? [])
- return items.slice().sort((a, b) => a.time - b.time)
- })
-
return {
- ready,
list,
- all,
+ all: () => state.all,
add,
remove,
clear,
- focus: createMemo(() => state.focus),
+ focus: () => state.focus,
setFocus,
clearFocus: () => setFocus(null),
- active: createMemo(() => state.active),
+ active: () => state.active,
setActive,
clearActive: () => setActive(null),
+ reindex: () => setState("all", aggregate(store.comments)),
+ }
+}
+
+export function createCommentSessionForTest(comments: Record<string, LineComment[]> = {}) {
+ const [store, setStore] = createStore<CommentStore>({ comments })
+ return createCommentSessionState(store, setStore)
+}
+
+function createCommentSession(dir: string, id: string | undefined) {
+ const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+
+ const [store, setStore, _, ready] = persisted(
+ Persist.scoped(dir, id, "comments", [legacy]),
+ createStore<CommentStore>({
+ comments: {},
+ }),
+ )
+ const session = createCommentSessionState(store, setStore)
+
+ createEffect(() => {
+ if (!ready()) return
+ session.reindex()
+ })
+
+ return {
+ ready,
+ list: session.list,
+ all: session.all,
+ add: session.add,
+ remove: session.remove,
+ clear: session.clear,
+ focus: session.focus,
+ setFocus: session.setFocus,
+ clearFocus: session.clearFocus,
+ active: session.active,
+ setActive: session.setActive,
+ clearActive: session.clearActive,
}
}
@@ -105,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
- const cache = new Map<string, CommentCacheEntry>()
-
- const disposeAll = () => {
- for (const entry of cache.values()) {
- entry.dispose()
- }
- cache.clear()
- }
-
- onCleanup(disposeAll)
-
- const prune = () => {
- while (cache.size > MAX_COMMENT_SESSIONS) {
- const first = cache.keys().next().value
- if (!first) return
- const entry = cache.get(first)
- entry?.dispose()
- cache.delete(first)
- }
- }
+ const cache = createScopedCache(
+ (key) => {
+ const split = key.lastIndexOf("\n")
+ const dir = split >= 0 ? key.slice(0, split) : key
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ return createRoot((dispose) => ({
+ value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ dispose,
+ }))
+ },
+ {
+ maxEntries: MAX_COMMENT_SESSIONS,
+ dispose: (entry) => entry.dispose(),
+ },
+ )
+
+ onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => {
- const key = `${dir}:${id ?? WORKSPACE_KEY}`
- const existing = cache.get(key)
- if (existing) {
- cache.delete(key)
- cache.set(key, existing)
- return existing.value
- }
-
- const entry = createRoot((dispose) => ({
- value: createCommentSession(dir, id),
- dispose,
- }))
-
- cache.set(key, entry)
- prune()
- return entry.value
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ return cache.get(key).value
}
const session = createMemo(() => load(params.dir!, params.id))
diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts
new file mode 100644
index 000000000..9a455e2af
--- /dev/null
+++ b/packages/app/src/context/file-content-eviction-accounting.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"
+
+let evictContentLru: (keep: Set<string> | undefined, evict: (path: string) => void) => void
+let getFileContentBytesTotal: () => number
+let getFileContentEntryCount: () => number
+let removeFileContentBytes: (path: string) => void
+let resetFileContentLru: () => void
+let setFileContentBytes: (path: string, bytes: number) => void
+let touchFileContent: (path: string, bytes?: number) => void
+
+beforeAll(async () => {
+ mock.module("@solidjs/router", () => ({
+ useParams: () => ({}),
+ }))
+ mock.module("@opencode-ai/ui/context", () => ({
+ createSimpleContext: () => ({
+ use: () => undefined,
+ provider: () => undefined,
+ }),
+ }))
+
+ const mod = await import("./file")
+ evictContentLru = mod.evictContentLru
+ getFileContentBytesTotal = mod.getFileContentBytesTotal
+ getFileContentEntryCount = mod.getFileContentEntryCount
+ removeFileContentBytes = mod.removeFileContentBytes
+ resetFileContentLru = mod.resetFileContentLru
+ setFileContentBytes = mod.setFileContentBytes
+ touchFileContent = mod.touchFileContent
+})
+
+describe("file content eviction accounting", () => {
+ afterEach(() => {
+ resetFileContentLru()
+ })
+
+ test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
+ setFileContentBytes("a", 10)
+ setFileContentBytes("b", 15)
+ expect(getFileContentBytesTotal()).toBe(25)
+ expect(getFileContentEntryCount()).toBe(2)
+
+ setFileContentBytes("a", 5)
+ expect(getFileContentBytesTotal()).toBe(20)
+ expect(getFileContentEntryCount()).toBe(2)
+
+ touchFileContent("a")
+ expect(getFileContentBytesTotal()).toBe(20)
+
+ removeFileContentBytes("b")
+ expect(getFileContentBytesTotal()).toBe(5)
+ expect(getFileContentEntryCount()).toBe(1)
+
+ resetFileContentLru()
+ expect(getFileContentBytesTotal()).toBe(0)
+ expect(getFileContentEntryCount()).toBe(0)
+ })
+
+ test("evicts by entry cap using LRU order", () => {
+ for (const i of Array.from({ length: 41 }, (_, n) => n)) {
+ setFileContentBytes(`f-${i}`, 1)
+ }
+
+ const evicted: string[] = []
+ evictContentLru(undefined, (path) => evicted.push(path))
+
+ expect(evicted).toEqual(["f-0"])
+ expect(getFileContentEntryCount()).toBe(40)
+ expect(getFileContentBytesTotal()).toBe(40)
+ })
+
+ test("evicts by byte cap while preserving protected entries", () => {
+ const chunk = 8 * 1024 * 1024
+ setFileContentBytes("a", chunk)
+ setFileContentBytes("b", chunk)
+ setFileContentBytes("c", chunk)
+
+ const evicted: string[] = []
+ evictContentLru(new Set(["a"]), (path) => evicted.push(path))
+
+ expect(evicted).toEqual(["b"])
+ expect(getFileContentEntryCount()).toBe(2)
+ expect(getFileContentBytesTotal()).toBe(chunk * 2)
+ })
+})
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 3ed1b1ae4..164da726f 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -9,6 +9,7 @@ import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
export type FileSelection = {
startLine: number
@@ -155,6 +156,7 @@ const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
const contentLru = new Map<string, number>()
+let contentBytesTotal = 0
function approxBytes(content: FileContent) {
const patchBytes =
@@ -165,19 +167,72 @@ function approxBytes(content: FileContent) {
return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
}
+function setContentBytes(path: string, nextBytes: number) {
+ const prev = contentLru.get(path)
+ if (prev !== undefined) contentBytesTotal -= prev
+ contentLru.delete(path)
+ contentLru.set(path, nextBytes)
+ contentBytesTotal += nextBytes
+}
+
function touchContent(path: string, bytes?: number) {
const prev = contentLru.get(path)
if (prev === undefined && bytes === undefined) return
- const value = bytes ?? prev ?? 0
+ setContentBytes(path, bytes ?? prev ?? 0)
+}
+
+function removeContentBytes(path: string) {
+ const prev = contentLru.get(path)
+ if (prev === undefined) return
contentLru.delete(path)
- contentLru.set(path, value)
+ contentBytesTotal -= prev
+}
+
+function resetContentBytes() {
+ contentLru.clear()
+ contentBytesTotal = 0
+}
+
+export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
+ const protectedSet = keep ?? new Set<string>()
+
+ while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || contentBytesTotal > MAX_FILE_CONTENT_BYTES) {
+ const path = contentLru.keys().next().value
+ if (!path) return
+
+ if (protectedSet.has(path)) {
+ touchContent(path)
+ if (contentLru.size <= protectedSet.size) return
+ continue
+ }
+
+ removeContentBytes(path)
+ evict(path)
+ }
+}
+
+export function resetFileContentLru() {
+ resetContentBytes()
+}
+
+export function setFileContentBytes(path: string, bytes: number) {
+ setContentBytes(path, bytes)
}
-type ViewSession = ReturnType<typeof createViewSession>
+export function removeFileContentBytes(path: string) {
+ removeContentBytes(path)
+}
+
+export function touchFileContent(path: string, bytes?: number) {
+ touchContent(path, bytes)
+}
-type ViewCacheEntry = {
- value: ViewSession
- dispose: VoidFunction
+export function getFileContentBytesTotal() {
+ return contentBytesTotal
+}
+
+export function getFileContentEntryCount() {
+ return contentLru.size
}
function createViewSession(dir: string, id: string | undefined) {
@@ -336,23 +391,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
const evictContent = (keep?: Set<string>) => {
- const protectedSet = keep ?? new Set<string>()
- const total = () => {
- return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
- }
-
- while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
- const path = contentLru.keys().next().value
- if (!path) return
-
- if (protectedSet.has(path)) {
- touchContent(path)
- if (contentLru.size <= protectedSet.size) return
- continue
- }
-
- contentLru.delete(path)
- if (!store.file[path]) continue
+ evictContentLru(keep, (path) => {
+ if (!store.file[path]) return
setStore(
"file",
path,
@@ -361,14 +401,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
draft.loaded = false
}),
)
- }
+ })
}
createEffect(() => {
scope()
inflight.clear()
treeInflight.clear()
- contentLru.clear()
+ resetContentBytes()
batch(() => {
setStore("file", reconcile({}))
@@ -378,42 +418,25 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
- const viewCache = new Map<string, ViewCacheEntry>()
-
- const disposeViews = () => {
- for (const entry of viewCache.values()) {
- entry.dispose()
- }
- viewCache.clear()
- }
-
- const pruneViews = () => {
- while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
- const first = viewCache.keys().next().value
- if (!first) return
- const entry = viewCache.get(first)
- entry?.dispose()
- viewCache.delete(first)
- }
- }
+ const viewCache = createScopedCache(
+ (key) => {
+ const split = key.lastIndexOf("\n")
+ const dir = split >= 0 ? key.slice(0, split) : key
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ return createRoot((dispose) => ({
+ value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ dispose,
+ }))
+ },
+ {
+ maxEntries: MAX_FILE_VIEW_SESSIONS,
+ dispose: (entry) => entry.dispose(),
+ },
+ )
const loadView = (dir: string, id: string | undefined) => {
- const key = `${dir}:${id ?? WORKSPACE_KEY}`
- const existing = viewCache.get(key)
- if (existing) {
- viewCache.delete(key)
- viewCache.set(key, existing)
- return existing.value
- }
-
- const entry = createRoot((dispose) => ({
- value: createViewSession(dir, id),
- dispose,
- }))
-
- viewCache.set(key, entry)
- pruneViews()
- return entry.value
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ return viewCache.get(key).value
}
const view = createMemo(() => loadView(scope(), params.id))
@@ -690,7 +713,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
onCleanup(() => {
stop()
- disposeViews()
+ viewCache.clear()
})
return {
diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts
new file mode 100644
index 000000000..582d5edbd
--- /dev/null
+++ b/packages/app/src/context/layout.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot, createSignal } from "solid-js"
+import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
+
+describe("layout session-key helpers", () => {
+ test("couples touch and scroll seed in order", () => {
+ const calls: string[] = []
+ const result = ensureSessionKey(
+ "dir/a",
+ (key) => calls.push(`touch:${key}`),
+ (key) => calls.push(`seed:${key}`),
+ )
+
+ expect(result).toBe("dir/a")
+ expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
+ })
+
+ test("reads dynamic accessor keys lazily", () => {
+ const seen: string[] = []
+
+ createRoot((dispose) => {
+ const [key, setKey] = createSignal("dir/one")
+ const read = createSessionKeyReader(key, (value) => seen.push(value))
+
+ expect(read()).toBe("dir/one")
+ setKey("dir/two")
+ expect(read()).toBe("dir/two")
+
+ dispose()
+ })
+
+ expect(seen).toEqual(["dir/one", "dir/two"])
+ })
+})
+
+describe("pruneSessionKeys", () => {
+ test("keeps active key and drops lowest-used keys", () => {
+ const drop = pruneSessionKeys({
+ keep: "k4",
+ max: 3,
+ used: new Map([
+ ["k1", 1],
+ ["k2", 2],
+ ["k3", 3],
+ ["k4", 4],
+ ]),
+ view: ["k1", "k2", "k4"],
+ tabs: ["k1", "k3", "k4"],
+ })
+
+ expect(drop).toEqual(["k1"])
+ expect(drop.includes("k4")).toBe(false)
+ })
+
+ test("does not prune without keep key", () => {
+ const drop = pruneSessionKeys({
+ keep: undefined,
+ max: 1,
+ used: new Map([
+ ["k1", 1],
+ ["k2", 2],
+ ]),
+ view: ["k1"],
+ tabs: ["k2"],
+ })
+
+ expect(drop).toEqual([])
+ })
+})
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 95a2006ea..8d9c865f8 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
-import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
@@ -47,6 +47,43 @@ export type LocalProject = Partial<Project> & { worktree: string; expanded: bool
export type ReviewDiffStyle = "unified" | "split"
+export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
+ touch(key)
+ seed(key)
+ return key
+}
+
+export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
+ const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
+ return () => {
+ const value = key()
+ ensure(value)
+ return value
+ }
+}
+
+export function pruneSessionKeys(input: {
+ keep?: string
+ max: number
+ used: Map<string, number>
+ view: string[]
+ tabs: string[]
+}) {
+ if (!input.keep) return []
+
+ const keys = new Set<string>([...input.view, ...input.tabs])
+ if (keys.size <= input.max) return []
+
+ const score = (key: string) => {
+ if (key === input.keep) return Number.MAX_SAFE_INTEGER
+ return input.used.get(key) ?? 0
+ }
+
+ return Array.from(keys)
+ .sort((a, b) => score(b) - score(a))
+ .slice(input.max)
+}
+
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -172,20 +209,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function prune(keep?: string) {
- if (!keep) return
-
- const keys = new Set<string>()
- for (const key of Object.keys(store.sessionView)) keys.add(key)
- for (const key of Object.keys(store.sessionTabs)) keys.add(key)
- if (keys.size <= MAX_SESSION_KEYS) return
-
- const score = (key: string) => {
- if (key === keep) return Number.MAX_SAFE_INTEGER
- return used.get(key) ?? 0
- }
-
- const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
- const drop = ordered.slice(MAX_SESSION_KEYS)
+ const drop = pruneSessionKeys({
+ keep,
+ max: MAX_SESSION_KEYS,
+ used,
+ view: Object.keys(store.sessionView),
+ tabs: Object.keys(store.sessionTabs),
+ })
if (drop.length === 0) return
setStore(
@@ -233,6 +263,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
})
+ const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
+
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
@@ -616,22 +648,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
view(sessionKey: string | Accessor<string>) {
- const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-
- touch(key())
- scroll.seed(key())
-
- createEffect(
- on(
- key,
- (value) => {
- touch(value)
- scroll.seed(value)
- },
- { defer: true },
- ),
- )
-
+ const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
@@ -711,20 +728,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string | Accessor<string>) {
- const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-
- touch(key())
-
- createEffect(
- on(
- key,
- (value) => {
- touch(value)
- },
- { defer: true },
- ),
- )
-
+ const key = createSessionKeyReader(sessionKey, ensureKey)
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
return {
tabs,
diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts
new file mode 100644
index 000000000..d8c8cfcd4
--- /dev/null
+++ b/packages/app/src/context/terminal.test.ts
@@ -0,0 +1,38 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+
+let getWorkspaceTerminalCacheKey: (dir: string) => string
+let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
+
+beforeAll(async () => {
+ mock.module("@solidjs/router", () => ({
+ useParams: () => ({}),
+ }))
+ mock.module("@opencode-ai/ui/context", () => ({
+ createSimpleContext: () => ({
+ use: () => undefined,
+ provider: () => undefined,
+ }),
+ }))
+ const mod = await import("./terminal")
+ getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
+ getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
+})
+
+describe("getWorkspaceTerminalCacheKey", () => {
+ test("uses workspace-only directory cache key", () => {
+ expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
+ })
+})
+
+describe("getLegacyTerminalStorageKeys", () => {
+ test("keeps workspace storage path when no legacy session id", () => {
+ expect(getLegacyTerminalStorageKeys("/repo")).toEqual(["/repo/terminal.v1"])
+ })
+
+ test("includes legacy session path before workspace path", () => {
+ expect(getLegacyTerminalStorageKeys("/repo", "session-123")).toEqual([
+ "/repo/terminal/session-123.v1",
+ "/repo/terminal.v1",
+ ])
+ })
+})
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 0c383a78d..76e8cf0f7 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -19,15 +19,24 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
-type TerminalSession = ReturnType<typeof createTerminalSession>
+export function getWorkspaceTerminalCacheKey(dir: string) {
+ return `${dir}:${WORKSPACE_KEY}`
+}
+
+export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
+ if (!legacySessionID) return [`${dir}/terminal.v1`]
+ return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
+}
+
+type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
}
-function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
- const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
+function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
+ const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const numberFromTitle = (title: string) => {
const match = title.match(/^Terminal (\d+)$/)
@@ -235,8 +244,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
- const load = (dir: string, session?: string) => {
- const key = `${dir}:${WORKSPACE_KEY}`
+ const loadWorkspace = (dir: string, legacySessionID?: string) => {
+ // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
+ const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -245,7 +255,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
- value: createTerminalSession(sdk, dir, session),
+ value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose,
}))
@@ -254,7 +264,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
- const workspace = createMemo(() => load(params.dir!, params.id))
+ const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
return {
ready: () => workspace().ready(),
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 433e47925..67606e860 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -75,6 +75,8 @@ import {
} from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
+import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
+import { createScrollSpy } from "@/pages/session/scroll-spy"
type DiffStyle = "unified" | "split"
@@ -872,19 +874,7 @@ export default function Page() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
- const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
- const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
- if (!element) return
-
- // Find and focus the ghostty textarea (the actual input element)
- const textarea = element.querySelector("textarea") as HTMLTextAreaElement
- if (textarea) {
- textarea.focus()
- return
- }
- // Fallback: focus container and dispatch pointer event
- element.focus()
- element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+ focusTerminalById(activeId)
},
),
)
@@ -973,7 +963,7 @@ export default function Page() {
})
}
- command.register(() => [
+ const sessionCommands = createMemo(() => [
{
id: "session.new",
title: language.t("command.session.new"),
@@ -982,6 +972,9 @@ export default function Page() {
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
+ ])
+
+ const fileCommands = createMemo(() => [
{
id: "file.open",
title: language.t("command.file.open"),
@@ -989,7 +982,7 @@ export default function Page() {
category: language.t("command.category.file"),
keybind: "mod+p",
slash: "open",
- onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
+ onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
},
{
id: "tab.close",
@@ -1003,6 +996,9 @@ export default function Page() {
tabs().close(active)
},
},
+ ])
+
+ const contextCommands = createMemo(() => [
{
id: "context.addSelection",
title: language.t("command.context.addSelection"),
@@ -1034,6 +1030,9 @@ export default function Page() {
addSelectionToContext(path, selectionFromLines(range))
},
},
+ ])
+
+ const viewCommands = createMemo(() => [
{
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
@@ -1087,6 +1086,9 @@ export default function Page() {
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
+ ])
+
+ const messageCommands = createMemo(() => [
{
id: "message.previous",
title: language.t("command.message.previous"),
@@ -1105,6 +1107,9 @@ export default function Page() {
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
},
+ ])
+
+ const agentCommands = createMemo(() => [
{
id: "model.choose",
title: language.t("command.model.choose"),
@@ -1150,6 +1155,9 @@ export default function Page() {
local.model.variant.cycle()
},
},
+ ])
+
+ const permissionCommands = createMemo(() => [
{
id: "permissions.autoaccept",
title:
@@ -1173,6 +1181,9 @@ export default function Page() {
})
},
},
+ ])
+
+ const sessionActionCommands = createMemo(() => [
{
id: "session.undo",
title: language.t("command.session.undo"),
@@ -1187,17 +1198,14 @@ export default function Page() {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
- // Find the last user message that's not already reverted
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
- // Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
- // Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
@@ -1216,17 +1224,13 @@ export default function Page() {
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
- // Full unrevert - restore all messages and navigate to last
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
- // Navigate to the last message (the one that was at the revert point)
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
- // Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
- // Navigate to the message before the new revert point
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
@@ -1265,74 +1269,90 @@ export default function Page() {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
- ...(sync.data.config.share !== "disabled"
- ? [
- {
- id: "session.share",
- title: language.t("command.session.share"),
- description: language.t("command.session.share.description"),
- category: language.t("command.category.session"),
- slash: "share",
- disabled: !params.id || !!info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .share({ sessionID: params.id })
- .then((res) => {
- navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
- showToast({
- title: language.t("toast.session.share.copyFailed.title"),
- variant: "error",
- }),
- )
- })
- .then(() =>
- showToast({
- title: language.t("toast.session.share.success.title"),
- description: language.t("toast.session.share.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.share.failed.title"),
- description: language.t("toast.session.share.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- {
- id: "session.unshare",
- title: language.t("command.session.unshare"),
- description: language.t("command.session.unshare.description"),
- category: language.t("command.category.session"),
- slash: "unshare",
- disabled: !params.id || !info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .unshare({ sessionID: params.id })
- .then(() =>
- showToast({
- title: language.t("toast.session.unshare.success.title"),
- description: language.t("toast.session.unshare.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.unshare.failed.title"),
- description: language.t("toast.session.unshare.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- ]
- : []),
])
+ const shareCommands = createMemo(() => {
+ if (sync.data.config.share === "disabled") return []
+ return [
+ {
+ id: "session.share",
+ title: language.t("command.session.share"),
+ description: language.t("command.session.share.description"),
+ category: language.t("command.category.session"),
+ slash: "share",
+ disabled: !params.id || !!info()?.share?.url,
+ onSelect: async () => {
+ if (!params.id) return
+ await sdk.client.session
+ .share({ sessionID: params.id })
+ .then((res) => {
+ navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
+ showToast({
+ title: language.t("toast.session.share.copyFailed.title"),
+ variant: "error",
+ }),
+ )
+ })
+ .then(() =>
+ showToast({
+ title: language.t("toast.session.share.success.title"),
+ description: language.t("toast.session.share.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: language.t("toast.session.share.failed.title"),
+ description: language.t("toast.session.share.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ {
+ id: "session.unshare",
+ title: language.t("command.session.unshare"),
+ description: language.t("command.session.unshare.description"),
+ category: language.t("command.category.session"),
+ slash: "unshare",
+ disabled: !params.id || !info()?.share?.url,
+ onSelect: async () => {
+ if (!params.id) return
+ await sdk.client.session
+ .unshare({ sessionID: params.id })
+ .then(() =>
+ showToast({
+ title: language.t("toast.session.unshare.success.title"),
+ description: language.t("toast.session.unshare.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: language.t("toast.session.unshare.failed.title"),
+ description: language.t("toast.session.unshare.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ ]
+ })
+
+ command.register("session", () =>
+ combineCommandSections([
+ sessionCommands(),
+ fileCommands(),
+ contextCommands(),
+ viewCommands(),
+ messageCommands(),
+ agentCommands(),
+ permissionCommands(),
+ sessionActionCommands(),
+ shareCommands(),
+ ]),
+ )
+
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
@@ -1407,19 +1427,7 @@ export default function Page() {
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
- const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
- const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
- if (!element) return
-
- // Find and focus the ghostty textarea (the actual input element)
- const textarea = element.querySelector("textarea") as HTMLTextAreaElement
- if (textarea) {
- textarea.focus()
- return
- }
- // Fallback: focus container and dispatch pointer event
- element.focus()
- element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+ focusTerminalById(activeId)
}, 0)
}
@@ -1457,6 +1465,13 @@ export default function Page() {
setFileTreeTab("all")
}
+ const openReviewFile = createOpenReviewFile({
+ showAllFiles,
+ tabForPath: file.tab,
+ openTab: tabs().open,
+ loadFile: file.load,
+ })
+
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
@@ -1481,65 +1496,72 @@ export default function Page() {
</div>
)
+ const reviewContent = (input: {
+ diffStyle: DiffStyle
+ onDiffStyleChange?: (style: DiffStyle) => void
+ classes?: SessionReviewTabProps["classes"]
+ loadingClass: string
+ emptyClass: string
+ }) => (
+ <Switch>
+ <Match when={store.changes === "turn" && !!params.id}>
+ <SessionReviewTab
+ title={changesTitle()}
+ empty={emptyTurn()}
+ diffs={reviewDiffs}
+ view={view}
+ diffStyle={input.diffStyle}
+ onDiffStyleChange={input.onDiffStyleChange}
+ onScrollRef={(el) => setTree("reviewScroll", el)}
+ focusedFile={tree.activeDiff}
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ comments={comments.all()}
+ focusedComment={comments.focus()}
+ onFocusedCommentChange={comments.setFocus}
+ onViewFile={openReviewFile}
+ classes={input.classes}
+ />
+ </Match>
+ <Match when={hasReview()}>
+ <Show
+ when={diffsReady()}
+ fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
+ >
+ <SessionReviewTab
+ title={changesTitle()}
+ diffs={reviewDiffs}
+ view={view}
+ diffStyle={input.diffStyle}
+ onDiffStyleChange={input.onDiffStyleChange}
+ onScrollRef={(el) => setTree("reviewScroll", el)}
+ focusedFile={tree.activeDiff}
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ comments={comments.all()}
+ focusedComment={comments.focus()}
+ onFocusedCommentChange={comments.setFocus}
+ onViewFile={openReviewFile}
+ classes={input.classes}
+ />
+ </Show>
+ </Match>
+ <Match when={true}>
+ <div class={input.emptyClass}>
+ <Mark class="w-14 opacity-10" />
+ <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
+ </div>
+ </Match>
+ </Switch>
+ )
+
const reviewPanel = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <Switch>
- <Match when={store.changes === "turn" && !!params.id}>
- <SessionReviewTab
- title={changesTitle()}
- empty={emptyTurn()}
- diffs={reviewDiffs}
- view={view}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- onScrollRef={(el) => setTree("reviewScroll", el)}
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- />
- </Match>
- <Match when={hasReview()}>
- <Show
- when={diffsReady()}
- fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
- >
- <SessionReviewTab
- title={changesTitle()}
- diffs={reviewDiffs}
- view={view}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- onScrollRef={(el) => setTree("reviewScroll", el)}
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- />
- </Show>
- </Match>
- <Match when={true}>
- <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
- </div>
- </Match>
- </Switch>
+ {reviewContent({
+ diffStyle: layout.review.diffStyle(),
+ onDiffStyleChange: layout.review.setDiffStyle,
+ loadingClass: "px-6 py-4 text-text-weak",
+ emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
+ })}
</div>
</div>
)
@@ -1656,6 +1678,12 @@ export default function Page() {
return "empty"
})
+ const activeFileTab = createMemo(() => {
+ const active = activeTab()
+ if (!openedTabs().includes(active)) return
+ return active
+ })
+
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
@@ -1760,6 +1788,12 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
+ const scrollSpy = createScrollSpy({
+ onActive: (id) => {
+ if (id === store.messageId) return
+ setStore("messageId", id)
+ },
+ })
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
@@ -1807,16 +1841,11 @@ export default function Page() {
),
)
- let scrollSpyFrame: number | undefined
- let scrollSpyTarget: HTMLDivElement | undefined
-
createEffect(
on(
sessionKey,
() => {
- if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
- scrollSpyFrame = undefined
- scrollSpyTarget = undefined
+ scrollSpy.clear()
},
{ defer: true },
),
@@ -1827,6 +1856,7 @@ export default function Page() {
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
+ scrollSpy.setContainer(el)
if (el) scheduleScrollState(el)
}
@@ -1835,6 +1865,7 @@ export default function Page() {
() => {
const el = scroller
if (el) scheduleScrollState(el)
+ scrollSpy.markDirty()
},
)
@@ -1940,6 +1971,7 @@ export default function Page() {
}
if (el) scheduleScrollState(el)
+ scrollSpy.markDirty()
},
)
@@ -2053,61 +2085,6 @@ export default function Page() {
if (el) scheduleScrollState(el)
}
- const closestMessage = (node: Element | null): HTMLElement | null => {
- if (!node) return null
- const match = node.closest?.("[data-message-id]") as HTMLElement | null
- if (match) return match
- const root = node.getRootNode?.()
- if (root instanceof ShadowRoot) return closestMessage(root.host)
- return null
- }
-
- const getActiveMessageId = (container: HTMLDivElement) => {
- const rect = container.getBoundingClientRect()
- if (!rect.width || !rect.height) return
-
- const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
- const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
-
- const hit = document.elementFromPoint(x, y)
- const host = closestMessage(hit)
- const id = host?.dataset.messageId
- if (id) return id
-
- // Fallback: DOM query (handles edge hit-testing cases)
- const cutoff = container.scrollTop + 100
- const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
- let last: string | undefined
-
- for (const node of nodes) {
- const next = node.dataset.messageId
- if (!next) continue
- if (node.offsetTop > cutoff) break
- last = next
- }
-
- return last
- }
-
- const scheduleScrollSpy = (container: HTMLDivElement) => {
- scrollSpyTarget = container
- if (scrollSpyFrame !== undefined) return
-
- scrollSpyFrame = requestAnimationFrame(() => {
- scrollSpyFrame = undefined
-
- const target = scrollSpyTarget
- scrollSpyTarget = undefined
- if (!target) return
-
- const id = getActiveMessageId(target)
- if (!id) return
- if (id === store.messageId) return
-
- setStore("messageId", id)
- })
- }
-
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
@@ -2215,7 +2192,7 @@ export default function Page() {
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
- if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
+ scrollSpy.destroy()
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
@@ -2272,74 +2249,16 @@ export default function Page() {
when={!mobileChanges()}
fallback={
<div class="relative h-full overflow-hidden">
- <Switch>
- <Match when={store.changes === "turn" && !!params.id}>
- <SessionReviewTab
- title={changesTitle()}
- empty={emptyTurn()}
- diffs={reviewDiffs}
- view={view}
- diffStyle="unified"
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- classes={{
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- }}
- />
- </Match>
- <Match when={hasReview()}>
- <Show
- when={diffsReady()}
- fallback={
- <div class="px-4 py-4 text-text-weak">
- {language.t("session.review.loadingChanges")}
- </div>
- }
- >
- <SessionReviewTab
- title={changesTitle()}
- diffs={reviewDiffs}
- view={view}
- diffStyle="unified"
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- classes={{
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- }}
- />
- </Show>
- </Match>
- <Match when={true}>
- <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="text-14-regular text-text-weak max-w-56">
- {language.t("session.review.empty")}
- </div>
- </div>
- </Match>
- </Switch>
+ {reviewContent({
+ diffStyle: "unified",
+ classes: {
+ root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+ header: "px-4",
+ container: "px-4",
+ },
+ loadingClass: "px-4 py-4 text-text-weak",
+ emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
+ })}
</div>
}
>
@@ -2451,7 +2370,7 @@ export default function Page() {
if (!hasScrollGesture()) return
autoScroll.handleScroll()
markScrollGesture(e.currentTarget)
- if (isDesktop()) scheduleScrollSpy(e.currentTarget)
+ if (isDesktop()) scrollSpy.onScroll()
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
@@ -2636,6 +2555,10 @@ export default function Page() {
<div
id={anchor(message.id)}
data-message-id={message.id}
+ ref={(el) => {
+ scrollSpy.register(el, message.id)
+ onCleanup(() => scrollSpy.unregister(message.id))
+ }}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 3xl:max-w-[1200px]": centered(),
@@ -2979,7 +2902,7 @@ export default function Page() {
</Tabs.Content>
</Show>
- <For each={openedTabs()}>
+ <Show when={activeFileTab()} keyed>
{(tab) => {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
@@ -3483,7 +3406,7 @@ export default function Page() {
</Tabs.Content>
)
}}
- </For>
+ </Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts
new file mode 100644
index 000000000..0afc7eb6a
--- /dev/null
+++ b/packages/app/src/pages/session/helpers.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers"
+
+describe("createOpenReviewFile", () => {
+ test("opens and loads selected review file", () => {
+ const calls: string[] = []
+ const openReviewFile = createOpenReviewFile({
+ showAllFiles: () => calls.push("show"),
+ tabForPath: (path) => {
+ calls.push(`tab:${path}`)
+ return `file://${path}`
+ },
+ openTab: (tab) => calls.push(`open:${tab}`),
+ loadFile: (path) => calls.push(`load:${path}`),
+ })
+
+ openReviewFile("src/a.ts")
+
+ expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"])
+ })
+})
+
+describe("focusTerminalById", () => {
+ test("focuses textarea when present", () => {
+ document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>`
+
+ const focused = focusTerminalById("one")
+
+ expect(focused).toBe(true)
+ expect(document.activeElement?.tagName).toBe("TEXTAREA")
+ })
+
+ test("falls back to terminal element focus", () => {
+ document.body.innerHTML = `<div id="terminal-wrapper-two"><div data-component="terminal" tabindex="0"></div></div>`
+ const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement
+ let pointerDown = false
+ terminal.addEventListener("pointerdown", () => {
+ pointerDown = true
+ })
+
+ const focused = focusTerminalById("two")
+
+ expect(focused).toBe(true)
+ expect(document.activeElement).toBe(terminal)
+ expect(pointerDown).toBe(true)
+ })
+})
+
+describe("combineCommandSections", () => {
+ test("keeps section order stable", () => {
+ const result = combineCommandSections([
+ [{ id: "a", title: "A" }],
+ [
+ { id: "b", title: "B" },
+ { id: "c", title: "C" },
+ ],
+ ])
+
+ expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
+ })
+})
diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts
new file mode 100644
index 000000000..d9ce90793
--- /dev/null
+++ b/packages/app/src/pages/session/helpers.ts
@@ -0,0 +1,38 @@
+import type { CommandOption } from "@/context/command"
+
+export const focusTerminalById = (id: string) => {
+ const wrapper = document.getElementById(`terminal-wrapper-${id}`)
+ const terminal = wrapper?.querySelector('[data-component="terminal"]')
+ if (!(terminal instanceof HTMLElement)) return false
+
+ const textarea = terminal.querySelector("textarea")
+ if (textarea instanceof HTMLTextAreaElement) {
+ textarea.focus()
+ return true
+ }
+
+ terminal.focus()
+ terminal.dispatchEvent(
+ typeof PointerEvent === "function"
+ ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
+ : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
+ )
+ return true
+}
+
+export const createOpenReviewFile = (input: {
+ showAllFiles: () => void
+ tabForPath: (path: string) => string
+ openTab: (tab: string) => void
+ loadFile: (path: string) => void
+}) => {
+ return (path: string) => {
+ input.showAllFiles()
+ input.openTab(input.tabForPath(path))
+ input.loadFile(path)
+ }
+}
+
+export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
+ return sections.flatMap((section) => section)
+}
diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts
new file mode 100644
index 000000000..f3e6775cb
--- /dev/null
+++ b/packages/app/src/pages/session/scroll-spy.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, test } from "bun:test"
+import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
+
+const rect = (top: number, height = 80): DOMRect =>
+ ({
+ x: 0,
+ y: top,
+ top,
+ left: 0,
+ right: 800,
+ bottom: top + height,
+ width: 800,
+ height,
+ toJSON: () => ({}),
+ }) as DOMRect
+
+const setRect = (el: Element, top: number, height = 80) => {
+ Object.defineProperty(el, "getBoundingClientRect", {
+ configurable: true,
+ value: () => rect(top, height),
+ })
+}
+
+describe("pickVisibleId", () => {
+ test("prefers higher intersection ratio", () => {
+ const id = pickVisibleId(
+ [
+ { id: "a", ratio: 0.2, top: 100 },
+ { id: "b", ratio: 0.8, top: 300 },
+ ],
+ 120,
+ )
+
+ expect(id).toBe("b")
+ })
+
+ test("breaks ratio ties by nearest line", () => {
+ const id = pickVisibleId(
+ [
+ { id: "a", ratio: 0.5, top: 90 },
+ { id: "b", ratio: 0.5, top: 140 },
+ ],
+ 130,
+ )
+
+ expect(id).toBe("b")
+ })
+})
+
+describe("pickOffsetId", () => {
+ test("uses binary search cutoff", () => {
+ const id = pickOffsetId(
+ [
+ { id: "a", top: 0 },
+ { id: "b", top: 200 },
+ { id: "c", top: 400 },
+ ],
+ 350,
+ )
+
+ expect(id).toBe("b")
+ })
+})
+
+describe("createScrollSpy fallback", () => {
+ test("tracks active id from offsets and dirty refresh", () => {
+ const active: string[] = []
+ const root = document.createElement("div") as HTMLDivElement
+ const one = document.createElement("div")
+ const two = document.createElement("div")
+ const three = document.createElement("div")
+
+ root.append(one, two, three)
+ document.body.append(root)
+
+ Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
+ setRect(root, 0, 800)
+ setRect(one, -250)
+ setRect(two, -50)
+ setRect(three, 150)
+
+ const queue: FrameRequestCallback[] = []
+ const flush = () => {
+ const run = [...queue]
+ queue.length = 0
+ for (const cb of run) cb(0)
+ }
+
+ const spy = createScrollSpy({
+ onActive: (id) => active.push(id),
+ raf: (cb) => (queue.push(cb), queue.length),
+ caf: () => {},
+ IntersectionObserver: undefined,
+ ResizeObserver: undefined,
+ MutationObserver: undefined,
+ })
+
+ spy.setContainer(root)
+ spy.register(one, "a")
+ spy.register(two, "b")
+ spy.register(three, "c")
+ spy.onScroll()
+ flush()
+
+ expect(spy.getActiveId()).toBe("b")
+ expect(active.at(-1)).toBe("b")
+
+ root.scrollTop = 450
+ setRect(one, -450)
+ setRect(two, -250)
+ setRect(three, -50)
+ spy.onScroll()
+ flush()
+ expect(spy.getActiveId()).toBe("c")
+
+ root.scrollTop = 250
+ setRect(one, -250)
+ setRect(two, 250)
+ setRect(three, 150)
+ spy.markDirty()
+ spy.onScroll()
+ flush()
+ expect(spy.getActiveId()).toBe("a")
+
+ spy.destroy()
+ })
+})
diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts
new file mode 100644
index 000000000..8c52d77dc
--- /dev/null
+++ b/packages/app/src/pages/session/scroll-spy.ts
@@ -0,0 +1,274 @@
+type Visible = {
+ id: string
+ ratio: number
+ top: number
+}
+
+type Offset = {
+ id: string
+ top: number
+}
+
+type Input = {
+ onActive: (id: string) => void
+ raf?: (cb: FrameRequestCallback) => number
+ caf?: (id: number) => void
+ IntersectionObserver?: typeof globalThis.IntersectionObserver
+ ResizeObserver?: typeof globalThis.ResizeObserver
+ MutationObserver?: typeof globalThis.MutationObserver
+}
+
+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
+
+ const da = Math.abs(a.top - line)
+ const db = Math.abs(b.top - line)
+ if (da !== db) return da - db
+
+ return a.top - b.top
+ })
+
+ return sorted[0]?.id
+}
+
+export const pickOffsetId = (list: Offset[], cutoff: number) => {
+ if (list.length === 0) return
+
+ let lo = 0
+ let hi = list.length - 1
+ let out = 0
+
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1
+ const top = list[mid]?.top
+ if (top === undefined) break
+
+ if (top <= cutoff) {
+ out = mid
+ lo = mid + 1
+ continue
+ }
+
+ hi = mid - 1
+ }
+
+ return list[out]?.id
+}
+
+export const createScrollSpy = (input: Input) => {
+ const raf = input.raf ?? requestAnimationFrame
+ const caf = input.caf ?? cancelAnimationFrame
+ const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
+ const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
+ const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
+
+ let root: HTMLDivElement | undefined
+ let io: IntersectionObserver | undefined
+ let ro: ResizeObserver | undefined
+ let mo: MutationObserver | undefined
+ let frame: number | undefined
+ let active: string | undefined
+ let dirty = true
+
+ const node = new Map<string, HTMLElement>()
+ const id = new WeakMap<HTMLElement, string>()
+ const visible = new Map<string, { ratio: number; top: number }>()
+ let offset: Offset[] = []
+
+ const schedule = () => {
+ if (frame !== undefined) return
+ frame = raf(() => {
+ frame = undefined
+ update()
+ })
+ }
+
+ const refreshOffset = () => {
+ const el = root
+ if (!el) {
+ offset = []
+ dirty = false
+ return
+ }
+
+ const base = el.getBoundingClientRect().top
+ offset = [...node].map(([next, item]) => ({
+ id: next,
+ top: item.getBoundingClientRect().top - base + el.scrollTop,
+ }))
+ offset.sort((a, b) => a.top - b.top)
+ dirty = false
+ }
+
+ const update = () => {
+ const el = root
+ if (!el) return
+
+ const line = el.getBoundingClientRect().top + 100
+ const next =
+ pickVisibleId(
+ [...visible].map(([k, v]) => ({
+ id: k,
+ ratio: v.ratio,
+ top: v.top,
+ })),
+ line,
+ ) ??
+ (() => {
+ if (dirty) refreshOffset()
+ return pickOffsetId(offset, el.scrollTop + 100)
+ })()
+
+ if (!next || next === active) return
+ active = next
+ input.onActive(next)
+ }
+
+ const observe = () => {
+ const el = root
+ if (!el) return
+
+ io?.disconnect()
+ io = undefined
+ if (CtorIO) {
+ try {
+ io = new CtorIO(
+ (entries) => {
+ for (const entry of entries) {
+ const item = entry.target
+ if (!(item instanceof HTMLElement)) continue
+ const key = id.get(item)
+ if (!key) continue
+
+ if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
+ visible.delete(key)
+ continue
+ }
+
+ visible.set(key, {
+ ratio: entry.intersectionRatio,
+ top: entry.boundingClientRect.top,
+ })
+ }
+
+ schedule()
+ },
+ {
+ root: el,
+ threshold: [0, 0.25, 0.5, 0.75, 1],
+ },
+ )
+ } catch {
+ io = undefined
+ }
+ }
+
+ if (io) {
+ for (const item of node.values()) io.observe(item)
+ }
+
+ ro?.disconnect()
+ ro = undefined
+ if (CtorRO) {
+ ro = new CtorRO(() => {
+ dirty = true
+ schedule()
+ })
+ ro.observe(el)
+ for (const item of node.values()) ro.observe(item)
+ }
+
+ mo?.disconnect()
+ mo = undefined
+ if (CtorMO) {
+ mo = new CtorMO(() => {
+ dirty = true
+ schedule()
+ })
+ mo.observe(el, { subtree: true, childList: true, characterData: true })
+ }
+
+ dirty = true
+ schedule()
+ }
+
+ const setContainer = (el?: HTMLDivElement) => {
+ if (root === el) return
+
+ root = el
+ visible.clear()
+ active = undefined
+ observe()
+ }
+
+ const register = (el: HTMLElement, key: string) => {
+ const prev = node.get(key)
+ if (prev && prev !== el) {
+ io?.unobserve(prev)
+ ro?.unobserve(prev)
+ }
+
+ node.set(key, el)
+ id.set(el, key)
+ if (io) io.observe(el)
+ if (ro) ro.observe(el)
+ dirty = true
+ schedule()
+ }
+
+ const unregister = (key: string) => {
+ const item = node.get(key)
+ if (!item) return
+
+ io?.unobserve(item)
+ ro?.unobserve(item)
+ node.delete(key)
+ visible.delete(key)
+ dirty = true
+ }
+
+ const markDirty = () => {
+ dirty = true
+ schedule()
+ }
+
+ const clear = () => {
+ for (const item of node.values()) {
+ io?.unobserve(item)
+ ro?.unobserve(item)
+ }
+
+ node.clear()
+ visible.clear()
+ offset = []
+ active = undefined
+ dirty = true
+ }
+
+ const destroy = () => {
+ if (frame !== undefined) caf(frame)
+ frame = undefined
+ clear()
+ io?.disconnect()
+ ro?.disconnect()
+ mo?.disconnect()
+ io = undefined
+ ro = undefined
+ mo = undefined
+ root = undefined
+ }
+
+ return {
+ setContainer,
+ register,
+ unregister,
+ onScroll: schedule,
+ markDirty,
+ clear,
+ destroy,
+ getActiveId: () => active,
+ }
+}
diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts
new file mode 100644
index 000000000..0c6189daf
--- /dev/null
+++ b/packages/app/src/utils/scoped-cache.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { createScopedCache } from "./scoped-cache"
+
+describe("createScopedCache", () => {
+ test("evicts least-recently-used entry when max is reached", () => {
+ const disposed: string[] = []
+ const cache = createScopedCache((key) => ({ key }), {
+ maxEntries: 2,
+ dispose: (value) => disposed.push(value.key),
+ })
+
+ const a = cache.get("a")
+ const b = cache.get("b")
+ expect(a.key).toBe("a")
+ expect(b.key).toBe("b")
+
+ cache.get("a")
+ const c = cache.get("c")
+
+ expect(c.key).toBe("c")
+ expect(cache.peek("a")?.key).toBe("a")
+ expect(cache.peek("b")).toBeUndefined()
+ expect(cache.peek("c")?.key).toBe("c")
+ expect(disposed).toEqual(["b"])
+ })
+
+ test("disposes entries on delete and clear", () => {
+ const disposed: string[] = []
+ const cache = createScopedCache((key) => ({ key }), {
+ dispose: (value) => disposed.push(value.key),
+ })
+
+ cache.get("a")
+ cache.get("b")
+
+ const removed = cache.delete("a")
+ expect(removed?.key).toBe("a")
+ expect(cache.peek("a")).toBeUndefined()
+
+ cache.clear()
+ expect(cache.peek("b")).toBeUndefined()
+ expect(disposed).toEqual(["a", "b"])
+ })
+
+ test("expires stale entries with ttl and recreates on get", () => {
+ let clock = 0
+ let count = 0
+ const disposed: string[] = []
+ const cache = createScopedCache((key) => ({ key, count: ++count }), {
+ ttlMs: 10,
+ now: () => clock,
+ dispose: (value) => disposed.push(`${value.key}:${value.count}`),
+ })
+
+ const first = cache.get("a")
+ expect(first.count).toBe(1)
+
+ clock = 9
+ expect(cache.peek("a")?.count).toBe(1)
+
+ clock = 11
+ expect(cache.peek("a")).toBeUndefined()
+ expect(disposed).toEqual(["a:1"])
+
+ const second = cache.get("a")
+ expect(second.count).toBe(2)
+ expect(disposed).toEqual(["a:1"])
+ })
+})
diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts
new file mode 100644
index 000000000..224c363c1
--- /dev/null
+++ b/packages/app/src/utils/scoped-cache.ts
@@ -0,0 +1,104 @@
+type ScopedCacheOptions<T> = {
+ maxEntries?: number
+ ttlMs?: number
+ dispose?: (value: T, key: string) => void
+ now?: () => number
+}
+
+type Entry<T> = {
+ value: T
+ touchedAt: number
+}
+
+export function createScopedCache<T>(createValue: (key: string) => T, options: ScopedCacheOptions<T> = {}) {
+ const store = new Map<string, Entry<T>>()
+ const now = options.now ?? Date.now
+
+ const dispose = (key: string, entry: Entry<T>) => {
+ options.dispose?.(entry.value, key)
+ }
+
+ const expired = (entry: Entry<T>) => {
+ if (options.ttlMs === undefined) return false
+ return now() - entry.touchedAt >= options.ttlMs
+ }
+
+ const sweep = () => {
+ if (options.ttlMs === undefined) return
+ for (const [key, entry] of store) {
+ if (!expired(entry)) continue
+ store.delete(key)
+ dispose(key, entry)
+ }
+ }
+
+ const touch = (key: string, entry: Entry<T>) => {
+ entry.touchedAt = now()
+ store.delete(key)
+ store.set(key, entry)
+ }
+
+ const prune = () => {
+ if (options.maxEntries === undefined) return
+ while (store.size > options.maxEntries) {
+ const key = store.keys().next().value
+ if (!key) return
+ const entry = store.get(key)
+ store.delete(key)
+ if (!entry) continue
+ dispose(key, entry)
+ }
+ }
+
+ const remove = (key: string) => {
+ const entry = store.get(key)
+ if (!entry) return
+ store.delete(key)
+ dispose(key, entry)
+ return entry.value
+ }
+
+ const peek = (key: string) => {
+ sweep()
+ const entry = store.get(key)
+ if (!entry) return
+ if (!expired(entry)) return entry.value
+ store.delete(key)
+ dispose(key, entry)
+ }
+
+ const get = (key: string) => {
+ sweep()
+ const entry = store.get(key)
+ if (entry && !expired(entry)) {
+ touch(key, entry)
+ return entry.value
+ }
+ if (entry) {
+ store.delete(key)
+ dispose(key, entry)
+ }
+
+ const created = {
+ value: createValue(key),
+ touchedAt: now(),
+ }
+ store.set(key, created)
+ prune()
+ return created.value
+ }
+
+ const clear = () => {
+ for (const [key, entry] of store) {
+ dispose(key, entry)
+ }
+ store.clear()
+ }
+
+ return {
+ get,
+ peek,
+ delete: remove,
+ clear,
+ }
+}