diff options
| author | Adam <[email protected]> | 2026-02-06 09:37:49 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 09:37:49 -0600 |
| commit | a4bc883595df9ea0f752079519081bc602408553 (patch) | |
| tree | 583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app | |
| parent | c07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff) | |
| download | opencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz opencode-a4bc883595df9ea0f752079519081bc602408553.zip | |
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app')
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, + } +} |
