mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-20 13:44:27 +00:00
Compare commits
88 Commits
input-clea
...
opencode-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eeeec359a | ||
|
|
65e786258a | ||
|
|
cbc40a5981 | ||
|
|
b9b210a864 | ||
|
|
d473b7e971 | ||
|
|
f5783c4313 | ||
|
|
9439a5647e | ||
|
|
2bfe81ee5c | ||
|
|
fcf1bb010c | ||
|
|
08b6d9c6dc | ||
|
|
0293a8bb80 | ||
|
|
850dbb93eb | ||
|
|
48e867ee20 | ||
|
|
b5ebc541b9 | ||
|
|
bd7a4cec90 | ||
|
|
63af295a17 | ||
|
|
04954a9620 | ||
|
|
fb63fd79a3 | ||
|
|
2e04b66eab | ||
|
|
f0b7c8c374 | ||
|
|
be6f59035a | ||
|
|
27ab51f490 | ||
|
|
bca723e8fe | ||
|
|
1ac39718d8 | ||
|
|
190319fb56 | ||
|
|
3154f0a61c | ||
|
|
0b686b8178 | ||
|
|
4cba56171b | ||
|
|
66342acd31 | ||
|
|
88dae67549 | ||
|
|
0ec42582f3 | ||
|
|
4f82248a68 | ||
|
|
5e069aab97 | ||
|
|
5325b2ec99 | ||
|
|
2a98920922 | ||
|
|
5ea92ea6cb | ||
|
|
a18528a7ee | ||
|
|
ced125a974 | ||
|
|
655fe20beb | ||
|
|
dd0c258e23 | ||
|
|
791e27d289 | ||
|
|
fac0aec69f | ||
|
|
ca26e639f6 | ||
|
|
0b5d54f2cb | ||
|
|
1b408cf06b | ||
|
|
8e102d19ed | ||
|
|
721b2406e9 | ||
|
|
4a6a18cd79 | ||
|
|
c10b5880cc | ||
|
|
e6bf83084c | ||
|
|
6722ee22ee | ||
|
|
870a5731ac | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 |
26
.github/workflows/test.yml
vendored
26
.github/workflows/test.yml
vendored
@@ -50,17 +50,20 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
- name: windows
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
playwright: bunx playwright install
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -73,28 +76,9 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Read Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/.playwright-browsers
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||
|
||||
- name: Install Playwright system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install-deps chromium
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
|
||||
- name: Run app e2e tests
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
|
||||
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
|
||||
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
|
||||
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
|
||||
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
|
||||
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
|
||||
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
|
||||
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +112,6 @@
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectSwitchSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
@@ -24,16 +23,6 @@ import {
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
|
||||
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||
|
||||
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||
phase.set(page, value)
|
||||
}
|
||||
|
||||
export function healthPhase(page: Page) {
|
||||
return phase.get(page) ?? "test"
|
||||
}
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
.evaluate(() => {
|
||||
@@ -207,49 +196,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
|
||||
async function errorBoundaryText(page: Page) {
|
||||
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
if (!(await title.isVisible().catch(() => false))) return
|
||||
|
||||
const description = await page
|
||||
.getByText(/an error occurred while loading the application\./i)
|
||||
.first()
|
||||
.textContent()
|
||||
.catch(() => "")
|
||||
const detail = await page
|
||||
.getByRole("textbox", { name: /error details/i })
|
||||
.first()
|
||||
.inputValue()
|
||||
.catch(async () =>
|
||||
(
|
||||
(await page
|
||||
.getByRole("textbox", { name: /error details/i })
|
||||
.first()
|
||||
.textContent()
|
||||
.catch(() => "")) ?? ""
|
||||
).trim(),
|
||||
)
|
||||
|
||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
export async function assertHealthy(page: Page, context: string) {
|
||||
const text = await errorBoundaryText(page)
|
||||
if (!text) return
|
||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||
}
|
||||
|
||||
async function waitSidebarButton(page: Page, context: string) {
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||
await assertHealthy(page, context)
|
||||
return button
|
||||
await expect(button).toBeVisible()
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
@@ -260,7 +209,7 @@ export async function toggleSidebar(page: Page) {
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = await waitSidebarButton(page, "openSidebar")
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await button.click()
|
||||
|
||||
const opened = await expect(button)
|
||||
@@ -277,7 +226,7 @@ export async function openSidebar(page: Page) {
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = await waitSidebarButton(page, "closeSidebar")
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await button.click()
|
||||
|
||||
const closed = await expect(button)
|
||||
@@ -292,7 +241,6 @@ export async function closeSidebar(page: Page) {
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
await assertHealthy(page, "openSettings")
|
||||
await defocus(page)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
@@ -305,8 +253,6 @@ export async function openSettings(page: Page) {
|
||||
|
||||
if (opened) return dialog
|
||||
|
||||
await assertHealthy(page, "openSettings")
|
||||
|
||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||
await expect(dialog).toBeVisible()
|
||||
return dialog
|
||||
@@ -368,12 +314,10 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
|
||||
export async function createTestProject() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||
const id = `e2e-${path.basename(root)}`
|
||||
|
||||
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
@@ -395,24 +339,12 @@ export function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function probeSession(page: Page) {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const win = window as E2EWindow
|
||||
const current = win.__opencode_e2e?.model?.current
|
||||
if (!current) return null
|
||||
return { dir: current.dir, sessionID: current.sessionID }
|
||||
})
|
||||
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||
}
|
||||
|
||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
let next = ""
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSlug")
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
@@ -442,7 +374,6 @@ export async function waitDir(page: Page, directory: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
@@ -455,69 +386,6 @@ export async function waitDir(page: Page, directory: string) {
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = await resolveDirectory(input.directory)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSession")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
}
|
||||
|
||||
return page
|
||||
.locator(promptSelector)
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = await resolveDirectory(directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(target)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const items = await sdk.session
|
||||
.messages({ sessionID, limit: 20 })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
return items.some((item) => item.info.role === "user")
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
@@ -929,14 +797,8 @@ export async function openStatusPopover(page: Page) {
|
||||
}
|
||||
|
||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
await openSidebar(page)
|
||||
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await expect(trigger).toBeVisible()
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -945,7 +807,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ force: true, timeout: 1500 })
|
||||
.click({ timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
cleanupTestProject,
|
||||
createTestProject,
|
||||
setHealthPhase,
|
||||
seedProjects,
|
||||
sessionIDFromUrl,
|
||||
waitSlug,
|
||||
waitSession,
|
||||
} from "./actions"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
@@ -36,29 +27,6 @@ type WorkerFixtures = {
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
const consoleHandler = (msg: { text(): string }) => {
|
||||
const text = msg.text()
|
||||
if (!text.includes("[e2e:error-boundary]")) return
|
||||
if (healthPhase(page) === "cleanup") {
|
||||
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||
return
|
||||
}
|
||||
boundary ||= text
|
||||
console.log(text)
|
||||
}
|
||||
const pageErrorHandler = (err: Error) => {
|
||||
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||
}
|
||||
page.on("console", consoleHandler)
|
||||
page.on("pageerror", pageErrorHandler)
|
||||
await use(page)
|
||||
page.off("console", consoleHandler)
|
||||
page.off("pageerror", pageErrorHandler)
|
||||
if (boundary) throw new Error(boundary)
|
||||
},
|
||||
directory: [
|
||||
async ({}, use) => {
|
||||
const directory = await getWorktree()
|
||||
@@ -80,20 +48,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await waitSession(page, { directory, sessionID })
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
@@ -108,16 +77,13 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
defocus,
|
||||
@@ -6,14 +7,43 @@ import {
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitDir,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { dirSlug, resolveDirectory } from "../utils"
|
||||
|
||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||
await page.evaluate(
|
||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||
const key = "opencode.global.dat:layout"
|
||||
const raw = localStorage.getItem(key)
|
||||
const data = raw ? JSON.parse(raw) : {}
|
||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||
const current =
|
||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||
? sidebar.workspaces
|
||||
: {}
|
||||
const next = { ...current }
|
||||
|
||||
if (enabled) next[directory] = true
|
||||
if (!enabled) delete next[directory]
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
...data,
|
||||
sidebar: {
|
||||
...sidebar,
|
||||
workspaces: next,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, enabled },
|
||||
)
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -54,7 +84,9 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await withProject(
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await openSidebar(page)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
@@ -76,7 +108,8 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
|
||||
await waitSession(page, { directory: space })
|
||||
await waitSlug(page)
|
||||
await waitDir(page, space)
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
@@ -90,7 +123,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
trackSession(created, space)
|
||||
await waitSessionSaved(space, created)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
@@ -98,14 +130,15 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click({ force: true })
|
||||
await waitSession(page, { directory: other })
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click({ force: true })
|
||||
await rootButton.click()
|
||||
|
||||
await waitSession(page, { directory: space, sessionID: created })
|
||||
await waitDir(page, space)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitDir,
|
||||
waitSession,
|
||||
waitSessionSaved,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
@@ -23,7 +14,20 @@ function button(space: { slug: string; raw: string }) {
|
||||
|
||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const row = page.locator(item(space)).first()
|
||||
try {
|
||||
await row.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
@@ -45,8 +49,7 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
||||
await expect(next).toBeVisible()
|
||||
await next.click({ force: true })
|
||||
|
||||
await waitSession(page, { directory: space.directory })
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||
return waitDir(page, space.directory)
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(
|
||||
@@ -54,28 +57,39 @@ async function createSessionFromWorkspace(
|
||||
space: { slug: string; raw: string; directory: string },
|
||||
text: string,
|
||||
) {
|
||||
await openWorkspaceNewSession(page, space)
|
||||
const next = await openWorkspaceNewSession(page, space)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.click()
|
||||
await expect(prompt).toBeFocused()
|
||||
await prompt.fill(text)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await waitDir(page, next.directory)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
return { sessionID, slug: next.slug }
|
||||
}
|
||||
|
||||
await waitSessionSaved(space.directory, sessionID)
|
||||
await createSdk(space.directory)
|
||||
.session.abort({ sessionID })
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
const info = await createSdk(directory)
|
||||
.session.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
return sessionID
|
||||
if (!info) return ""
|
||||
return info.directory
|
||||
}
|
||||
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
@@ -87,8 +101,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second)
|
||||
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, first.directory)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
trackSession(secondSession.sessionID, second.directory)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
trackSession(thirdSession.sessionID, first.directory)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
resolveSlug,
|
||||
sessionIDFromUrl,
|
||||
setWorkspacesEnabled,
|
||||
waitSession,
|
||||
waitSessionIdle,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
|
||||
import {
|
||||
promptAgentSelector,
|
||||
promptModelSelector,
|
||||
@@ -37,6 +29,8 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
|
||||
|
||||
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||
|
||||
const dirKey = (state: Probe | null) => state?.dir ?? ""
|
||||
|
||||
async function probe(page: Page): Promise<Probe | null> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
@@ -50,6 +44,21 @@ async function probe(page: Page): Promise<Probe | null> {
|
||||
})
|
||||
}
|
||||
|
||||
async function currentDir(page: Page) {
|
||||
let hit = ""
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const next = dirKey(await probe(page))
|
||||
if (next) hit = next
|
||||
return next
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
return hit
|
||||
}
|
||||
|
||||
async function read(page: Page): Promise<Footer> {
|
||||
return {
|
||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
@@ -178,7 +187,8 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||
|
||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await waitSession(page, { directory, sessionID })
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
|
||||
}
|
||||
|
||||
async function submit(page: Page, value: string) {
|
||||
@@ -214,7 +224,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||
await waitSession(page, { directory: next.directory })
|
||||
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -246,7 +256,9 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
||||
await button.click({ force: true })
|
||||
|
||||
const next = await resolveSlug(await waitSlug(page))
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
return currentDir(page)
|
||||
}
|
||||
|
||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||
@@ -265,7 +277,7 @@ test("session model and variant restore per session without leaking into new ses
|
||||
await waitUser(directory, first)
|
||||
|
||||
await page.reload()
|
||||
await waitSession(page, { directory, sessionID: first })
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
type Range = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type CommentChipProps = {
|
||||
variant?: "preview" | "full"
|
||||
path: string
|
||||
label: string
|
||||
selection?: Range
|
||||
comment?: string
|
||||
class?: string
|
||||
onOpen?: () => void
|
||||
onRemove?: () => void
|
||||
removeLabel?: string
|
||||
}
|
||||
|
||||
const removeClass =
|
||||
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||
const removeIconClass =
|
||||
"absolute top-1 right-1 size-3.5 rounded-[var(--radius-sm)] flex items-center justify-center bg-transparent group-hover/remove:bg-surface-base-hover group-active/remove:bg-surface-base-active"
|
||||
|
||||
export const CommentChip: Component<CommentChipProps> = (props) => {
|
||||
const variant = () => props.variant ?? "preview"
|
||||
const range = () => {
|
||||
const sel = props.selection
|
||||
if (!sel) return
|
||||
const start = Math.min(sel.start, sel.end)
|
||||
const end = Math.max(sel.start, sel.end)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const pad = () => (props.onRemove ? "pr-7" : "pr-2")
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`group relative flex flex-col rounded-[6px] cursor-default bg-background-stronger ${
|
||||
variant() === "full" ? "border border-border-weak-base" : "shadow-xs-border"
|
||||
} ${variant() === "full" ? `pl-2 py-1 ${pad()}` : `pl-2 py-1 h-12 ${pad()}`} ${props.class ?? ""}`}
|
||||
onClick={() => props.onOpen?.()}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{props.label}</span>
|
||||
<Show when={range()}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().start === sel().end ? `:${sel().start}` : `:${sel().start}-${sel().end}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={(props.comment ?? "").trim().length > 0}>
|
||||
<div
|
||||
class={`text-base text-text-strong ml-5 ${
|
||||
variant() === "full" ? "whitespace-pre-wrap break-words" : "truncate"
|
||||
}`}
|
||||
>
|
||||
{props.comment}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.onRemove}>
|
||||
<button
|
||||
type="button"
|
||||
class={`${removeClass} group/remove`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onRemove?.()
|
||||
}}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<span class={removeIconClass}>
|
||||
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
|
||||
</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||
import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
@@ -1290,7 +1291,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
/>
|
||||
<PromptContextItems
|
||||
items={contextItems()}
|
||||
images={imageAttachments()}
|
||||
active={(item) => {
|
||||
const active = comments.active()
|
||||
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
|
||||
@@ -1300,12 +1300,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
prompt.context.remove(item.key)
|
||||
}}
|
||||
openImage={(attachment) =>
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<PromptImageAttachments
|
||||
attachments={imageAttachments()}
|
||||
onOpen={(attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||
}
|
||||
removeImage={removeAttachment}
|
||||
imageRemoveLabel={language.t("prompt.attachment.remove")}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
onRemove={removeAttachment}
|
||||
removeLabel={language.t("prompt.attachment.remove")}
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
|
||||
@@ -1,62 +1,30 @@
|
||||
import { Component, For, Show, createMemo } from "solid-js"
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
|
||||
import { PromptImageAttachment } from "./image-attachments"
|
||||
import { CommentChip } from "@/components/comment-chip"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
|
||||
type PromptContextItem = ContextItem & { key: string }
|
||||
|
||||
type ContextItemsProps = {
|
||||
items: PromptContextItem[]
|
||||
images: ImageAttachmentPart[]
|
||||
active: (item: PromptContextItem) => boolean
|
||||
openComment: (item: PromptContextItem) => void
|
||||
remove: (item: PromptContextItem) => void
|
||||
openImage: (attachment: ImageAttachmentPart) => void
|
||||
removeImage: (id: string) => void
|
||||
imageRemoveLabel: string
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
const seen = new Map<string, number>()
|
||||
let seq = 0
|
||||
|
||||
const rows = createMemo(() => {
|
||||
const all = [
|
||||
...props.items.map((item) => ({ type: "ctx" as const, key: `ctx:${item.key}`, item })),
|
||||
...props.images.map((attachment) => ({ type: "img" as const, key: `img:${attachment.id}`, attachment })),
|
||||
]
|
||||
|
||||
for (const row of all) {
|
||||
if (seen.has(row.key)) continue
|
||||
seen.set(row.key, seq)
|
||||
seq += 1
|
||||
}
|
||||
|
||||
return all.slice().sort((a, b) => (seen.get(a.key) ?? 0) - (seen.get(b.key) ?? 0))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={rows().length > 0}>
|
||||
<Show when={props.items.length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={rows()}>
|
||||
{(row) => {
|
||||
if (row.type === "img") {
|
||||
return (
|
||||
<PromptImageAttachment
|
||||
attachment={row.attachment}
|
||||
onOpen={props.openImage}
|
||||
onRemove={props.removeImage}
|
||||
removeLabel={props.imageRemoveLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const directory = getDirectory(row.item.path)
|
||||
const filename = getFilename(row.item.path)
|
||||
const label = getFilenameTruncated(row.item.path, 14)
|
||||
<For each={props.items}>
|
||||
{(item) => {
|
||||
const directory = getDirectory(item.path)
|
||||
const filename = getFilename(item.path)
|
||||
const label = getFilenameTruncated(item.path, 14)
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -70,26 +38,46 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
class="shrink-0"
|
||||
>
|
||||
<CommentChip
|
||||
variant="preview"
|
||||
path={row.item.path}
|
||||
label={label}
|
||||
selection={
|
||||
row.item.selection
|
||||
? {
|
||||
start: row.item.selection.startLine,
|
||||
end: row.item.selection.endLine,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
comment={row.item.comment}
|
||||
class="max-w-[200px]"
|
||||
onOpen={() => props.openComment(row.item)}
|
||||
onRemove={() => props.remove(row.item)}
|
||||
removeLabel={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
||||
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
||||
"bg-background-stronger": !selected,
|
||||
}}
|
||||
onClick={() => props.openComment(item)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
||||
<Show when={item.selection}>
|
||||
{(sel) => (
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
||||
{sel().startLine === sel().endLine
|
||||
? `:${sel().startLine}`
|
||||
: `:${sel().startLine}-${sel().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.remove(item)
|
||||
}}
|
||||
aria-label={props.t("prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Component, Show } from "solid-js"
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||
|
||||
type PromptImageAttachmentsProps = {
|
||||
@@ -11,68 +9,50 @@ type PromptImageAttachmentsProps = {
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
type PromptImageAttachmentProps = {
|
||||
attachment: ImageAttachmentPart
|
||||
onOpen: (attachment: ImageAttachmentPart) => void
|
||||
onRemove: (id: string) => void
|
||||
removeLabel: string
|
||||
}
|
||||
|
||||
const fallbackClass =
|
||||
"size-12 rounded-[6px] bg-background-stronger flex items-center justify-center shadow-xs-border cursor-default"
|
||||
const imageClass = "size-12 rounded-[6px] object-cover shadow-xs-border"
|
||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
||||
const imageClass =
|
||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
const removeClass =
|
||||
"absolute top-0 right-0 size-6 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto"
|
||||
const removeIconClass =
|
||||
"absolute top-1 right-1 size-3.5 rounded-[var(--radius-sm)] flex items-center justify-center bg-transparent group-hover/remove:bg-surface-base-hover group-active/remove:bg-surface-base-active"
|
||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
||||
|
||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.attachments.length > 0}>
|
||||
<>
|
||||
{props.attachments.map((attachment) => (
|
||||
<PromptImageAttachment
|
||||
attachment={attachment}
|
||||
onOpen={props.onOpen}
|
||||
onRemove={props.onRemove}
|
||||
removeLabel={props.removeLabel}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.attachments}>
|
||||
{(attachment) => (
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.filename}
|
||||
class={imageClass}
|
||||
onClick={() => props.onOpen(attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRemove(attachment.id)}
|
||||
class={removeClass}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
<div class={nameClass}>
|
||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptImageAttachment: Component<PromptImageAttachmentProps> = (props) => {
|
||||
return (
|
||||
<Tooltip value={props.attachment.filename} placement="top" gutter={6} class="shrink-0">
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={props.attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class={fallbackClass}>
|
||||
<FileIcon node={{ path: props.attachment.filename, type: "file" }} class="size-5" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={props.attachment.dataUrl}
|
||||
alt={props.attachment.filename}
|
||||
class={imageClass}
|
||||
onClick={() => props.onOpen(props.attachment)}
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class={`${removeClass} group/remove`}
|
||||
onClick={() => props.onRemove(props.attachment.id)}
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<span class={removeIconClass}>
|
||||
<Icon name="close-small" size="small" class="text-text-weak group-hover/remove:text-text-strong" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,7 +378,6 @@ function createGlobalSync() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
bootstrap,
|
||||
updateConfig,
|
||||
project: projectApi,
|
||||
|
||||
@@ -226,15 +226,6 @@ export function createChildStoreManager(input: {
|
||||
return childStore
|
||||
}
|
||||
|
||||
function peek(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
input.onBootstrap(directory)
|
||||
}
|
||||
return childStore
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cached = metaCache.get(directory)
|
||||
@@ -265,7 +256,6 @@ export function createChildStoreManager(input: {
|
||||
children,
|
||||
ensureChild,
|
||||
child,
|
||||
peek,
|
||||
projectMeta,
|
||||
projectIcon,
|
||||
mark,
|
||||
|
||||
@@ -477,7 +477,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} رسائل تم التراجع عنها",
|
||||
"session.revertDock.collapse": "طي الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.expand": "توسيع الرسائل التي تم التراجع عنها",
|
||||
"session.revertDock.restore": "استعادة",
|
||||
"session.revertDock.restore": "استعادة الرسالة",
|
||||
"session.new.title": "ابنِ أي شيء",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
|
||||
@@ -481,7 +481,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} mensagens revertidas",
|
||||
"session.revertDock.collapse": "Recolher mensagens revertidas",
|
||||
"session.revertDock.expand": "Expandir mensagens revertidas",
|
||||
"session.revertDock.restore": "Restaurar",
|
||||
"session.revertDock.restore": "Restaurar mensagem",
|
||||
"session.new.title": "Crie qualquer coisa",
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
|
||||
@@ -536,7 +536,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} vraćenih poruka",
|
||||
"session.revertDock.collapse": "Sažmi vraćene poruke",
|
||||
"session.revertDock.expand": "Proširi vraćene poruke",
|
||||
"session.revertDock.restore": "Vrati",
|
||||
"session.revertDock.restore": "Vrati poruku",
|
||||
|
||||
"session.new.title": "Napravi bilo šta",
|
||||
"session.new.worktree.main": "Glavna grana",
|
||||
|
||||
@@ -531,7 +531,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} tilbagerullede beskeder",
|
||||
"session.revertDock.collapse": "Skjul tilbagerullede beskeder",
|
||||
"session.revertDock.expand": "Udvid tilbagerullede beskeder",
|
||||
"session.revertDock.restore": "Gendan",
|
||||
"session.revertDock.restore": "Gendan besked",
|
||||
|
||||
"session.new.title": "Byg hvad som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
|
||||
@@ -489,7 +489,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} zurückgesetzte Nachrichten",
|
||||
"session.revertDock.collapse": "Zurückgesetzte Nachrichten einklappen",
|
||||
"session.revertDock.expand": "Zurückgesetzte Nachrichten ausklappen",
|
||||
"session.revertDock.restore": "Wiederherstellen",
|
||||
"session.revertDock.restore": "Nachricht wiederherstellen",
|
||||
"session.new.title": "Baue, was du willst",
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
|
||||
@@ -561,7 +561,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} rolled back messages",
|
||||
"session.revertDock.collapse": "Collapse rolled back messages",
|
||||
"session.revertDock.expand": "Expand rolled back messages",
|
||||
"session.revertDock.restore": "Restore",
|
||||
"session.revertDock.restore": "Restore message",
|
||||
|
||||
"session.new.title": "Build anything",
|
||||
"session.new.worktree.main": "Main branch",
|
||||
|
||||
@@ -537,7 +537,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} mensajes revertidos",
|
||||
"session.revertDock.collapse": "Contraer mensajes revertidos",
|
||||
"session.revertDock.expand": "Expandir mensajes revertidos",
|
||||
"session.revertDock.restore": "Restaurar",
|
||||
"session.revertDock.restore": "Restaurar mensaje",
|
||||
|
||||
"session.new.title": "Construye lo que quieras",
|
||||
"session.new.worktree.main": "Rama principal",
|
||||
|
||||
@@ -486,7 +486,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} messages annulés",
|
||||
"session.revertDock.collapse": "Réduire les messages annulés",
|
||||
"session.revertDock.expand": "Développer les messages annulés",
|
||||
"session.revertDock.restore": "Restaurer",
|
||||
"session.revertDock.restore": "Restaurer le message",
|
||||
"session.new.title": "Créez ce que vous voulez",
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
|
||||
@@ -478,7 +478,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} 件のロールバックされたメッセージ",
|
||||
"session.revertDock.collapse": "ロールバックされたメッセージを折りたたむ",
|
||||
"session.revertDock.expand": "ロールバックされたメッセージを展開",
|
||||
"session.revertDock.restore": "復元",
|
||||
"session.revertDock.restore": "メッセージを復元",
|
||||
"session.new.title": "何でも作る",
|
||||
"session.new.worktree.main": "メインブランチ",
|
||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||
|
||||
@@ -480,7 +480,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}}개의 롤백된 메시지",
|
||||
"session.revertDock.collapse": "롤백된 메시지 접기",
|
||||
"session.revertDock.expand": "롤백된 메시지 펼치기",
|
||||
"session.revertDock.restore": "복원",
|
||||
"session.revertDock.restore": "메시지 복원",
|
||||
"session.new.title": "무엇이든 만들기",
|
||||
"session.new.worktree.main": "메인 브랜치",
|
||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||
|
||||
@@ -537,7 +537,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} tilbakestilte meldinger",
|
||||
"session.revertDock.collapse": "Skjul tilbakestilte meldinger",
|
||||
"session.revertDock.expand": "Utvid tilbakestilte meldinger",
|
||||
"session.revertDock.restore": "Gjenopprett",
|
||||
"session.revertDock.restore": "Gjenopprett melding",
|
||||
|
||||
"session.new.title": "Bygg hva som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
|
||||
@@ -479,7 +479,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} cofnięte wiadomości",
|
||||
"session.revertDock.collapse": "Zwiń cofnięte wiadomości",
|
||||
"session.revertDock.expand": "Rozwiń cofnięte wiadomości",
|
||||
"session.revertDock.restore": "Przywróć",
|
||||
"session.revertDock.restore": "Przywróć wiadomość",
|
||||
"session.new.title": "Zbuduj cokolwiek",
|
||||
"session.new.worktree.main": "Główna gałąź",
|
||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||
|
||||
@@ -534,7 +534,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} сообщений возвращено",
|
||||
"session.revertDock.collapse": "Свернуть возвращённые сообщения",
|
||||
"session.revertDock.expand": "Развернуть возвращённые сообщения",
|
||||
"session.revertDock.restore": "Восстановить",
|
||||
"session.revertDock.restore": "Восстановить сообщение",
|
||||
|
||||
"session.new.title": "Создавайте что угодно",
|
||||
"session.new.worktree.main": "Основная ветка",
|
||||
|
||||
@@ -531,7 +531,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} ข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.collapse": "ย่อข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.expand": "ขยายข้อความที่ถูกย้อนกลับ",
|
||||
"session.revertDock.restore": "กู้คืน",
|
||||
"session.revertDock.restore": "กู้คืนข้อความ",
|
||||
|
||||
"session.new.title": "สร้างอะไรก็ได้",
|
||||
"session.new.worktree.main": "สาขาหลัก",
|
||||
|
||||
@@ -541,7 +541,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} geri alınan mesaj",
|
||||
"session.revertDock.collapse": "Geri alınan mesajları daralt",
|
||||
"session.revertDock.expand": "Geri alınan mesajları genişlet",
|
||||
"session.revertDock.restore": "Geri yükle",
|
||||
"session.revertDock.restore": "Mesajı geri yükle",
|
||||
|
||||
"session.new.title": "İstediğini yap",
|
||||
"session.new.worktree.main": "Ana dal",
|
||||
|
||||
@@ -531,7 +531,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} 条已回滚消息",
|
||||
"session.revertDock.collapse": "折叠已回滚消息",
|
||||
"session.revertDock.expand": "展开已回滚消息",
|
||||
"session.revertDock.restore": "恢复",
|
||||
"session.revertDock.restore": "恢复消息",
|
||||
"session.new.title": "构建任何东西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||
|
||||
@@ -527,7 +527,7 @@ export const dict = {
|
||||
"session.revertDock.summary.other": "{{count}} 則已回復訊息",
|
||||
"session.revertDock.collapse": "收合已回復訊息",
|
||||
"session.revertDock.expand": "展開已回復訊息",
|
||||
"session.revertDock.restore": "還原",
|
||||
"session.revertDock.restore": "還原訊息",
|
||||
|
||||
"session.new.title": "建構任何東西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component, Show, onMount } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import type { E2EWindow } from "@/testing/terminal"
|
||||
|
||||
export type InitError = {
|
||||
name: string
|
||||
@@ -227,13 +226,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
actionError: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const win = window as E2EWindow
|
||||
if (!win.__opencode_e2e) return
|
||||
const detail = formatError(props.error, language.t)
|
||||
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
@@ -129,16 +129,6 @@ export default function Layout(props: ParentProps) {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const initialDirectory = decode64(params.dir)
|
||||
const route = createMemo(() => {
|
||||
const slug = params.dir
|
||||
if (!slug) return { slug, dir: "" }
|
||||
const dir = decode64(slug)
|
||||
if (!dir) return { slug, dir: "" }
|
||||
return {
|
||||
slug,
|
||||
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
|
||||
}
|
||||
})
|
||||
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
||||
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
||||
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
|
||||
@@ -147,7 +137,7 @@ export default function Layout(props: ParentProps) {
|
||||
dark: "theme.scheme.dark",
|
||||
}
|
||||
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
||||
const currentDir = createMemo(() => route().dir)
|
||||
const currentDir = createMemo(() => decode64(params.dir) ?? "")
|
||||
|
||||
const [state, setState] = createStore({
|
||||
autoselect: !initialDirectory,
|
||||
@@ -494,8 +484,8 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const currentSession = params.id
|
||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
|
||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
|
||||
if (directory === currentDir() && props.sessionID === currentSession) return
|
||||
if (directory === currentDir() && session?.parentID === currentSession) return
|
||||
|
||||
dismissSessionAlert(sessionKey)
|
||||
|
||||
@@ -630,7 +620,7 @@ export default function Layout(props: ParentProps) {
|
||||
const activeDir = currentDir()
|
||||
return workspaceIds(project).filter((directory) => {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = workspaceKey(directory) === workspaceKey(activeDir)
|
||||
const active = directory === activeDir
|
||||
return expanded || active
|
||||
})
|
||||
})
|
||||
@@ -697,7 +687,7 @@ export default function Layout(props: ParentProps) {
|
||||
seen: lru,
|
||||
keep: sessionID,
|
||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
|
||||
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -710,7 +700,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
route()
|
||||
params.dir
|
||||
globalSDK.url
|
||||
|
||||
prefetchToken.value += 1
|
||||
@@ -1702,10 +1692,13 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
|
||||
const dir = params.dir
|
||||
const directory = dir ? decode64(dir) : undefined
|
||||
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
|
||||
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
|
||||
},
|
||||
([ready, slug, id, root, dir]) => {
|
||||
if (!ready || !slug || !dir) {
|
||||
([ready, dir, id, root, directory, resolved]) => {
|
||||
if (!ready || !dir || !directory) {
|
||||
activeRoute.session = ""
|
||||
activeRoute.sessionProject = ""
|
||||
activeRoute.directory = ""
|
||||
@@ -1719,28 +1712,29 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = `${slug}/${id}`
|
||||
const next = resolved || directory
|
||||
const session = `${dir}/${id}`
|
||||
|
||||
if (!root) {
|
||||
activeRoute.session = session
|
||||
activeRoute.directory = dir
|
||||
activeRoute.directory = next
|
||||
activeRoute.sessionProject = ""
|
||||
return
|
||||
}
|
||||
|
||||
if (server.projects.last() !== root) server.projects.touch(root)
|
||||
|
||||
const changed = session !== activeRoute.session || dir !== activeRoute.directory
|
||||
const changed = session !== activeRoute.session || next !== activeRoute.directory
|
||||
if (changed) {
|
||||
activeRoute.session = session
|
||||
activeRoute.directory = dir
|
||||
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
|
||||
activeRoute.directory = next
|
||||
activeRoute.sessionProject = syncSessionRoute(next, id, root)
|
||||
return
|
||||
}
|
||||
|
||||
if (root === activeRoute.sessionProject) return
|
||||
activeRoute.directory = dir
|
||||
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
|
||||
activeRoute.directory = next
|
||||
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -1933,7 +1927,6 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const projectSidebarCtx: ProjectSidebarContext = {
|
||||
currentDir,
|
||||
currentProject,
|
||||
sidebarOpened: () => layout.sidebar.opened(),
|
||||
sidebarHovering,
|
||||
hoverProject: () => state.hoverProject,
|
||||
|
||||
@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
|
||||
stores.flatMap(roots).sort(sortSessions(now))[0]
|
||||
|
||||
export function hasProjectPermissions<T>(
|
||||
request: Record<string, T[] | undefined> | undefined,
|
||||
request: Record<string, T[] | undefined>,
|
||||
include: (item: T) => boolean = () => true,
|
||||
) {
|
||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||
return Object.values(request).some((list) => list?.some(include))
|
||||
}
|
||||
|
||||
export const childMapByParent = (sessions: Session[] | undefined) => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
||||
|
||||
export type ProjectSidebarContext = {
|
||||
currentDir: Accessor<string>
|
||||
currentProject: Accessor<LocalProject | undefined>
|
||||
sidebarOpened: Accessor<boolean>
|
||||
sidebarHovering: Accessor<boolean>
|
||||
hoverProject: Accessor<string | undefined>
|
||||
@@ -279,7 +278,11 @@ export const SortableProject = (props: {
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
const sortable = createSortable(props.project.worktree)
|
||||
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
|
||||
const selected = createMemo(
|
||||
() =>
|
||||
props.project.worktree === props.ctx.currentDir() ||
|
||||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
|
||||
)
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
|
||||
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
||||
import { childMapByParent, sortedRootSessions } from "./helpers"
|
||||
|
||||
type InlineEditorComponent = (props: {
|
||||
id: string
|
||||
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
|
||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
||||
const local = createMemo(() => props.directory === props.project.worktree)
|
||||
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
||||
const active = createMemo(() => props.ctx.currentDir() === props.directory)
|
||||
const workspaceValue = createMemo(() => {
|
||||
const branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
|
||||
@@ -1324,22 +1324,9 @@ export default function Page() {
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
const tag = (mime: string | undefined) => {
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime?.startsWith("image/")) return "image"
|
||||
return "file"
|
||||
}
|
||||
|
||||
const chip = (part: { filename: string; mime: string }) => `[${tag(part.mime)}:${part.filename}]`
|
||||
|
||||
const line = (id: string) => {
|
||||
const text = draft(id)
|
||||
.map((part) => {
|
||||
if (part.type === "image") return chip(part)
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
return part.content
|
||||
})
|
||||
.map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
@@ -1407,7 +1394,7 @@ export default function Page() {
|
||||
const followupText = (item: FollowupDraft) => {
|
||||
const text = item.prompt
|
||||
.map((part) => {
|
||||
if (part.type === "image") return chip(part)
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
return part.content
|
||||
|
||||
@@ -46,14 +46,6 @@ export function SessionComposerRegion(props: {
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
|
||||
const tag = (mime: string) => {
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
return "file"
|
||||
}
|
||||
|
||||
const chip = (part: { filename: string; mime: string }) => `[${tag(part.mime)}:${part.filename}]`
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||
|
||||
const previewPrompt = () =>
|
||||
@@ -62,7 +54,7 @@ export function SessionComposerRegion(props: {
|
||||
.map((part) => {
|
||||
if (part.type === "file") return `[file:${part.path}]`
|
||||
if (part.type === "agent") return `@${part.name}`
|
||||
if (part.type === "image") return chip(part)
|
||||
if (part.type === "image") return `[image:${part.filename}]`
|
||||
return part.content
|
||||
})
|
||||
.join("")
|
||||
|
||||
@@ -259,26 +259,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={
|
||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } f
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -14,7 +15,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
@@ -28,7 +29,6 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { CommentChip } from "@/components/comment-chip"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -960,36 +960,40 @@ export function MessageTimeline(props: {
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="w-full overflow-visible">
|
||||
<div class="overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<Show when={comment()}>
|
||||
{(c) => (
|
||||
<CommentChip
|
||||
variant="full"
|
||||
path={c().path}
|
||||
label={getFilenameTruncated(c().path, 14)}
|
||||
selection={
|
||||
c().selection
|
||||
? {
|
||||
start: c().selection!.startLine,
|
||||
end: c().selection!.endLine,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
comment={c().comment}
|
||||
class="max-w-[260px]"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<Show when={comment()}>
|
||||
{(c) => (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon
|
||||
node={{ path: c().path, type: "file" }}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{getFilename(c().path)}</span>
|
||||
<Show when={c().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{c().comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
)
|
||||
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
const migrations = await Promise.all(
|
||||
migrationDirs.map(async (name) => {
|
||||
const file = path.join(dir, "migration", name, "migration.sql")
|
||||
const sql = await Bun.file(file).text()
|
||||
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
|
||||
const timestamp = match
|
||||
? Date.UTC(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
Number(match[4]),
|
||||
Number(match[5]),
|
||||
Number(match[6]),
|
||||
)
|
||||
: 0
|
||||
return { sql, timestamp, name }
|
||||
}),
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist",
|
||||
format: "esm",
|
||||
external: ["jsonc-parser"],
|
||||
define: {
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
},
|
||||
})
|
||||
|
||||
console.log("Build complete")
|
||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAdaptorServer } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
@@ -56,10 +57,24 @@ export namespace WorkspaceServer {
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
const server = createAdaptorServer({
|
||||
fetch: App().fetch,
|
||||
})
|
||||
server.listen(opts.port, opts.hostname)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
stop() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { Server } from "./server/server"
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
|
||||
type Rule = {
|
||||
permission: string
|
||||
pattern: string
|
||||
action: "allow" | "deny" | "ask"
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
|
||||
const rules = rulesets.flat()
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { Wildcard } from "@/util/wildcard"
|
||||
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { evaluate as evalRule } from "./evaluate"
|
||||
import { PermissionID } from "./schema"
|
||||
|
||||
export namespace PermissionNext {
|
||||
@@ -126,8 +125,12 @@ export namespace PermissionNext {
|
||||
}
|
||||
|
||||
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
|
||||
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
|
||||
return evalRule(permission, pattern, ...rulesets)
|
||||
const rules = rulesets.flat()
|
||||
log.info("evaluate", { permission, pattern, ruleset: rules })
|
||||
const match = rules.findLast(
|
||||
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
|
||||
)
|
||||
return match ?? { action: "ask", permission, pattern: "*" }
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}
|
||||
|
||||
@@ -184,15 +184,6 @@ export namespace Provider {
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
xai: async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
"github-copilot": async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
|
||||
@@ -23,6 +23,8 @@ export namespace Pty {
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const key = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
@@ -97,9 +99,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -230,9 +232,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [id, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (key(ws) === id) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -263,16 +265,13 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
// Use ws.data as the unique key for this connection lifecycle.
|
||||
// If ws.data is undefined, fallback to ws object.
|
||||
const connectionKey = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||
const sub = key(ws)
|
||||
|
||||
// Optionally cleanup if the key somehow exists
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.set(connectionKey, ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(connectionKey)
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const projects = Project.list()
|
||||
const projects = await Project.list()
|
||||
return c.json(projects)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
@@ -197,5 +196,5 @@ export const PtyRoutes = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Log } from "../util/log"
|
||||
import { Bus } from "../bus"
|
||||
import { BusEvent } from "../bus/bus-event"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
@@ -25,7 +28,7 @@ import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||
import { ProjectRoutes } from "./routes/project"
|
||||
import { SessionRoutes } from "./routes/session"
|
||||
import { PtyRoutes } from "./routes/pty"
|
||||
// import { PtyRoutes } from "./routes/pty"
|
||||
import { McpRoutes } from "./routes/mcp"
|
||||
import { FileRoutes } from "./routes/file"
|
||||
import { ConfigRoutes } from "./routes/config"
|
||||
@@ -35,7 +38,8 @@ import { EventRoutes } from "./routes/event"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { NotFoundError } from "../storage/db"
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||
import { websocket } from "hono/bun"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -49,13 +53,20 @@ import { lazy } from "@/util/lazy"
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const Default = lazy(() => createApp({}))
|
||||
export const Default = lazy(() => create({}).app)
|
||||
|
||||
export const createApp = (opts: { cors?: string[] }): Hono => {
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const log = Log.create({ service: "server" })
|
||||
const app = new Hono()
|
||||
return app
|
||||
const ws = createNodeWebSocket({ app })
|
||||
const route = app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
error: err,
|
||||
@@ -241,7 +252,6 @@ export namespace Server {
|
||||
),
|
||||
)
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
@@ -497,22 +507,70 @@ export namespace Server {
|
||||
return c.json(await Format.status())
|
||||
},
|
||||
)
|
||||
.all("/*", async (c) => {
|
||||
const path = c.req.path
|
||||
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
.get(
|
||||
"/event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to events",
|
||||
description: "Get events",
|
||||
operationId: "event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(BusEvent.payloads()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
||||
)
|
||||
return response
|
||||
})
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
}),
|
||||
})
|
||||
const unsub = Bus.subscribeAll(async (event) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
})
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stream.close()
|
||||
}
|
||||
})
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
stream.writeSSE({
|
||||
data: JSON.stringify({
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
}),
|
||||
})
|
||||
}, 10_000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
resolve()
|
||||
log.info("event disconnected")
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
// .route("/pty", PtyRoutes(ws.upgradeWebSocket))
|
||||
|
||||
return {
|
||||
app: route as Hono,
|
||||
ws,
|
||||
}
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
@@ -530,52 +588,89 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
/** @deprecated do not use this dumb shit */
|
||||
export let url: URL
|
||||
|
||||
export function listen(opts: {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
||||
const app = createApp(opts)
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: websocket,
|
||||
} as const
|
||||
const tryServe = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}): Promise<Listener> {
|
||||
const log = Log.create({ service: "server" })
|
||||
const built = create({
|
||||
...opts,
|
||||
})
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = opts.hostname
|
||||
url.port = String(addr.port)
|
||||
Server.url = url
|
||||
|
||||
const shouldPublishMDNS =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
const originalStop = server.stop.bind(server)
|
||||
server.stop = async (closeActiveConnections?: boolean) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
return originalStop(closeActiveConnections)
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: addr.port,
|
||||
url,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import type { SystemError } from "bun"
|
||||
import { type SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { MCP } from "../mcp"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
@@ -1989,10 +1988,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (!cleaned) return
|
||||
|
||||
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
|
||||
return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return
|
||||
throw err
|
||||
})
|
||||
return Session.setTitle({ sessionID: input.session.id, title })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Snapshot } from "@/snapshot"
|
||||
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Bus } from "@/bus"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
|
||||
export namespace SessionSummary {
|
||||
function unquoteGitPath(input: string) {
|
||||
@@ -74,17 +73,11 @@ export namespace SessionSummary {
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
await Session.messages({ sessionID: input.sessionID })
|
||||
.then((all) =>
|
||||
Promise.all([
|
||||
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
||||
summarizeMessage({ messageID: input.messageID, messages: all }),
|
||||
]),
|
||||
)
|
||||
.catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return
|
||||
throw err
|
||||
})
|
||||
const all = await Session.messages({ sessionID: input.sessionID })
|
||||
await Promise.all([
|
||||
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
||||
summarizeMessage({ messageID: input.messageID, messages: all }),
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
@@ -109,8 +102,7 @@ export namespace SessionSummary {
|
||||
const messages = input.messages.filter(
|
||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||
)
|
||||
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
|
||||
if (!msgWithParts) return
|
||||
const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
|
||||
const userMsg = msgWithParts.info as MessageV2.User
|
||||
const diffs = await computeDiff({ messages })
|
||||
userMsg.summary = {
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace ToolRegistry {
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||
const mod = await import(pathToFileURL(match).href)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { evaluate } from "@/permission/evaluate"
|
||||
import { PermissionNext } from "../permission"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Log } from "../util/log"
|
||||
import { ToolID } from "./schema"
|
||||
@@ -28,7 +28,7 @@ export namespace TruncateEffect {
|
||||
|
||||
function hasTaskTool(agent?: Agent.Info) {
|
||||
if (!agent?.permission) return false
|
||||
return evaluate("task", "*", agent.permission).action !== "deny"
|
||||
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
||||
@@ -61,9 +61,9 @@ export namespace Process {
|
||||
|
||||
const proc = launch(cmd[0], cmd.slice(1), {
|
||||
cwd: opts.cwd,
|
||||
shell: opts.shell,
|
||||
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
|
||||
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
|
||||
shell: opts.shell,
|
||||
windowsHide: process.platform === "win32",
|
||||
})
|
||||
|
||||
|
||||
@@ -4,14 +4,12 @@ import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { TruncateEffect } from "../../src/tool/truncate-effect"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Process } from "../../src/util/process"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import path from "path"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { writeFileStringScoped } from "../lib/filesystem"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||
|
||||
describe("Truncate", () => {
|
||||
describe("output", () => {
|
||||
@@ -127,14 +125,6 @@ describe("Truncate", () => {
|
||||
if (result.truncated) throw new Error("expected not truncated")
|
||||
expect("outputPath" in result).toBe(false)
|
||||
})
|
||||
|
||||
test("loads truncate effect in a fresh process", async () => {
|
||||
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
|
||||
cwd: ROOT,
|
||||
})
|
||||
|
||||
expect(out.code).toBe(0)
|
||||
}, 20000)
|
||||
})
|
||||
|
||||
describe("cleanup", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-weak-base);
|
||||
border-color: var(--border-strong-base);
|
||||
}
|
||||
|
||||
&[data-clickable] {
|
||||
@@ -62,11 +62,9 @@
|
||||
}
|
||||
|
||||
&[data-type="file"] {
|
||||
width: fit-content;
|
||||
max-width: min(260px, 100%);
|
||||
width: min(220px, 100%);
|
||||
height: 48px;
|
||||
padding: 0 18px 0 10px;
|
||||
background: var(--background-stronger);
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +89,7 @@
|
||||
}
|
||||
|
||||
[data-slot="user-message-attachment-file"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -107,12 +104,10 @@
|
||||
|
||||
[data-slot="user-message-attachment-name"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
@@ -900,12 +900,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
|
||||
const text = createMemo(() => textPart()?.text || "")
|
||||
|
||||
const shown = createMemo(() => {
|
||||
const value = text()
|
||||
if (!value.trim()) return ""
|
||||
return value
|
||||
})
|
||||
|
||||
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
|
||||
|
||||
const attachments = createMemo(() => files().filter(attached))
|
||||
@@ -1001,11 +995,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={shown()}>
|
||||
<Show when={text()}>
|
||||
<>
|
||||
<div data-slot="user-message-body">
|
||||
<div data-slot="user-message-text">
|
||||
<HighlightedText text={shown()} references={inlineFiles()} agents={agents()} />
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -959,7 +959,7 @@
|
||||
model: z4.string().nullish(),
|
||||
object: z4.literal("response"),
|
||||
output: z4.array(outputItemSchema),
|
||||
- usage: xaiResponsesUsageSchema,
|
||||
+ usage: xaiResponsesUsageSchema.nullish(),
|
||||
status: z4.string()
|
||||
});
|
||||
var xaiResponsesChunkSchema = z4.union([
|
||||
\ No newline at end of file
|
||||
@@ -1143,6 +1143,18 @@
|
||||
z4.object({
|
||||
type: z4.literal("response.completed"),
|
||||
response: xaiResponsesResponseSchema
|
||||
+ }),
|
||||
+ z4.object({
|
||||
+ type: z4.literal("response.function_call_arguments.delta"),
|
||||
+ item_id: z4.string(),
|
||||
+ output_index: z4.number(),
|
||||
+ delta: z4.string()
|
||||
+ }),
|
||||
+ z4.object({
|
||||
+ type: z4.literal("response.function_call_arguments.done"),
|
||||
+ item_id: z4.string(),
|
||||
+ output_index: z4.number(),
|
||||
+ arguments: z4.string()
|
||||
})
|
||||
]);
|
||||
|
||||
\ No newline at end of file
|
||||
@@ -1940,6 +1952,9 @@
|
||||
if (response2.status) {
|
||||
finishReason = mapXaiResponsesFinishReason(response2.status);
|
||||
}
|
||||
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
|
||||
+ finishReason = "tool-calls";
|
||||
+ }
|
||||
return;
|
||||
}
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
\ No newline at end of file
|
||||
@@ -2024,7 +2039,7 @@
|
||||
}
|
||||
}
|
||||
} else if (part.type === "function_call") {
|
||||
- if (!seenToolCalls.has(part.call_id)) {
|
||||
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
|
||||
seenToolCalls.add(part.call_id);
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -964,7 +964,7 @@
|
||||
model: import_v44.z.string().nullish(),
|
||||
object: import_v44.z.literal("response"),
|
||||
output: import_v44.z.array(outputItemSchema),
|
||||
- usage: xaiResponsesUsageSchema,
|
||||
+ usage: xaiResponsesUsageSchema.nullish(),
|
||||
status: import_v44.z.string()
|
||||
});
|
||||
var xaiResponsesChunkSchema = import_v44.z.union([
|
||||
\ No newline at end of file
|
||||
@@ -1148,6 +1148,18 @@
|
||||
import_v44.z.object({
|
||||
type: import_v44.z.literal("response.completed"),
|
||||
response: xaiResponsesResponseSchema
|
||||
+ }),
|
||||
+ import_v44.z.object({
|
||||
+ type: import_v44.z.literal("response.function_call_arguments.delta"),
|
||||
+ item_id: import_v44.z.string(),
|
||||
+ output_index: import_v44.z.number(),
|
||||
+ delta: import_v44.z.string()
|
||||
+ }),
|
||||
+ import_v44.z.object({
|
||||
+ type: import_v44.z.literal("response.function_call_arguments.done"),
|
||||
+ item_id: import_v44.z.string(),
|
||||
+ output_index: import_v44.z.number(),
|
||||
+ arguments: import_v44.z.string()
|
||||
})
|
||||
]);
|
||||
|
||||
\ No newline at end of file
|
||||
@@ -1935,6 +1947,9 @@
|
||||
if (response2.status) {
|
||||
finishReason = mapXaiResponsesFinishReason(response2.status);
|
||||
}
|
||||
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
|
||||
+ finishReason = "tool-calls";
|
||||
+ }
|
||||
return;
|
||||
}
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
|
||||
\ No newline at end of file
|
||||
@@ -2019,7 +2034,7 @@
|
||||
}
|
||||
}
|
||||
} else if (part.type === "function_call") {
|
||||
- if (!seenToolCalls.has(part.call_id)) {
|
||||
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
|
||||
seenToolCalls.add(part.call_id);
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
\ No newline at end of file
|
||||
@@ -1,58 +0,0 @@
|
||||
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/dist/dev.cjs b/dist/dev.cjs
|
||||
index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
|
||||
--- a/dist/dev.cjs
|
||||
+++ b/dist/dev.cjs
|
||||
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
diff --git a/dist/dev.js b/dist/dev.js
|
||||
index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
|
||||
--- a/dist/dev.js
|
||||
+++ b/dist/dev.js
|
||||
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
diff --git a/dist/solid.cjs b/dist/solid.cjs
|
||||
index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
|
||||
--- a/dist/solid.cjs
|
||||
+++ b/dist/solid.cjs
|
||||
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
diff --git a/dist/solid.js b/dist/solid.js
|
||||
index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
|
||||
--- a/dist/solid.js
|
||||
+++ b/dist/solid.js
|
||||
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
|
||||
if (node.updatedAt != null && "observers" in node) {
|
||||
writeSignal(node, nextValue, true);
|
||||
} else if (Transition && Transition.running && node.pure) {
|
||||
+ // On first computation during transition, also set committed value #2046
|
||||
+ if (!Transition.sources.has(node)) node.value = nextValue;
|
||||
Transition.sources.add(node);
|
||||
node.tValue = nextValue;
|
||||
} else node.value = nextValue;
|
||||
Reference in New Issue
Block a user