chore: refactoring and tests (#12468)
This commit is contained in:
133
.github/workflows/test.yml
vendored
133
.github/workflows/test.yml
vendored
@@ -7,8 +7,32 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.settings.name }})
|
||||
unit:
|
||||
name: unit (linux)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo test
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -16,17 +40,12 @@ jobs:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
workdir: .
|
||||
command: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun turbo test
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
playwright: bunx playwright install
|
||||
workdir: packages/app
|
||||
command: bun test:e2e:local
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -43,87 +62,10 @@ jobs:
|
||||
working-directory: packages/app
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
|
||||
- name: Set OS-specific paths
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
||||
else
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Seed opencode data
|
||||
if: matrix.settings.name != 'windows'
|
||||
working-directory: packages/opencode
|
||||
run: bun script/seed-e2e.ts
|
||||
env:
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
||||
|
||||
- name: Run opencode server
|
||||
if: matrix.settings.name != 'windows'
|
||||
working-directory: packages/opencode
|
||||
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
|
||||
env:
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_CLIENT: "app"
|
||||
|
||||
- name: Wait for opencode server
|
||||
if: matrix.settings.name != 'windows'
|
||||
run: |
|
||||
for i in {1..120}; do
|
||||
curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: run
|
||||
working-directory: ${{ matrix.settings.workdir }}
|
||||
run: ${{ matrix.settings.command }}
|
||||
- name: Run app e2e tests
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
|
||||
PLAYWRIGHT_SERVER_PORT: "4096"
|
||||
VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
|
||||
VITE_OPENCODE_SERVER_PORT: "4096"
|
||||
OPENCODE_CLIENT: "app"
|
||||
timeout-minutes: 30
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
@@ -136,3 +78,18 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
|
||||
required:
|
||||
name: test (linux)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
needs:
|
||||
- unit
|
||||
- e2e
|
||||
if: always()
|
||||
steps:
|
||||
- name: Verify upstream test jobs passed
|
||||
run: |
|
||||
echo "unit=${{ needs.unit.result }}"
|
||||
echo "e2e=${{ needs.e2e.result }}"
|
||||
test "${{ needs.unit.result }}" = "success"
|
||||
test "${{ needs.e2e.result }}" = "success"
|
||||
|
||||
@@ -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",
|
||||
|
||||
77
packages/app/src/components/file-tree.test.ts
Normal file
77
packages/app/src/components/file-tree.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,82 +736,58 @@ 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isFocused,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDragging: (value) => setStore("dragging", value),
|
||||
addPart,
|
||||
})
|
||||
|
||||
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") {
|
||||
const selection = window.getSelection()
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
132
packages/app/src/components/prompt-input/attachments.ts
Normal file
132
packages/app/src/components/prompt-input/attachments.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
51
packages/app/src/components/prompt-input/editor-dom.test.ts
Normal file
51
packages/app/src/components/prompt-input/editor-dom.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
135
packages/app/src/components/prompt-input/editor-dom.ts
Normal file
135
packages/app/src/components/prompt-input/editor-dom.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
69
packages/app/src/components/prompt-input/history.test.ts
Normal file
69
packages/app/src/components/prompt-input/history.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
160
packages/app/src/components/prompt-input/history.ts
Normal file
160
packages/app/src/components/prompt-input/history.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
587
packages/app/src/components/prompt-input/submit.ts
Normal file
587
packages/app/src/components/prompt-input/submit.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
111
packages/app/src/context/comments.test.ts
Normal file
111
packages/app/src/context/comments.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
list,
|
||||
all: () => state.all,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
focus: () => state.focus,
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
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,
|
||||
all,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
focus: createMemo(() => state.focus),
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
active: createMemo(() => state.active),
|
||||
setActive,
|
||||
clearActive: () => setActive(null),
|
||||
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 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(),
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
contentLru.delete(path)
|
||||
contentLru.set(path, value)
|
||||
setContentBytes(path, bytes ?? prev ?? 0)
|
||||
}
|
||||
|
||||
type ViewSession = ReturnType<typeof createViewSession>
|
||||
function removeContentBytes(path: string) {
|
||||
const prev = contentLru.get(path)
|
||||
if (prev === undefined) return
|
||||
contentLru.delete(path)
|
||||
contentBytesTotal -= prev
|
||||
}
|
||||
|
||||
type ViewCacheEntry = {
|
||||
value: ViewSession
|
||||
dispose: VoidFunction
|
||||
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)
|
||||
}
|
||||
|
||||
export function removeFileContentBytes(path: string) {
|
||||
removeContentBytes(path)
|
||||
}
|
||||
|
||||
export function touchFileContent(path: string, bytes?: number) {
|
||||
touchContent(path, bytes)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
69
packages/app/src/context/layout.test.ts
Normal file
69
packages/app/src/context/layout.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
38
packages/app/src/context/terminal.test.ts
Normal file
38
packages/app/src/context/terminal.test.ts
Normal file
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}>
|
||||
|
||||
61
packages/app/src/pages/session/helpers.test.ts
Normal file
61
packages/app/src/pages/session/helpers.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
})
|
||||
38
packages/app/src/pages/session/helpers.ts
Normal file
38
packages/app/src/pages/session/helpers.ts
Normal file
@@ -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)
|
||||
}
|
||||
127
packages/app/src/pages/session/scroll-spy.test.ts
Normal file
127
packages/app/src/pages/session/scroll-spy.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
274
packages/app/src/pages/session/scroll-spy.ts
Normal file
274
packages/app/src/pages/session/scroll-spy.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
69
packages/app/src/utils/scoped-cache.test.ts
Normal file
69
packages/app/src/utils/scoped-cache.test.ts
Normal file
@@ -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"])
|
||||
})
|
||||
})
|
||||
104
packages/app/src/utils/scoped-cache.ts
Normal file
104
packages/app/src/utils/scoped-cache.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
105
specs/09-session-page-hot-paths.md
Normal file
105
specs/09-session-page-hot-paths.md
Normal file
@@ -0,0 +1,105 @@
|
||||
## Session hot paths
|
||||
|
||||
Reduce render work and duplication in `session.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/pages/session.tsx` mixes routing, commands, tab rendering, review panel wiring, terminal focus logic, and message scrolling. This spec targets hot-path performance + local code quality improvements that can ship together in one session-page-focused PR. It should follow the keyed command-registration pattern introduced in `packages/app/src/context/command.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Render heavy file-tab content only for the active tab
|
||||
- Deduplicate review-panel wiring used in desktop and mobile paths
|
||||
- Centralize terminal-focus DOM logic into one helper
|
||||
- Reduce churn in command registration setup
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Scroll-spy rewrite (covered by `specs/04-scroll-spy-optimization.md`)
|
||||
- Large routing/layout redesign
|
||||
- Behavior changes to prompt submission or session history
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- New files under `packages/app/src/pages/session/*` (if extracted)
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/context/*`
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- `packages/app/src/components/file-tree.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Add shared helpers for repeated session-page actions
|
||||
|
||||
- Extract `openReviewFile(path)` helper to replace repeated inline `onViewFile` bodies.
|
||||
- Extract `focusTerminalById(id)` helper and reuse in both:
|
||||
- terminal active change effect
|
||||
- terminal drag-end focus restoration
|
||||
|
||||
2. Deduplicate review panel construction
|
||||
|
||||
- Build a shared review props factory (or local render helper) so desktop/mobile paths do not duplicate comment wiring, `onViewFile`, and classes glue.
|
||||
- Keep per-surface differences limited to layout classes and diff style.
|
||||
|
||||
3. Gate heavy file-tab rendering by active tab
|
||||
|
||||
- Keep tab trigger list rendered for all opened tabs.
|
||||
- Render `Tabs.Content` body only for `activeTab()`, plus lightweight placeholders as needed.
|
||||
- Ensure per-tab scroll state restore still works when reactivating a tab.
|
||||
|
||||
4. Reduce command registry reallocation
|
||||
|
||||
- Register session commands with a stable key (`command.register("session", ...)`) so remounts replace prior session command entries.
|
||||
- Move large command-array construction into smaller memoized blocks:
|
||||
- stable command definitions
|
||||
- dynamic state fields (`disabled`, titles) as narrow computed closures
|
||||
- Keep command IDs, keybinds, and behavior identical.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- File tab bodies are not all mounted at once for large open-tab sets.
|
||||
- `onViewFile` review behavior is defined in one shared helper.
|
||||
- Terminal focus query/dispatch logic lives in one function and is reused.
|
||||
- Session command registration uses a stable key (`"session"`) and `command.register` no longer contains one monolithic inline array with repeated inline handlers for shared actions.
|
||||
- Session UX remains unchanged for:
|
||||
- opening files from review
|
||||
- drag-reordering terminal tabs
|
||||
- keyboard command execution
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Open 12+ file tabs, switch quickly, verify active tab restore and no blank states.
|
||||
- Open review panel (desktop and mobile), use "view file" from diffs, verify same behavior as before.
|
||||
- Drag terminal tab, ensure terminal input focus is restored.
|
||||
- Run key commands: `mod+p`, `mod+w`, `mod+shift+r`, `ctrl+``.
|
||||
- Perf sanity:
|
||||
- Compare CPU usage while switching tabs with many opened files before/after.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: unmounted tab content loses transient editor state.
|
||||
- Mitigation: keep persisted scroll/selection restore path intact and verify reactivation behavior.
|
||||
- Risk: command refactor subtly changes command ordering.
|
||||
- Mitigation: keep IDs and registration order stable, diff against current command list in dev.
|
||||
99
specs/10-file-content-eviction-accounting.md
Normal file
99
specs/10-file-content-eviction-accounting.md
Normal file
@@ -0,0 +1,99 @@
|
||||
## File cache accounting
|
||||
|
||||
Make file-content eviction bookkeeping O(1)
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/file.tsx` currently recomputes total cached bytes by reducing the entire LRU map inside the eviction loop. This creates avoidable overhead on large file sets. We will switch to incremental byte accounting while keeping LRU behavior unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Remove repeated full-map reductions from eviction path
|
||||
- Maintain accurate total byte tracking incrementally
|
||||
- Preserve existing eviction semantics (entry count + byte cap)
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing cache limits
|
||||
- Changing file loading API behavior
|
||||
- Introducing cross-session shared caches
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/file.tsx`
|
||||
- Optional tests in `packages/app/src/context/*file*.test.ts`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/components/file-tree.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Introduce incremental byte counters
|
||||
|
||||
- Add module-level `contentBytesTotal`.
|
||||
- Add helper(s):
|
||||
- `setContentBytes(path, nextBytes)`
|
||||
- `removeContentBytes(path)`
|
||||
- `resetContentBytes()`
|
||||
|
||||
2. Refactor LRU touch/update path
|
||||
|
||||
- Keep `contentLru` as LRU order map.
|
||||
- Update byte total only when a path is inserted/updated/removed.
|
||||
- Ensure replacing existing byte value updates total correctly.
|
||||
|
||||
3. Refactor eviction loop
|
||||
|
||||
- Use `contentBytesTotal` in loop condition instead of `Array.from(...).reduce(...)`.
|
||||
- On eviction, remove from both `contentLru` and byte counter.
|
||||
|
||||
4. Keep scope reset correct
|
||||
|
||||
- On directory scope change, clear inflight maps + `contentLru` + byte counter.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `evictContent` performs no full-map reduction per iteration.
|
||||
- Total bytes remain accurate after:
|
||||
- loading file A
|
||||
- loading file B
|
||||
- force-reloading file A with a different size
|
||||
- evicting entries
|
||||
- scope reset
|
||||
- Existing caps (`MAX_FILE_CONTENT_ENTRIES`, `MAX_FILE_CONTENT_BYTES`) continue to enforce correctly.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Open many files with mixed sizes and verify old files still evict as before.
|
||||
- Switch directory scope and verify cache clears safely.
|
||||
- Optional unit coverage:
|
||||
- size counter updates on overwrite + delete.
|
||||
- eviction condition uses count and bytes as expected.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: byte counter drifts from map contents.
|
||||
- Mitigation: route all updates through centralized helpers.
|
||||
- Risk: stale bytes retained on early returns.
|
||||
- Mitigation: assert cleanup paths in `finally`/scope reset still execute.
|
||||
92
specs/11-layout-view-tabs-reactivity.md
Normal file
92
specs/11-layout-view-tabs-reactivity.md
Normal file
@@ -0,0 +1,92 @@
|
||||
## Layout reactivity
|
||||
|
||||
Reduce per-call reactive overhead in `useLayout`
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/layout.tsx` creates reactive effects inside `view(sessionKey)` and `tabs(sessionKey)` each time these helpers are called. Multiple consumers for the same key can accumulate duplicate watchers. This spec simplifies the API internals so calls stay lightweight while preserving behavior.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Remove avoidable per-call `createEffect` allocations in `view()` and `tabs()`
|
||||
- Preserve scroll seeding, pruning, and touch semantics
|
||||
- Keep external `useLayout` API stable
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Persistence schema migration
|
||||
- Session tab behavior redesign
|
||||
- New layout features
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/layout.tsx`
|
||||
- `packages/app/src/context/layout-scroll.test.ts` (if updates needed)
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/components/session/*`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Consolidate key-touch logic
|
||||
|
||||
- Introduce shared internal helper, e.g. `ensureSessionKey(key)` that performs:
|
||||
- `touch(key)`
|
||||
- `scroll.seed(key)`
|
||||
|
||||
2. Remove per-call effects in `view()` / `tabs()`
|
||||
|
||||
- Replace internal `createEffect(on(key, ...))` usage with lazy key reads inside accessors/memos.
|
||||
- Ensure reads still invoke `ensureSessionKey` at safe points.
|
||||
|
||||
3. Keep return API stable
|
||||
|
||||
- Preserve current method names and behavior:
|
||||
- `view(...).scroll`, `setScroll`, `terminal`, `reviewPanel`, `review`
|
||||
- `tabs(...).active`, `all`, `open`, `close`, `move`, etc.
|
||||
|
||||
4. Verify pruning behavior
|
||||
|
||||
- Ensure session-key pruning still runs when key set grows and active key changes.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `view()` and `tabs()` no longer instantiate per-call key-change effects.
|
||||
- Existing callers do not require API changes.
|
||||
- Scroll restore and tab persistence still work across session navigation.
|
||||
- No regressions in handoff/pending-message behavior.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Navigate across multiple sessions; verify tabs + review open state + scroll positions restore.
|
||||
- Toggle terminal/review panels and confirm persisted state remains consistent.
|
||||
- Tests:
|
||||
- Update/add targeted tests for key seeding/pruning if behavior changed.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: subtle key-touch ordering changes affect prune timing.
|
||||
- Mitigation: keep `touch` and `seed` coupled through one helper and verify prune boundaries.
|
||||
- Risk: removing effects misses updates for dynamic accessor keys.
|
||||
- Mitigation: ensure every public accessor path reads current key and calls helper.
|
||||
96
specs/12-session-context-metrics-shared.md
Normal file
96
specs/12-session-context-metrics-shared.md
Normal file
@@ -0,0 +1,96 @@
|
||||
## Context metrics shared
|
||||
|
||||
Unify duplicate session usage calculations
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`session-context-tab.tsx` and `session-context-usage.tsx` both compute overlapping session metrics (cost, last assistant token totals, provider/model context usage). This creates duplicate loops and raises drift risk. We will centralize shared calculations in one helper module and have both components consume it.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Compute shared session usage metrics in one place
|
||||
- Remove duplicate loops for cost and latest-token context usage
|
||||
- Keep UI output unchanged in both components
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Rewriting the detailed context breakdown estimator logic
|
||||
- Changing translations or labels
|
||||
- Moving metrics into backend API responses
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/components/session/session-context-tab.tsx`
|
||||
- `packages/app/src/components/session-context-usage.tsx`
|
||||
- New helper in `packages/app/src/components/session/*` or `packages/app/src/utils/*`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/context/sync.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Add shared metrics helper
|
||||
|
||||
- Create helper for raw metrics from message list + provider map, e.g.:
|
||||
- `totalCost`
|
||||
- `lastAssistantWithTokens`
|
||||
- `tokenTotal`
|
||||
- `tokenUsagePercent`
|
||||
- provider/model labels
|
||||
- Return raw numeric values; keep locale formatting in consumers.
|
||||
|
||||
2. Add memoization guard
|
||||
|
||||
- Use reference-based memoization (e.g. by message-array identity) inside helper or component-level memo to avoid duplicate recalculation on unchanged arrays.
|
||||
|
||||
3. Migrate both components
|
||||
|
||||
- Replace duplicated loops in:
|
||||
- `session-context-tab.tsx`
|
||||
- `session-context-usage.tsx`
|
||||
- Keep existing UI structure and i18n keys unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Shared cost + token calculations are defined in one module.
|
||||
- Both components read from the shared helper.
|
||||
- Rendered values remain identical for:
|
||||
- total cost
|
||||
- token totals
|
||||
- usage percentage
|
||||
- provider/model fallback labels
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Open session context tab and compare values with header/context indicator tooltip.
|
||||
- Verify values update correctly while new assistant messages stream in.
|
||||
- Regression:
|
||||
- locale change still formats numbers/currency correctly.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: helper changes semantic edge cases (no provider, no model, missing token fields).
|
||||
- Mitigation: preserve existing fallback behavior (`"—"`, null percent).
|
||||
- Risk: memoization over-caches stale values.
|
||||
- Mitigation: key cache by message-array reference and dependent IDs only.
|
||||
88
specs/13-file-tree-fetch-discipline.md
Normal file
88
specs/13-file-tree-fetch-discipline.md
Normal file
@@ -0,0 +1,88 @@
|
||||
## File tree fetches
|
||||
|
||||
Make directory listing triggers explicit and minimal
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/components/file-tree.tsx` currently invokes `file.tree.list(path)` from a generic effect in each tree instance. Even with inflight guards, this pattern causes avoidable list calls and makes load behavior harder to reason about. This spec tightens fetch triggers.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Avoid redundant list invocations from passive rerenders
|
||||
- Fetch directory data only when needed (mount + expansion + explicit refresh)
|
||||
- Keep tree behavior unchanged for users
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Replacing recursive tree rendering with virtualization
|
||||
- Changing file-tree visual design
|
||||
- Backend/API changes for file listing
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/components/file-tree.tsx`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/context/file.tsx`
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Replace broad list effect with explicit triggers
|
||||
|
||||
- Load root path on mount.
|
||||
- For nested directories, list only when:
|
||||
- node is expanded, or
|
||||
- parent explicitly requests refresh.
|
||||
|
||||
2. Guard expansion-driven fetches
|
||||
|
||||
- Keep `file.tree.expand(path)` as the primary source of truth for expansion fetches.
|
||||
- Ensure passive rerenders do not retrigger `list(path)` calls for already loaded dirs.
|
||||
|
||||
3. Keep filter auto-expand behavior
|
||||
|
||||
- Preserve existing "allowed filter" directory auto-expansion.
|
||||
- Ensure auto-expanded directories still fetch exactly once unless force refresh occurs.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `file-tree.tsx` no longer calls `file.tree.list(path)` from an unscoped rerender effect.
|
||||
- Expanding a folder still loads its children correctly.
|
||||
- Filtering by `allowed` still opens and shows required parent directories.
|
||||
- No regressions in change/all tabs where `FileTree` is used.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Expand/collapse deep directory trees repeatedly.
|
||||
- Switch between "changes" and "all" tree tabs.
|
||||
- Open review, click files, verify tree stays responsive.
|
||||
- Optional instrumentation:
|
||||
- count list calls per user action and compare before/after.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: directories fail to load when expansion timing changes.
|
||||
- Mitigation: rely on `expand()` path and verify for root + nested nodes.
|
||||
- Risk: filter-driven auto-expand misses one level.
|
||||
- Mitigation: keep existing auto-expand iteration and add regression checks.
|
||||
87
specs/14-comments-aggregation-index.md
Normal file
87
specs/14-comments-aggregation-index.md
Normal file
@@ -0,0 +1,87 @@
|
||||
## Comments indexing
|
||||
|
||||
Avoid repeated flatten+sort for comment aggregates
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/comments.tsx` derives `all` by flattening all file comment arrays and sorting on every change. This is simple but can become expensive with many comments. We will maintain an indexed aggregate structure incrementally.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Keep `comments.list(file)` behavior unchanged
|
||||
- Make `comments.all()` retrieval near O(1) for reads
|
||||
- Preserve chronological ordering guarantees
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Persisting comments in a new schema
|
||||
- Adding new comment metadata fields
|
||||
- UI changes for comment display
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/comments.tsx`
|
||||
- Optional tests for comments context
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/ui/src/components/line-comment.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Add aggregate index state
|
||||
|
||||
- Maintain `commentsByFile` (existing) plus an `allComments` array in chronological order.
|
||||
- Keep both updated through the same mutator paths.
|
||||
|
||||
2. Update mutators
|
||||
|
||||
- `add`: append new comment to file list and aggregate list.
|
||||
- `remove`: remove from file list and aggregate list by id/file.
|
||||
- `clear`: reset both structures and focus/active state.
|
||||
|
||||
3. Simplify selectors
|
||||
|
||||
- `list(file)` reads file list directly.
|
||||
- `all()` returns pre-indexed aggregate list without per-read flatten+sort.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `comments.all()` no longer flattens and sorts every reactive run.
|
||||
- Comment order stays chronological by `time`.
|
||||
- `add/remove/clear/focus/active` semantics remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Add multiple comments across different files.
|
||||
- Remove one comment and verify both file-level and global views update correctly.
|
||||
- Submit prompt (which clears comments) and verify reset behavior.
|
||||
- Optional unit test:
|
||||
- add/remove/clear keeps aggregate ordering and integrity.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: aggregate list and per-file lists diverge.
|
||||
- Mitigation: funnel all writes through centralized mutators; avoid direct store writes elsewhere.
|
||||
- Risk: ID collision edge cases.
|
||||
- Mitigation: keep UUID creation unchanged and remove by `file + id` pair.
|
||||
104
specs/15-prompt-input-modularization.md
Normal file
104
specs/15-prompt-input-modularization.md
Normal file
@@ -0,0 +1,104 @@
|
||||
## Prompt input split
|
||||
|
||||
Modularize `prompt-input.tsx` without behavior changes
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/components/prompt-input.tsx` is a very large component that combines editor DOM parsing, popovers, history, drag/drop + paste uploads, worktree/session creation, optimistic messages, and send/abort flow. This spec splits it into focused modules so future changes are safer.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Reduce `prompt-input.tsx` complexity and file size
|
||||
- Extract cohesive logic into testable hooks/helpers
|
||||
- Keep runtime behavior and UX unchanged
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Replacing contenteditable editor approach
|
||||
- Major UX redesign of composer controls
|
||||
- API contract changes for prompt submission
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/components/prompt-input.tsx`
|
||||
- New files under `packages/app/src/components/prompt-input/*`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/context/prompt.tsx` (except minor type-only imports if needed)
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Extract editor DOM helpers
|
||||
|
||||
- Move pure DOM/selection helpers into `prompt-input/editor-dom.ts`:
|
||||
- `createTextFragment`
|
||||
- `getNodeLength`
|
||||
- `getTextLength`
|
||||
- cursor get/set helpers
|
||||
|
||||
2. Extract history controller
|
||||
|
||||
- Move prompt history read/write/navigation logic into `prompt-input/history.ts` hook.
|
||||
- Keep existing persisted keys and history semantics unchanged.
|
||||
|
||||
3. Extract attachment interactions
|
||||
|
||||
- Move image/file paste + drag/drop + file-input attachment flows to `prompt-input/attachments.ts` hook.
|
||||
|
||||
4. Extract submit pipeline
|
||||
|
||||
- Move send/abort/optimistic message pipeline to `prompt-input/submit.ts` service/hook.
|
||||
- Keep existing error toasts, worktree handling, and rollback behavior.
|
||||
|
||||
5. Keep composition shell stable
|
||||
|
||||
- `PromptInput` component remains the integration shell that wires hooks + JSX.
|
||||
- Preserve exported component API and props.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `prompt-input.tsx` becomes primarily orchestration + view code.
|
||||
- Extracted modules contain the heavy imperative logic.
|
||||
- All existing behaviors remain intact:
|
||||
- slash and @ popovers
|
||||
- history up/down navigation
|
||||
- image attach/paste/drag-drop
|
||||
- shell mode submit/abort
|
||||
- optimistic message + rollback on failure
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual regression checklist:
|
||||
- type prompt, submit, stop, retry
|
||||
- use `/` command selection and `@` selector
|
||||
- history navigation with arrows
|
||||
- paste image, drag image, remove attachment
|
||||
- start in new session + worktree create path
|
||||
- failure path restores prompt and context comments
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: subtle ordering changes in submit rollback logic.
|
||||
- Mitigation: migrate logic mechanically first, then cleanup.
|
||||
- Risk: editor selection bugs after helper extraction.
|
||||
- Mitigation: keep existing cursor helpers unchanged and add focused manual checks.
|
||||
82
specs/16-terminal-cache-key-clarity.md
Normal file
82
specs/16-terminal-cache-key-clarity.md
Normal file
@@ -0,0 +1,82 @@
|
||||
## Terminal cache scope
|
||||
|
||||
Clarify workspace-only terminal cache semantics
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
`packages/app/src/context/terminal.tsx` accepts `(dir, session)` but currently keys cache entries as `${dir}:${WORKSPACE_KEY}`. The behavior is workspace-scoped, but the API shape suggests session-scoped caching. This spec aligns naming and implementation to avoid confusion and future bugs.
|
||||
|
||||
---
|
||||
|
||||
### Goals
|
||||
|
||||
- Make terminal cache scope explicit (workspace-scoped)
|
||||
- Remove misleading unused session-keying surface
|
||||
- Preserve existing runtime behavior
|
||||
|
||||
---
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Changing terminal persistence behavior
|
||||
- Moving terminals to per-session isolation
|
||||
- UI changes to terminal tabs
|
||||
|
||||
---
|
||||
|
||||
### Parallel execution contract
|
||||
|
||||
This spec owns:
|
||||
|
||||
- `packages/app/src/context/terminal.tsx`
|
||||
|
||||
This spec should not modify:
|
||||
|
||||
- `packages/app/src/pages/session.tsx`
|
||||
- `packages/app/src/components/session/session-sortable-terminal-tab.tsx`
|
||||
|
||||
---
|
||||
|
||||
### Implementation plan
|
||||
|
||||
1. Rename internals for clarity
|
||||
|
||||
- Update internal function names/variables from session-oriented to workspace-oriented where applicable.
|
||||
|
||||
2. Remove unused session cache-key parametering
|
||||
|
||||
- Simplify `load`/factory signatures so keying intent is explicit.
|
||||
- Keep key format workspace-only by directory.
|
||||
|
||||
3. Add inline documentation
|
||||
|
||||
- Add short comment near cache key creation clarifying why terminals are shared across sessions in the same workspace.
|
||||
|
||||
4. Keep behavior stable
|
||||
|
||||
- Ensure active terminal, tab order, clone/new/close behavior remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- No unused session-derived cache key logic remains.
|
||||
- Code communicates workspace-scoped terminal lifecycle clearly.
|
||||
- No functional changes to terminal operations.
|
||||
|
||||
---
|
||||
|
||||
### Validation plan
|
||||
|
||||
- Manual:
|
||||
- Create multiple terminals, navigate between sessions in same workspace, confirm state continuity.
|
||||
- Switch workspace directory, confirm separate terminal state.
|
||||
|
||||
---
|
||||
|
||||
### Risks and mitigations
|
||||
|
||||
- Risk: accidental behavior change to session-scoped terminals.
|
||||
- Mitigation: keep cache key unchanged; refactor naming/signatures only.
|
||||
59
specs/parallel-agent-plan.md
Normal file
59
specs/parallel-agent-plan.md
Normal file
@@ -0,0 +1,59 @@
|
||||
## Parallel agent plan
|
||||
|
||||
Execution map for session-page improvement concerns
|
||||
|
||||
---
|
||||
|
||||
### New specs added
|
||||
|
||||
- `specs/09-session-page-hot-paths.md`
|
||||
- `specs/10-file-content-eviction-accounting.md`
|
||||
- `specs/11-layout-view-tabs-reactivity.md`
|
||||
- `specs/12-session-context-metrics-shared.md`
|
||||
- `specs/13-file-tree-fetch-discipline.md`
|
||||
- `specs/14-comments-aggregation-index.md`
|
||||
- `specs/15-prompt-input-modularization.md`
|
||||
- `specs/16-terminal-cache-key-clarity.md`
|
||||
|
||||
---
|
||||
|
||||
### Existing related specs
|
||||
|
||||
- `specs/04-scroll-spy-optimization.md` (session scroll-spy concern)
|
||||
- `specs/05-modularize-and-dedupe.md` (broad modularization roadmap)
|
||||
|
||||
---
|
||||
|
||||
### Parallel-safe batching
|
||||
|
||||
Batch A (run one at a time, shared `session.tsx` surface):
|
||||
|
||||
- `specs/09-session-page-hot-paths.md`
|
||||
- `specs/04-scroll-spy-optimization.md`
|
||||
|
||||
Batch B (parallel with each other and with Batch A):
|
||||
|
||||
- `specs/10-file-content-eviction-accounting.md`
|
||||
- `specs/11-layout-view-tabs-reactivity.md`
|
||||
- `specs/12-session-context-metrics-shared.md`
|
||||
- `specs/13-file-tree-fetch-discipline.md`
|
||||
- `specs/14-comments-aggregation-index.md`
|
||||
- `specs/15-prompt-input-modularization.md`
|
||||
- `specs/16-terminal-cache-key-clarity.md`
|
||||
|
||||
Batch C (broad follow-up after focused specs land):
|
||||
|
||||
- `specs/05-modularize-and-dedupe.md`
|
||||
|
||||
---
|
||||
|
||||
### Suggested assignment
|
||||
|
||||
1. Agent A: `specs/09-session-page-hot-paths.md`
|
||||
2. Agent B: `specs/10-file-content-eviction-accounting.md`
|
||||
3. Agent C: `specs/11-layout-view-tabs-reactivity.md`
|
||||
4. Agent D: `specs/12-session-context-metrics-shared.md`
|
||||
5. Agent E: `specs/13-file-tree-fetch-discipline.md`
|
||||
6. Agent F: `specs/14-comments-aggregation-index.md`
|
||||
7. Agent G: `specs/15-prompt-input-modularization.md`
|
||||
8. Agent H: `specs/16-terminal-cache-key-clarity.md`
|
||||
Reference in New Issue
Block a user