mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 10:07:58 +00:00
Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1,4 +1,5 @@
|
||||
# web + desktop packages
|
||||
packages/app/ @adamdotdevin
|
||||
packages/tauri/ @adamdotdevin
|
||||
packages/desktop/src-tauri/ @brendonovich
|
||||
packages/desktop/ @adamdotdevin
|
||||
|
||||
34
bun.lock
34
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -258,7 +258,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -309,7 +309,7 @@
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
@@ -363,7 +363,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -383,7 +383,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -394,7 +394,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -407,7 +407,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -449,7 +449,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -460,7 +460,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -2108,7 +2108,7 @@
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=",
|
||||
"aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=",
|
||||
"aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=",
|
||||
"x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU="
|
||||
"x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=",
|
||||
"aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=",
|
||||
"aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=",
|
||||
"x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ import {
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page.mouse.click(5, 5)
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const el = document.activeElement
|
||||
if (el instanceof HTMLElement) el.blur()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function openPalette(page: Page) {
|
||||
@@ -68,14 +73,50 @@ export async function toggleSidebar(page: Page) {
|
||||
|
||||
export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const opened = await expect(main)
|
||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const closed = await expect(main)
|
||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(main).toHaveClass(/xl:border-l/)
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
@@ -182,13 +223,30 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
}
|
||||
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
const sessionEl = await hoverSessionItem(page, sessionID)
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
|
||||
const scroller = page.locator(".session-scroller").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
|
||||
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
|
||||
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
|
||||
.first()
|
||||
|
||||
const opened = await menu
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return menu
|
||||
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect } from "@playwright/test"
|
||||
import { seedProjects } from "./actions"
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
@@ -8,6 +8,14 @@ export const settingsKey = "settings.v3"
|
||||
type TestFixtures = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(
|
||||
callback: (project: {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
@@ -33,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
await seedProjects(page, { directory })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await seedStorage(page, { directory })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
@@ -51,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const directory = await createTestProject()
|
||||
const slug = dirSlug(directory)
|
||||
await seedStorage(page, { directory, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
return await callback({ directory, slug, gotoSession })
|
||||
} finally {
|
||||
await cleanupTestProject(directory)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await openSidebar(page)
|
||||
await withProject(async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const open = async () => {
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||
return dialog
|
||||
}
|
||||
|
||||
const name = `e2e project ${Date.now()}`
|
||||
const startup = `echo e2e_${Date.now()}`
|
||||
|
||||
const dialog = await open()
|
||||
|
||||
const nameInput = dialog.getByLabel("Name")
|
||||
await nameInput.fill(name)
|
||||
|
||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||
await startupInput.fill(startup)
|
||||
|
||||
await dialog.getByRole("button", { name: "Save" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const open = async () => {
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||
return dialog
|
||||
}
|
||||
|
||||
const name = `e2e project ${Date.now()}`
|
||||
const startup = `echo e2e_${Date.now()}`
|
||||
|
||||
const dialog = await open()
|
||||
|
||||
const nameInput = dialog.getByLabel("Name")
|
||||
await nameInput.fill(name)
|
||||
|
||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||
await startupInput.fill(startup)
|
||||
|
||||
await dialog.getByRole("button", { name: "Save" }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
const reopened = await open()
|
||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||
await expect(reopened).toHaveCount(0)
|
||||
const reopened = await open()
|
||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||
await expect(reopened).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,69 +1,73 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => {
|
||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
await openSidebar(page)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => {
|
||||
test("can close a project via project header more options menu", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherName = other.split("/").pop() ?? other
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
await openSidebar(page)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
const header = page
|
||||
.locator(".group\\/project")
|
||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||
.first()
|
||||
await expect(header).toContainText(otherName)
|
||||
|
||||
const header = page
|
||||
.locator(".group\\/project")
|
||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||
.first()
|
||||
await expect(header).toContainText(otherName)
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
|
||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => {
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await withProject(
|
||||
async ({ directory }) => {
|
||||
await defocus(page)
|
||||
|
||||
await defocus(page)
|
||||
const currentSlug = dirSlug(directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
|
||||
const currentSlug = dirSlug(directory)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||
await expect(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -10,33 +10,20 @@ import {
|
||||
cleanupTestProject,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
createTestProject,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
seedProjects,
|
||||
setWorkspacesEnabled,
|
||||
} from "../actions"
|
||||
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
await gotoSession()
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(rootSlug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
@@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: ()
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
return { project, rootSlug, slug, directory: dir }
|
||||
return { rootSlug, slug, directory: dir }
|
||||
}
|
||||
|
||||
test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
|
||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const slug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
@@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc
|
||||
await setWorkspacesEnabled(page, slug, false)
|
||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can create a workspace", async ({ page, directory, gotoSession }) => {
|
||||
test("can create a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const project = await createTestProject()
|
||||
const slug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
|
||||
@@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||
|
||||
await cleanupTestProject(workspaceDir)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can rename a workspace", async ({ page, directory, gotoSession }) => {
|
||||
test("can rename a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
await withProject(async (project) => {
|
||||
const { slug } = await setupWorkspaceTest(page, project)
|
||||
|
||||
try {
|
||||
const rename = `e2e workspace ${Date.now()}`
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||
@@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await input.fill(rename)
|
||||
await input.press("Enter")
|
||||
await expect(item).toContainText(rename)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
await withProject(async (project) => {
|
||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||
|
||||
try {
|
||||
const readme = path.join(createdDir, "README.md")
|
||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||
const original = await fs.readFile(readme, "utf8")
|
||||
@@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||
.catch(() => false)
|
||||
})
|
||||
.toBe(false)
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can delete a workspace", async ({ page, directory, gotoSession }) => {
|
||||
test("can delete a workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
|
||||
await withProject(async (project) => {
|
||||
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
|
||||
|
||||
try {
|
||||
const menu = await openWorkspaceMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
@@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => {
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||
} finally {
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
|
||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
await withProject(async ({ slug: rootSlug }) => {
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
const listSlugs = async () => {
|
||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||
const slugs = await nodes.evaluateAll((els) => {
|
||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||
})
|
||||
return slugs
|
||||
}
|
||||
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
|
||||
const listSlugs = async () => {
|
||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||
const slugs = await nodes.evaluateAll((els) => {
|
||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||
})
|
||||
return slugs
|
||||
}
|
||||
|
||||
const waitReady = async (slug: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await src.scrollIntoViewIfNeeded()
|
||||
await dst.scrollIntoViewIfNeeded()
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
await openSidebar(page)
|
||||
|
||||
const target = page.locator(projectSwitchSelector(rootSlug)).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
const waitReady = async (slug: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
const drag = async (from: string, to: string) => {
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await src.scrollIntoViewIfNeeded()
|
||||
await dst.scrollIntoViewIfNeeded()
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
|
||||
await openSidebar(page)
|
||||
}
|
||||
|
||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||
|
||||
const a = workspaces[0].slug
|
||||
const b = workspaces[1].slug
|
||||
|
||||
await waitReady(a)
|
||||
await waitReady(b)
|
||||
|
||||
const list = async () => {
|
||||
const slugs = await listSlugs()
|
||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const slugs = await list()
|
||||
return slugs.length === 2
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const before = await list()
|
||||
const from = before[1]
|
||||
const to = before[0]
|
||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||
|
||||
await drag(from, to)
|
||||
|
||||
await expect.poll(async () => await list()).toEqual([from, to])
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
||||
}
|
||||
|
||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||
|
||||
const a = workspaces[0].slug
|
||||
const b = workspaces[1].slug
|
||||
|
||||
await waitReady(a)
|
||||
await waitReady(b)
|
||||
|
||||
const list = async () => {
|
||||
const slugs = await listSlugs()
|
||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const slugs = await list()
|
||||
return slugs.length === 2
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const before = await list()
|
||||
const from = before[1]
|
||||
const to = before[0]
|
||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||
|
||||
await drag(from, to)
|
||||
|
||||
await expect.poll(async () => await list()).toEqual([from, to])
|
||||
} finally {
|
||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
||||
await cleanupTestProject(project)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,57 +11,98 @@ import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
|
||||
type Sdk = Parameters<typeof withSession>[0]
|
||||
|
||||
async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "e2e seed" }],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const newTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
|
||||
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.time?.archived
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
return data?.id
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +113,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openStatusPopover, defocus } from "../actions"
|
||||
import { openStatusPopover } from "../actions"
|
||||
|
||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -88,7 +88,7 @@ test("status popover closes when clicking outside", async ({ page, gotoSession }
|
||||
const { popoverBody } = await openStatusPopover(page)
|
||||
await expect(popoverBody).toBeVisible()
|
||||
|
||||
await defocus(page)
|
||||
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
|
||||
|
||||
await expect(popoverBody).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const win = process.platform === "win32"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -15,8 +14,7 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: !win,
|
||||
workers: win ? 1 : undefined,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
|
||||
@@ -1220,7 +1220,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
if (session) {
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
}
|
||||
if (!session) return
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
@@ -52,6 +53,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
}
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const platform = usePlatform()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
@@ -135,6 +137,22 @@ export const Terminal = (props: TerminalProps) => {
|
||||
focusTerminal()
|
||||
}
|
||||
|
||||
const handleLinkClick = (event: MouseEvent) => {
|
||||
if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
|
||||
if (event.altKey) return
|
||||
if (event.button !== 0) return
|
||||
|
||||
const t = term
|
||||
if (!t) return
|
||||
|
||||
const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink
|
||||
if (!link?.text) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
platform.openLink(link.text)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const run = async () => {
|
||||
const loaded = await loadGhostty()
|
||||
@@ -146,6 +164,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const once = { value: false }
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (window.__OPENCODE__?.serverPassword) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
@@ -239,6 +258,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
container.addEventListener("pointerdown", handlePointerDown)
|
||||
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
||||
|
||||
container.addEventListener("click", handleLinkClick, { capture: true })
|
||||
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
|
||||
|
||||
handleTextareaFocus = () => {
|
||||
t.options.cursorBlink = true
|
||||
}
|
||||
|
||||
@@ -70,6 +70,14 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
setFocus((current) => (current?.id === id ? null : current))
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
batch(() => {
|
||||
setStore("comments", {})
|
||||
setFocus(null)
|
||||
setActive(null)
|
||||
})
|
||||
}
|
||||
|
||||
const all = createMemo(() => {
|
||||
const files = Object.keys(store.comments)
|
||||
const items = files.flatMap((file) => store.comments[file] ?? [])
|
||||
@@ -82,6 +90,7 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
all,
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
focus: createMemo(() => state.focus),
|
||||
setFocus,
|
||||
clearFocus: () => setFocus(null),
|
||||
@@ -144,6 +153,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
all: () => session().all(),
|
||||
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
||||
remove: (file: string, id: string) => session().remove(file, id),
|
||||
clear: () => session().clear(),
|
||||
focus: () => session().focus(),
|
||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||
clearFocus: () => session().clearFocus(),
|
||||
|
||||
@@ -33,6 +33,14 @@ type SessionTabs = {
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
}
|
||||
|
||||
type TabHandoff = {
|
||||
dir: string
|
||||
id: string
|
||||
at: number
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
@@ -115,10 +123,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
sessionTabs: {} as Record<string, SessionTabs>,
|
||||
sessionView: {} as Record<string, SessionView>,
|
||||
handoff: {
|
||||
tabs: undefined as TabHandoff | undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
|
||||
@@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
ready,
|
||||
handoff: {
|
||||
tabs: createMemo(() => store.handoff?.tabs),
|
||||
setTabs(dir: string, id: string) {
|
||||
setStore("handoff", "tabs", { dir, id, at: Date.now() })
|
||||
},
|
||||
clearTabs() {
|
||||
if (!store.handoff?.tabs) return
|
||||
setStore("handoff", "tabs", undefined)
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
@@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("mobileSidebar", "opened", (x) => !x)
|
||||
},
|
||||
},
|
||||
pendingMessage: {
|
||||
set(sessionKey: string, messageID: string) {
|
||||
const at = Date.now()
|
||||
touch(sessionKey)
|
||||
const current = store.sessionView[sessionKey]
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, {
|
||||
scroll: {},
|
||||
pendingMessage: messageID,
|
||||
pendingMessageAt: at,
|
||||
})
|
||||
prune(meta.active ?? sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
setStore(
|
||||
"sessionView",
|
||||
sessionKey,
|
||||
produce((draft) => {
|
||||
draft.pendingMessage = messageID
|
||||
draft.pendingMessageAt = at
|
||||
}),
|
||||
)
|
||||
},
|
||||
consume(sessionKey: string) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const message = current?.pendingMessage
|
||||
const at = current?.pendingMessageAt
|
||||
if (!message || !at) return
|
||||
|
||||
setStore(
|
||||
"sessionView",
|
||||
sessionKey,
|
||||
produce((draft) => {
|
||||
delete draft.pendingMessage
|
||||
delete draft.pendingMessageAt
|
||||
}),
|
||||
)
|
||||
|
||||
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
|
||||
return message
|
||||
},
|
||||
},
|
||||
view(sessionKey: string | Accessor<string>) {
|
||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { agentColor } from "@/utils/agent"
|
||||
|
||||
@@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const navLeave = { current: undefined as number | undefined }
|
||||
|
||||
const aim = createAim({
|
||||
enabled: () => !layout.sidebar.opened(),
|
||||
active: () => state.hoverProject,
|
||||
el: () => state.nav,
|
||||
onActivate: (directory) => {
|
||||
globalSync.child(directory)
|
||||
setState("hoverProject", directory)
|
||||
setState("hoverSession", undefined)
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (navLeave.current === undefined) return
|
||||
clearTimeout(navLeave.current)
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
aim.reset()
|
||||
})
|
||||
|
||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||
@@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.sidebar.opened()) return
|
||||
aim.reset()
|
||||
setState("hoverProject", undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (state.hoverProject !== undefined) return
|
||||
aim.reset()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
() => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (!state.hoverProject) return
|
||||
aim.reset()
|
||||
setState("hoverSession", undefined)
|
||||
setState("hoverProject", undefined)
|
||||
},
|
||||
@@ -1000,69 +1019,6 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(session: Session) {
|
||||
const [store, setStore] = globalSync.child(session.directory)
|
||||
const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === session.id)
|
||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||
|
||||
const result = await globalSDK.client.session
|
||||
.delete({ directory: session.directory, sessionID: session.id })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([session.id])
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [session.id]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
if (session.id === params.id) {
|
||||
if (nextSession) {
|
||||
navigate(`/${params.dir}/session/${nextSession.id}`)
|
||||
} else {
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command.register(() => {
|
||||
const commands: CommandOption[] = [
|
||||
{
|
||||
@@ -1316,15 +1272,6 @@ export default function Layout(props: ParentProps) {
|
||||
globalSync.project.meta(project.worktree, { name })
|
||||
}
|
||||
|
||||
async function renameSession(session: Session, next: string) {
|
||||
if (next === session.title) return
|
||||
await globalSDK.client.session.update({
|
||||
directory: session.directory,
|
||||
sessionID: session.id,
|
||||
title: next,
|
||||
})
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
if (current === next) return
|
||||
@@ -1475,33 +1422,6 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { session: Session }) {
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.session)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: props.session.title })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspace(props: { root: string; directory: string }) {
|
||||
const name = createMemo(() => getFilename(props.directory))
|
||||
const [data, setData] = createStore({
|
||||
@@ -1855,10 +1775,6 @@ export default function Layout(props: ParentProps) {
|
||||
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
|
||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||
const isActive = createMemo(() => props.session.id === params.id)
|
||||
const [menu, setMenu] = createStore({
|
||||
open: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
|
||||
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const cancelHoverPrefetch = () => {
|
||||
@@ -1885,7 +1801,7 @@ export default function Layout(props: ParentProps) {
|
||||
const item = (
|
||||
<A
|
||||
href={`${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onPointerEnter={scheduleHoverPrefetch}
|
||||
onPointerLeave={cancelHoverPrefetch}
|
||||
onMouseEnter={scheduleHoverPrefetch}
|
||||
@@ -1917,14 +1833,9 @@ export default function Layout(props: ParentProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<InlineEditor
|
||||
id={`session:${props.session.id}`}
|
||||
value={() => props.session.title}
|
||||
onSave={(next) => renameSession(props.session, next)}
|
||||
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
@@ -1972,7 +1883,10 @@ export default function Layout(props: ParentProps) {
|
||||
getLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive()) {
|
||||
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
|
||||
layout.pendingMessage.set(
|
||||
`${base64Encode(props.session.directory)}/${props.session.id}`,
|
||||
message.id,
|
||||
)
|
||||
navigate(`${props.slug}/session/${props.session.id}`)
|
||||
return
|
||||
}
|
||||
@@ -1989,49 +1903,25 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": menu.open,
|
||||
"opacity-0 pointer-events-none": !menu.open,
|
||||
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
|
||||
<Tooltip value={language.t("common.moreOptions")} placement="top">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!menu.pendingRename) return
|
||||
event.preventDefault()
|
||||
setMenu("pendingRename", false)
|
||||
openEditor(`session:${props.session.id}`, props.session.title)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setMenu("pendingRename", true)
|
||||
setMenu("open", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2440,17 +2330,17 @@ export default function Layout(props: ParentProps) {
|
||||
!selected() && !active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!overlay()) return
|
||||
globalSync.child(props.project.worktree)
|
||||
setState("hoverProject", props.project.worktree)
|
||||
setState("hoverSession", undefined)
|
||||
aim.enter(props.project.worktree, event)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!overlay()) return
|
||||
aim.leave(props.project.worktree)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!overlay()) return
|
||||
globalSync.child(props.project.worktree)
|
||||
setState("hoverProject", props.project.worktree)
|
||||
setState("hoverSession", undefined)
|
||||
aim.activate(props.project.worktree)
|
||||
}}
|
||||
onClick={() => navigateToProject(props.project.worktree)}
|
||||
onBlur={() => setOpen(false)}
|
||||
@@ -2935,7 +2825,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full overflow-hidden">
|
||||
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
|
||||
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>
|
||||
<div class="flex-1 min-h-0 w-full">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
@@ -3030,6 +2920,7 @@ export default function Layout(props: ParentProps) {
|
||||
navLeave.current = undefined
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
@@ -3045,7 +2936,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
||||
{(project) => (
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex">
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||
<SidebarPanel project={project} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,15 +16,19 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
@@ -51,7 +55,7 @@ import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useComments, type LineComment } from "@/context/comments"
|
||||
@@ -73,13 +77,36 @@ import { same } from "@/utils/same"
|
||||
|
||||
type DiffStyle = "unified" | "split"
|
||||
|
||||
type HandoffSession = {
|
||||
prompt: string
|
||||
files: Record<string, SelectedLineRange | null>
|
||||
}
|
||||
|
||||
const HANDOFF_MAX = 40
|
||||
|
||||
const handoff = {
|
||||
prompt: "",
|
||||
terminals: [] as string[],
|
||||
files: {} as Record<string, SelectedLineRange | null>,
|
||||
session: new Map<string, HandoffSession>(),
|
||||
terminal: new Map<string, string[]>(),
|
||||
}
|
||||
|
||||
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
|
||||
map.delete(key)
|
||||
map.set(key, value)
|
||||
while (map.size > HANDOFF_MAX) {
|
||||
const first = map.keys().next().value
|
||||
if (first === undefined) return
|
||||
map.delete(first)
|
||||
}
|
||||
}
|
||||
|
||||
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
|
||||
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
|
||||
touch(handoff.session, key, { ...prev, ...patch })
|
||||
}
|
||||
|
||||
interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: () => FileDiff[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
@@ -196,6 +223,8 @@ function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
|
||||
return (
|
||||
<SessionReview
|
||||
title={props.title}
|
||||
empty={props.empty}
|
||||
scrollRef={(el) => {
|
||||
scroll = el
|
||||
props.onScrollRef?.(el)
|
||||
@@ -255,6 +284,10 @@ export default function Page() {
|
||||
pendingMessage: undefined as string | undefined,
|
||||
scrollGesture: 0,
|
||||
autoCreated: false,
|
||||
scroll: {
|
||||
overflow: false,
|
||||
bottom: true,
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -280,9 +313,47 @@ export default function Page() {
|
||||
.finally(() => setUi("responding", false))
|
||||
}
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const workspaceKey = createMemo(() => params.dir ?? "")
|
||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id, prev) => {
|
||||
if (!id) return
|
||||
if (prev) return
|
||||
|
||||
const pending = layout.handoff.tabs()
|
||||
if (!pending) return
|
||||
if (Date.now() - pending.at > 60_000) {
|
||||
layout.handoff.clearTabs()
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.id !== id) return
|
||||
layout.handoff.clearTabs()
|
||||
if (pending.dir !== (params.dir ?? "")) return
|
||||
|
||||
const from = workspaceTabs().tabs()
|
||||
if (from.all.length === 0 && !from.active) return
|
||||
|
||||
const current = tabs().tabs()
|
||||
if (current.all.length > 0 || current.active) return
|
||||
|
||||
const all = normalizeTabs(from.all)
|
||||
const active = from.active ? normalizeTab(from.active) : undefined
|
||||
tabs().setAll(all)
|
||||
tabs().setActive(active && all.includes(active) ? active : all[0])
|
||||
|
||||
workspaceTabs().setAll([])
|
||||
workspaceTabs().setActive(undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
createEffect(
|
||||
on(
|
||||
@@ -398,6 +469,213 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!params.id) return
|
||||
setTitle({ editing: true, draft: info()?.title ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (info()?.title ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function archiveSession(sessionID: string) {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
|
||||
if (params.id !== sessionID) return
|
||||
if (session.parentID) {
|
||||
navigate(`/${params.dir}/session/${session.parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSession) {
|
||||
navigate(`/${params.dir}/session/${nextSession.id}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteSession(sessionID: string) {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
if (params.id !== sessionID) return true
|
||||
if (session.parentID) {
|
||||
navigate(`/${params.dir}/session/${session.parentID}`)
|
||||
return true
|
||||
}
|
||||
if (nextSession) {
|
||||
navigate(`/${params.dir}/session/${nextSession.id}`)
|
||||
return true
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
return true
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: title() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -436,10 +714,14 @@ export default function Page() {
|
||||
messageId: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
promptHeight: 0,
|
||||
})
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = visibleUserMessages()
|
||||
@@ -526,6 +808,7 @@ export default function Page() {
|
||||
let inputRef!: HTMLDivElement
|
||||
let promptDock: HTMLDivElement | undefined
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
|
||||
const scrollGestureWindowMs = 250
|
||||
|
||||
@@ -545,8 +828,10 @@ export default function Page() {
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.id) return
|
||||
sync.session.sync(params.id)
|
||||
sdk.directory
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync.session.sync(id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -614,10 +899,23 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("expanded", {})
|
||||
setStore("changes", "session")
|
||||
setUi("autoCreated", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
(dir) => {
|
||||
if (!dir) return
|
||||
setStore("newSessionWorktree", "main")
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -1125,34 +1423,84 @@ export default function Page() {
|
||||
activeDiff: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const reviewScroll = () => tree.reviewScroll
|
||||
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
|
||||
const pendingDiff = () => tree.pendingDiff
|
||||
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
|
||||
const activeDiff = () => tree.activeDiff
|
||||
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const showAllFiles = () => {
|
||||
if (fileTreeTab() !== "changes") return
|
||||
setFileTreeTab("all")
|
||||
}
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="large"
|
||||
triggerStyle={{ "font-size": "var(--font-size-large)" }}
|
||||
/>
|
||||
)
|
||||
|
||||
const emptyTurn = () => (
|
||||
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={(path) => {
|
||||
showAllFiles()
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onScrollRef={setReviewScroll}
|
||||
focusedFile={activeDiff()}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
@@ -1202,7 +1550,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const reviewDiffTop = (path: string) => {
|
||||
const root = reviewScroll()
|
||||
const root = tree.reviewScroll
|
||||
if (!root) return
|
||||
|
||||
const id = reviewDiffId(path)
|
||||
@@ -1218,7 +1566,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const scrollToReviewDiff = (path: string) => {
|
||||
const root = reviewScroll()
|
||||
const root = tree.reviewScroll
|
||||
if (!root) return false
|
||||
|
||||
const top = reviewDiffTop(path)
|
||||
@@ -1232,24 +1580,23 @@ export default function Page() {
|
||||
const focusReviewDiff = (path: string) => {
|
||||
const current = view().review.open() ?? []
|
||||
if (!current.includes(path)) view().review.setOpen([...current, path])
|
||||
setActiveDiff(path)
|
||||
setPendingDiff(path)
|
||||
setTree({ activeDiff: path, pendingDiff: path })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingDiff()
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!reviewScroll()) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (pendingDiff() !== pending) return
|
||||
if (tree.pendingDiff !== pending) return
|
||||
if (count > 60) {
|
||||
setPendingDiff(undefined)
|
||||
setTree("pendingDiff", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const root = reviewScroll()
|
||||
const root = tree.reviewScroll
|
||||
if (!root) {
|
||||
requestAnimationFrame(() => attempt(count + 1))
|
||||
return
|
||||
@@ -1267,7 +1614,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
if (Math.abs(root.scrollTop - top) <= 1) {
|
||||
setPendingDiff(undefined)
|
||||
setTree("pendingDiff", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1310,13 +1657,17 @@ export default function Page() {
|
||||
void sync.session.diff(id)
|
||||
})
|
||||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk.directory
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
fileTreeTab()
|
||||
void file.tree.list("")
|
||||
const refresh = treeDir !== dir
|
||||
treeDir = dir
|
||||
void (refresh ? file.tree.refresh("") : file.tree.list(""))
|
||||
})
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
@@ -1329,10 +1680,40 @@ export default function Page() {
|
||||
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
|
||||
}
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | undefined
|
||||
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
const overflow = max > 1
|
||||
const bottom = !overflow || el.scrollTop >= max - 2
|
||||
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||
setUi("scroll", { overflow, bottom })
|
||||
}
|
||||
|
||||
const scheduleScrollState = (el: HTMLDivElement) => {
|
||||
scrollStateTarget = el
|
||||
if (scrollStateFrame !== undefined) return
|
||||
|
||||
scrollStateFrame = requestAnimationFrame(() => {
|
||||
scrollStateFrame = undefined
|
||||
|
||||
const target = scrollStateTarget
|
||||
scrollStateTarget = undefined
|
||||
if (!target) return
|
||||
|
||||
updateScrollState(target)
|
||||
})
|
||||
}
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
}
|
||||
|
||||
// When the user returns to the bottom, treat the active message as "latest".
|
||||
@@ -1351,13 +1732,34 @@ export default function Page() {
|
||||
let scrollSpyFrame: number | undefined
|
||||
let scrollSpyTarget: HTMLDivElement | undefined
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||
scrollSpyFrame = undefined
|
||||
scrollSpyTarget = undefined
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const anchor = (id: string) => `message-${id}`
|
||||
|
||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||
scroller = el
|
||||
autoScroll.scrollRef(el)
|
||||
if (el) scheduleScrollState(el)
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => content,
|
||||
() => {
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
},
|
||||
)
|
||||
|
||||
const turnInit = 20
|
||||
const turnBatch = 20
|
||||
let turnHandle: number | undefined
|
||||
@@ -1458,6 +1860,8 @@ export default function Page() {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
|
||||
})
|
||||
}
|
||||
|
||||
if (el) scheduleScrollState(el)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1465,20 +1869,14 @@ export default function Page() {
|
||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const raw = sessionStorage.getItem("opencode.pendingMessage")
|
||||
if (!raw) return
|
||||
const parts = raw.split("|")
|
||||
const pendingSessionID = parts[0]
|
||||
const messageID = parts[1]
|
||||
if (!pendingSessionID || !messageID) return
|
||||
if (pendingSessionID !== sessionID) return
|
||||
|
||||
sessionStorage.removeItem("opencode.pendingMessage")
|
||||
setUi("pendingMessage", messageID)
|
||||
})
|
||||
createEffect(
|
||||
on(sessionKey, (key) => {
|
||||
if (!params.id) return
|
||||
const messageID = layout.pendingMessage.consume(key)
|
||||
if (!messageID) return
|
||||
setUi("pendingMessage", messageID)
|
||||
}),
|
||||
)
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
const root = scroller
|
||||
@@ -1544,6 +1942,9 @@ export default function Page() {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
autoScroll.forceScrollToBottom()
|
||||
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1569,6 +1970,9 @@ export default function Page() {
|
||||
}
|
||||
|
||||
autoScroll.forceScrollToBottom()
|
||||
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
}
|
||||
|
||||
const closestMessage = (node: Element | null): HTMLElement | null => {
|
||||
@@ -1692,7 +2096,7 @@ export default function Page() {
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
handoff.prompt = previewPrompt()
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -1712,26 +2116,29 @@ export default function Page() {
|
||||
return language.t("terminal.title")
|
||||
}
|
||||
|
||||
handoff.terminals = terminal.all().map(label)
|
||||
touch(handoff.terminal, params.dir!, terminal.all().map(label))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
handoff.files = Object.fromEntries(
|
||||
tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return []
|
||||
return [[path, file.selectedLines(path) ?? null] as const]
|
||||
}),
|
||||
)
|
||||
setSessionHandoff(sessionKey(), {
|
||||
files: Object.fromEntries(
|
||||
tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return []
|
||||
return [[path, file.selectedLines(path) ?? null] as const]
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelTurnBackfill()
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -1771,7 +2178,7 @@ export default function Page() {
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||
"flex-1 pt-6 md:pt-3": true,
|
||||
"flex-1 pt-2 md:pt-3": true,
|
||||
"md:flex-none": layout.fileTree.opened(),
|
||||
}}
|
||||
style={{
|
||||
@@ -1788,6 +2195,31 @@ export default function Page() {
|
||||
fallback={
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={(path) => {
|
||||
showAllFiles()
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
classes={{
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
@@ -1798,10 +2230,11 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
focusedFile={activeDiff()}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
@@ -1836,8 +2269,9 @@ export default function Page() {
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100": autoScroll.userScrolled(),
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !autoScroll.userScrolled(),
|
||||
"opacity-100 translate-y-0 scale-100": ui.scroll.overflow && !ui.scroll.bottom,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||
!ui.scroll.overflow || ui.scroll.bottom,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -1935,6 +2369,7 @@ export default function Page() {
|
||||
markScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
scheduleScrollState(e.currentTarget)
|
||||
if (!hasScrollGesture()) return
|
||||
autoScroll.handleScroll()
|
||||
markScrollGesture(e.currentTarget)
|
||||
@@ -1954,27 +2389,121 @@ export default function Page() {
|
||||
centered(),
|
||||
}}
|
||||
>
|
||||
<div class="h-10 flex items-center gap-1">
|
||||
<Show when={info()?.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||
}}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={info()?.title}>
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
<div class="h-10 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={info()?.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||
}}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={info()?.title || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-16-medium text-text-strong truncate min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{info()?.title}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-16-medium text-text-strong grow-1 min-w-0"
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={() => closeTitleEditor()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={params.id}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center">
|
||||
<DropdownMenu
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<Tooltip value={language.t("common.moreOptions")} placement="top">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingRename: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("common.rename")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("common.archive")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("common.delete")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
ref={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||
classList={{
|
||||
@@ -2147,7 +2676,7 @@ export default function Page() {
|
||||
when={prompt.ready()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
|
||||
{handoff.prompt || language.t("prompt.loading")}
|
||||
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -2157,7 +2686,10 @@ export default function Page() {
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={resumeScroll}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -2398,7 +2930,7 @@ export default function Page() {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (file.ready()) return file.selectedLines(p) ?? null
|
||||
return handoff.files[p] ?? null
|
||||
return handoff.session.get(sessionKey())?.files[p] ?? null
|
||||
})
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
@@ -2892,7 +3424,7 @@ export default function Page() {
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={activeDiff()}
|
||||
active={tree.activeDiff}
|
||||
onFileClick={(node) => focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
@@ -2952,7 +3484,7 @@ export default function Page() {
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff.terminals}>
|
||||
<For each={handoff.terminal.get(params.dir!) ?? []}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
|
||||
138
packages/app/src/utils/aim.ts
Normal file
138
packages/app/src/utils/aim.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
export function createAim(props: {
|
||||
enabled: () => boolean
|
||||
active: () => string | undefined
|
||||
el: () => HTMLElement | undefined
|
||||
onActivate: (id: string) => void
|
||||
delay?: number
|
||||
max?: number
|
||||
tolerance?: number
|
||||
edge?: number
|
||||
}) {
|
||||
const state = {
|
||||
locs: [] as Point[],
|
||||
timer: undefined as number | undefined,
|
||||
pending: undefined as string | undefined,
|
||||
over: undefined as string | undefined,
|
||||
last: undefined as Point | undefined,
|
||||
}
|
||||
|
||||
const delay = props.delay ?? 250
|
||||
const max = props.max ?? 4
|
||||
const tolerance = props.tolerance ?? 80
|
||||
const edge = props.edge ?? 18
|
||||
|
||||
const cancel = () => {
|
||||
if (state.timer !== undefined) clearTimeout(state.timer)
|
||||
state.timer = undefined
|
||||
state.pending = undefined
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
cancel()
|
||||
state.over = undefined
|
||||
state.last = undefined
|
||||
state.locs.length = 0
|
||||
}
|
||||
|
||||
const move = (event: MouseEvent) => {
|
||||
if (!props.enabled()) return
|
||||
const el = props.el()
|
||||
if (!el) return
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
|
||||
|
||||
state.locs.push({ x, y })
|
||||
if (state.locs.length > max) state.locs.shift()
|
||||
}
|
||||
|
||||
const wait = () => {
|
||||
if (!props.enabled()) return 0
|
||||
if (!props.active()) return 0
|
||||
|
||||
const el = props.el()
|
||||
if (!el) return 0
|
||||
if (state.locs.length < 2) return 0
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const loc = state.locs[state.locs.length - 1]
|
||||
if (!loc) return 0
|
||||
|
||||
const prev = state.locs[0] ?? loc
|
||||
if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
|
||||
if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
|
||||
|
||||
if (rect.right - loc.x <= edge) {
|
||||
state.last = loc
|
||||
return delay
|
||||
}
|
||||
|
||||
const upper = { x: rect.right, y: rect.top - tolerance }
|
||||
const lower = { x: rect.right, y: rect.bottom + tolerance }
|
||||
const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
|
||||
|
||||
const decreasing = slope(loc, upper)
|
||||
const increasing = slope(loc, lower)
|
||||
const prevDecreasing = slope(prev, upper)
|
||||
const prevIncreasing = slope(prev, lower)
|
||||
|
||||
if (decreasing < prevDecreasing && increasing > prevIncreasing) {
|
||||
state.last = loc
|
||||
return delay
|
||||
}
|
||||
|
||||
state.last = undefined
|
||||
return 0
|
||||
}
|
||||
|
||||
const activate = (id: string) => {
|
||||
cancel()
|
||||
props.onActivate(id)
|
||||
}
|
||||
|
||||
const request = (id: string) => {
|
||||
if (!id) return
|
||||
if (props.active() === id) return
|
||||
|
||||
if (!props.active()) {
|
||||
activate(id)
|
||||
return
|
||||
}
|
||||
|
||||
const ms = wait()
|
||||
if (ms === 0) {
|
||||
activate(id)
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
state.pending = id
|
||||
state.timer = window.setTimeout(() => {
|
||||
state.timer = undefined
|
||||
if (state.pending !== id) return
|
||||
state.pending = undefined
|
||||
if (!props.enabled()) return
|
||||
if (!props.active()) return
|
||||
if (state.over !== id) return
|
||||
props.onActivate(id)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
const enter = (id: string, event: MouseEvent) => {
|
||||
if (!props.enabled()) return
|
||||
state.over = id
|
||||
move(event)
|
||||
request(id)
|
||||
}
|
||||
|
||||
const leave = (id: string) => {
|
||||
if (state.over === id) state.over = undefined
|
||||
if (state.pending === id) cancel()
|
||||
}
|
||||
|
||||
return { move, enter, leave, activate, request, cancel, reset }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
|
||||
"externalBin": ["sidecars/opencode-cli"],
|
||||
"linux": {
|
||||
"rpm": {
|
||||
"compression": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "./entitlements.plist"
|
||||
},
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
"files": {
|
||||
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
|
||||
}
|
||||
},
|
||||
"rpm": {
|
||||
"compression": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.49"
|
||||
version = "1.1.51"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -94,7 +94,7 @@
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
|
||||
@@ -95,73 +95,7 @@ if (!Script.preview) {
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
// Source-based PKGBUILD for opencode
|
||||
const sourcePkgbuild = [
|
||||
"# Maintainer: dax",
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode'",
|
||||
`pkgver=${pkgver}`,
|
||||
`_subver=${_subver}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
"url='https://github.com/anomalyco/opencode'",
|
||||
"arch=('aarch64' 'x86_64')",
|
||||
"license=('MIT')",
|
||||
"provides=('opencode')",
|
||||
"conflicts=('opencode-bin')",
|
||||
"depends=('ripgrep')",
|
||||
"makedepends=('git' 'bun' 'go')",
|
||||
"",
|
||||
`source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
|
||||
`sha256sums=('SKIP')`,
|
||||
"",
|
||||
"build() {",
|
||||
` cd "opencode-\${pkgver}"`,
|
||||
` bun install`,
|
||||
" cd ./packages/opencode",
|
||||
` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`,
|
||||
"}",
|
||||
"",
|
||||
"package() {",
|
||||
` cd "opencode-\${pkgver}/packages/opencode"`,
|
||||
' mkdir -p "${pkgdir}/usr/bin"',
|
||||
' target_arch="x64"',
|
||||
' case "$CARCH" in',
|
||||
' x86_64) target_arch="x64" ;;',
|
||||
' aarch64) target_arch="arm64" ;;',
|
||||
' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;',
|
||||
" esac",
|
||||
' libc=""',
|
||||
" if command -v ldd >/dev/null 2>&1; then",
|
||||
" if ldd --version 2>&1 | grep -qi musl; then",
|
||||
' libc="-musl"',
|
||||
" fi",
|
||||
" fi",
|
||||
' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then',
|
||||
' libc="-musl"',
|
||||
" fi",
|
||||
' base=""',
|
||||
' if [ "$target_arch" = "x64" ]; then',
|
||||
" if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then",
|
||||
' base="-baseline"',
|
||||
" fi",
|
||||
" fi",
|
||||
' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"',
|
||||
' if [ ! -f "$bin" ]; then',
|
||||
' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2',
|
||||
" return 1",
|
||||
" fi",
|
||||
' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"',
|
||||
"}",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
for (const [pkg, pkgbuild] of [
|
||||
["opencode-bin", binaryPkgbuild],
|
||||
["opencode", sourcePkgbuild],
|
||||
]) {
|
||||
for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await $`rm -rf ./dist/aur-${pkg}`
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import { readableStreamToText } from "bun"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
@@ -86,20 +87,13 @@ export namespace BunProc {
|
||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
||||
}
|
||||
|
||||
const proxied = !!(
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.https_proxy
|
||||
)
|
||||
|
||||
// Build command arguments
|
||||
const args = [
|
||||
"add",
|
||||
"--force",
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied ? ["--no-cache"] : []),
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
"--cwd",
|
||||
Global.Path.cache,
|
||||
pkg + "@" + version,
|
||||
|
||||
@@ -187,7 +187,6 @@ function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
Clipboard.setRenderer(renderer)
|
||||
renderer.disableStdoutInterception()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { $ } from "bun"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { platform, release } from "os"
|
||||
import clipboardy from "clipboardy"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
const rendererRef = { current: undefined as CliRenderer | undefined }
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
* This allows clipboard operations to work over SSH by having
|
||||
* the terminal emulator handle the clipboard locally.
|
||||
*/
|
||||
function writeOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const passthrough = process.env["TMUX"] || process.env["STY"]
|
||||
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
process.stdout.write(sequence)
|
||||
}
|
||||
|
||||
export namespace Clipboard {
|
||||
export interface Content {
|
||||
@@ -14,10 +25,6 @@ export namespace Clipboard {
|
||||
mime: string
|
||||
}
|
||||
|
||||
export function setRenderer(renderer: CliRenderer | undefined): void {
|
||||
rendererRef.current = renderer
|
||||
}
|
||||
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
@@ -146,12 +153,7 @@ export namespace Clipboard {
|
||||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
const renderer = rendererRef.current
|
||||
if (renderer) {
|
||||
// Try OSC52 but don't early return - always fall back to native method
|
||||
// OSC52 may report success but not actually work in all terminals
|
||||
renderer.copyToClipboardOSC52(text)
|
||||
}
|
||||
writeOsc52(text)
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -247,13 +248,29 @@ export namespace Config {
|
||||
const hasGitIgnore = await Bun.file(gitignore).exists()
|
||||
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
|
||||
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
|
||||
cwd: dir,
|
||||
}).catch(() => {})
|
||||
await BunProc.run(
|
||||
[
|
||||
"add",
|
||||
`@opencode-ai/plugin@${targetVersion}`,
|
||||
"--exact",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
],
|
||||
{
|
||||
cwd: dir,
|
||||
},
|
||||
).catch(() => {})
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
|
||||
await BunProc.run(
|
||||
[
|
||||
"install",
|
||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||
...(proxied() ? ["--no-cache"] : []),
|
||||
],
|
||||
{ cwd: dir },
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
async function needsInstall(dir: string) {
|
||||
|
||||
@@ -239,7 +239,9 @@ export namespace Provider {
|
||||
options: providerOptions,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
// Skip region prefixing if model already has a cross-region inference profile prefix
|
||||
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
|
||||
// Models from models.dev may already include prefixes like us., eu., global., etc.
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
@@ -455,6 +457,29 @@ export namespace Provider {
|
||||
},
|
||||
}
|
||||
},
|
||||
"cloudflare-workers-ai": async (input) => {
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
if (!accountId) return { autoload: false }
|
||||
|
||||
const apiKey = await iife(async () => {
|
||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
||||
if (envToken) return envToken
|
||||
const auth = await Auth.get(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
})
|
||||
|
||||
return {
|
||||
autoload: !!apiKey,
|
||||
options: {
|
||||
apiKey,
|
||||
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
|
||||
},
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
return sdk.languageModel(modelID)
|
||||
},
|
||||
}
|
||||
},
|
||||
"cloudflare-ai-gateway": async (input) => {
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||
@@ -985,12 +1010,6 @@ export namespace Provider {
|
||||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
|
||||
// Merge configured headers into request headers
|
||||
opts.headers = {
|
||||
...(typeof opts.headers === "object" ? opts.headers : {}),
|
||||
...options["headers"],
|
||||
}
|
||||
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null) {
|
||||
const signals: AbortSignal[] = []
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
|
||||
@@ -630,6 +630,18 @@ export namespace ProviderTransform {
|
||||
}
|
||||
}
|
||||
|
||||
// Enable thinking by default for kimi-k2.5/k2p5 models using anthropic SDK
|
||||
const modelId = input.model.api.id.toLowerCase()
|
||||
if (
|
||||
(input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") &&
|
||||
(modelId.includes("k2p5") || modelId.includes("kimi-k2.5") || modelId.includes("kimi-k2p5"))
|
||||
) {
|
||||
result["thinking"] = {
|
||||
type: "enabled",
|
||||
budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)),
|
||||
}
|
||||
}
|
||||
|
||||
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||
result["reasoningEffort"] = "medium"
|
||||
|
||||
@@ -17,13 +17,14 @@ const FILES = [
|
||||
]
|
||||
|
||||
function globalFiles() {
|
||||
const files = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
const files = []
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
files.push(path.join(Global.Path.config, "AGENTS.md"))
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
|
||||
@@ -968,9 +968,11 @@ export namespace SessionPrompt {
|
||||
// have to normalize, symbol search returns absolute paths
|
||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||
const filepath = fileURLToPath(part.url)
|
||||
const stat = await Bun.file(filepath).stat()
|
||||
const stat = await Bun.file(filepath)
|
||||
.stat()
|
||||
.catch(() => undefined)
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (stat?.isDirectory()) {
|
||||
part.mime = "application/x-directory"
|
||||
}
|
||||
|
||||
@@ -989,7 +991,7 @@ export namespace SessionPrompt {
|
||||
// workspace/symbol searches, so we'll try to find the
|
||||
// symbol in the document to get the full range
|
||||
if (start === end) {
|
||||
const symbols = await LSP.documentSymbol(filePathURI)
|
||||
const symbols = await LSP.documentSymbol(filePathURI).catch(() => [])
|
||||
for (const symbol of symbols) {
|
||||
let range: LSP.Range | undefined
|
||||
if ("range" in symbol) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
@@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||
},
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(codeRequest),
|
||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Code search request timed out")
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||
@@ -36,8 +37,7 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
|
||||
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
|
||||
|
||||
// Build Accept header based on requested format with q parameters for fallbacks
|
||||
let acceptHeader = "*/*"
|
||||
@@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
acceptHeader =
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
}
|
||||
|
||||
const signal = AbortSignal.any([controller.signal, ctx.abort])
|
||||
const headers = {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
@@ -72,7 +70,7 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
|
||||
: initial
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status code: ${response.status}`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
@@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
},
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 25000)
|
||||
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(searchRequest),
|
||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Search request timed out")
|
||||
|
||||
35
packages/opencode/src/util/abort.ts
Normal file
35
packages/opencode/src/util/abort.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Creates an AbortController that automatically aborts after a timeout.
|
||||
*
|
||||
* Uses bind() instead of arrow functions to avoid capturing the surrounding
|
||||
* scope in closures. Arrow functions like `() => controller.abort()` capture
|
||||
* request bodies and other large objects, preventing GC for the timer lifetime.
|
||||
*
|
||||
* @param ms Timeout in milliseconds
|
||||
* @returns Object with controller, signal, and clearTimeout function
|
||||
*/
|
||||
export function abortAfter(ms: number) {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(controller.abort.bind(controller), ms)
|
||||
return {
|
||||
controller,
|
||||
signal: controller.signal,
|
||||
clearTimeout: () => globalThis.clearTimeout(id),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple AbortSignals with a timeout.
|
||||
*
|
||||
* @param ms Timeout in milliseconds
|
||||
* @param signals Additional signals to combine
|
||||
* @returns Combined signal that aborts on timeout or when any input signal aborts
|
||||
*/
|
||||
export function abortAfterAny(ms: number, ...signals: AbortSignal[]) {
|
||||
const timeout = abortAfter(ms)
|
||||
const signal = AbortSignal.any([timeout.signal, ...signals])
|
||||
return {
|
||||
signal,
|
||||
clearTimeout: timeout.clearTimeout,
|
||||
}
|
||||
}
|
||||
3
packages/opencode/src/util/proxied.ts
Normal file
3
packages/opencode/src/util/proxied.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function proxied() {
|
||||
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
}
|
||||
136
packages/opencode/test/memory/abort-leak.test.ts
Normal file
136
packages/opencode/test/memory/abort-leak.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test",
|
||||
messageID: "",
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024
|
||||
const ITERATIONS = 50
|
||||
|
||||
const getHeapMB = () => {
|
||||
Bun.gc(true)
|
||||
return process.memoryUsage().heapUsed / MB
|
||||
}
|
||||
|
||||
describe("memory: abort controller leak", () => {
|
||||
test("webfetch does not leak memory over many invocations", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const tool = await WebFetchTool.init()
|
||||
|
||||
// Warm up
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
|
||||
Bun.gc(true)
|
||||
const baseline = getHeapMB()
|
||||
|
||||
// Run many fetches
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
const after = getHeapMB()
|
||||
const growth = after - baseline
|
||||
|
||||
console.log(`Baseline: ${baseline.toFixed(2)} MB`)
|
||||
console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
|
||||
console.log(`Growth: ${growth.toFixed(2)} MB`)
|
||||
|
||||
// Memory growth should be minimal - less than 1MB per 10 requests
|
||||
// With the old closure pattern, this would grow ~0.5MB per request
|
||||
expect(growth).toBeLessThan(ITERATIONS / 10)
|
||||
},
|
||||
})
|
||||
}, 60000)
|
||||
|
||||
test("compare closure vs bind pattern directly", async () => {
|
||||
const ITERATIONS = 500
|
||||
|
||||
// Test OLD pattern: arrow function closure
|
||||
// Store closures in a map keyed by content to force retention
|
||||
const closureMap = new Map<string, () => void>()
|
||||
const timers: Timer[] = []
|
||||
const controllers: AbortController[] = []
|
||||
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const baseline = getHeapMB()
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
// Simulate large response body like webfetch would have
|
||||
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
|
||||
const controller = new AbortController()
|
||||
controllers.push(controller)
|
||||
|
||||
// OLD pattern - closure captures `content`
|
||||
const handler = () => {
|
||||
// Actually use content so it can't be optimized away
|
||||
if (content.length > 1000000000) controller.abort()
|
||||
}
|
||||
closureMap.set(content, handler)
|
||||
const timeoutId = setTimeout(handler, 30000)
|
||||
timers.push(timeoutId)
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const after = getHeapMB()
|
||||
const oldGrowth = after - baseline
|
||||
|
||||
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
|
||||
|
||||
// Cleanup after measuring
|
||||
timers.forEach(clearTimeout)
|
||||
controllers.forEach((c) => c.abort())
|
||||
closureMap.clear()
|
||||
|
||||
// Test NEW pattern: bind
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const baseline2 = getHeapMB()
|
||||
const handlers2: (() => void)[] = []
|
||||
const timers2: Timer[] = []
|
||||
const controllers2: AbortController[] = []
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
|
||||
const controller = new AbortController()
|
||||
controllers2.push(controller)
|
||||
|
||||
// NEW pattern - bind doesn't capture surrounding scope
|
||||
const handler = controller.abort.bind(controller)
|
||||
handlers2.push(handler)
|
||||
const timeoutId = setTimeout(handler, 30000)
|
||||
timers2.push(timeoutId)
|
||||
}
|
||||
|
||||
Bun.gc(true)
|
||||
Bun.sleepSync(100)
|
||||
const after2 = getHeapMB()
|
||||
const newGrowth = after2 - baseline2
|
||||
|
||||
// Cleanup after measuring
|
||||
timers2.forEach(clearTimeout)
|
||||
controllers2.forEach((c) => c.abort())
|
||||
handlers2.length = 0
|
||||
|
||||
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
|
||||
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
|
||||
|
||||
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import { test, expect, mock, describe } from "bun:test"
|
||||
import path from "path"
|
||||
import { unlink } from "fs/promises"
|
||||
|
||||
@@ -266,3 +266,214 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for cross-region inference profile prefix handling
|
||||
// Models from models.dev may come with prefixes already (e.g., us., eu., global.)
|
||||
// These should NOT be double-prefixed when passed to the SDK
|
||||
|
||||
test("Bedrock: model with us. prefix should not be double-prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
models: {
|
||||
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5 (US)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
// The model should exist with the us. prefix
|
||||
expect(providers["amazon-bedrock"].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: model with global. prefix should not be prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
models: {
|
||||
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5 (Global)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
expect(providers["amazon-bedrock"].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: model with eu. prefix should not be double-prefixed", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
models: {
|
||||
"eu.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5 (EU)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
expect(providers["amazon-bedrock"].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: model without prefix in US region should get us. prefix added", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
models: {
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||
name: "Claude Opus 4.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
// Non-prefixed model should still be registered
|
||||
expect(providers["amazon-bedrock"].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Direct unit tests for cross-region inference profile prefix handling
|
||||
// These test the prefix detection logic used in getModel
|
||||
|
||||
describe("Bedrock cross-region prefix detection", () => {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
|
||||
test("should detect global. prefix", () => {
|
||||
const modelID = "global.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect us. prefix", () => {
|
||||
const modelID = "us.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect eu. prefix", () => {
|
||||
const modelID = "eu.anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect jp. prefix", () => {
|
||||
const modelID = "jp.anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect apac. prefix", () => {
|
||||
const modelID = "apac.anthropic.claude-sonnet-4-20250514-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect au. prefix", () => {
|
||||
const modelID = "au.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(true)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for non-prefixed model", () => {
|
||||
const modelID = "anthropic.claude-opus-4-5-20251101-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for amazon nova models", () => {
|
||||
const modelID = "amazon.nova-pro-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for cohere models", () => {
|
||||
const modelID = "cohere.command-r-plus-v1:0"
|
||||
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
|
||||
expect(hasPrefix).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { InstructionPrompt } from "../../src/session/instruction"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Global } from "../../src/global"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("InstructionPrompt.resolve", () => {
|
||||
@@ -68,3 +69,102 @@ describe("InstructionPrompt.resolve", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
} else {
|
||||
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
|
||||
}
|
||||
})
|
||||
|
||||
test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => {
|
||||
await using profileTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
|
||||
},
|
||||
})
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: async () => {
|
||||
const paths = await InstructionPrompt.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => {
|
||||
await using profileTmp = await tmpdir()
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: async () => {
|
||||
const paths = await InstructionPrompt.systemPaths()
|
||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
|
||||
test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => {
|
||||
await using globalTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
|
||||
},
|
||||
})
|
||||
await using projectTmp = await tmpdir()
|
||||
|
||||
delete process.env["OPENCODE_CONFIG_DIR"]
|
||||
const originalGlobalConfig = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: projectTmp.path,
|
||||
fn: async () => {
|
||||
const paths = await InstructionPrompt.systemPaths()
|
||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
53
packages/opencode/test/session/prompt-missing-file.test.ts
Normal file
53
packages/opencode/test/session/prompt-missing-file.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("session.prompt missing file", () => {
|
||||
test("does not fail the prompt when a file part is missing", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
build: {
|
||||
model: "openai/gpt-5.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
const missing = path.join(tmp.path, "does-not-exist.ts")
|
||||
const msg = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [
|
||||
{ type: "text", text: "please review @does-not-exist.ts" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url: `file://${missing}`,
|
||||
filename: "does-not-exist.ts",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (msg.info.role !== "user") throw new Error("expected user message")
|
||||
|
||||
const hasFailure = msg.parts.some(
|
||||
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
|
||||
)
|
||||
expect(hasFailure).toBe(true)
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -70,8 +70,8 @@ export const Script = {
|
||||
get preview() {
|
||||
return IS_PREVIEW
|
||||
},
|
||||
get release() {
|
||||
return env.OPENCODE_RELEASE
|
||||
get release(): boolean {
|
||||
return !!env.OPENCODE_RELEASE
|
||||
},
|
||||
get team() {
|
||||
return team
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [data-slot="session-review-container"] { */
|
||||
/* height: 100%; */
|
||||
/* } */
|
||||
[data-slot="session-review-container"] {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
[data-slot="session-review-header"] {
|
||||
position: sticky;
|
||||
@@ -44,10 +44,11 @@
|
||||
|
||||
[data-component="sticky-accordion-header"] {
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
&[data-expanded]::before {
|
||||
top: -40px;
|
||||
}
|
||||
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||
top: -40px;
|
||||
}
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
@@ -79,6 +80,7 @@
|
||||
|
||||
[data-slot="session-review-accordion-content"] {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -211,7 +213,9 @@
|
||||
[data-slot="session-review-diff-wrapper"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
--line-comment-z: 5;
|
||||
--line-comment-popover-z: 30;
|
||||
--line-comment-open-z: 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export type SessionReviewLineComment = {
|
||||
export type SessionReviewFocus = { file: string; id: string }
|
||||
|
||||
export interface SessionReviewProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
split?: boolean
|
||||
diffStyle?: SessionReviewDiffStyle
|
||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||
@@ -184,6 +186,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
const open = () => props.open ?? store.open
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => props.diffs.length > 0
|
||||
|
||||
const handleChange = (open: string[]) => {
|
||||
props.onOpenChange?.(open)
|
||||
@@ -287,9 +290,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
[props.classes?.header ?? ""]: !!props.classes?.header,
|
||||
}}
|
||||
>
|
||||
<div data-slot="session-review-title">{i18n.t("ui.sessionReview.title")}</div>
|
||||
<div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
|
||||
<div data-slot="session-review-actions">
|
||||
<Show when={props.onDiffStyleChange}>
|
||||
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
||||
<RadioGroup
|
||||
options={["unified", "split"] as const}
|
||||
current={diffStyle()}
|
||||
@@ -300,12 +303,14 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
|
||||
/>
|
||||
</Show>
|
||||
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
|
||||
<Switch>
|
||||
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
|
||||
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
<Show when={hasDiffs()}>
|
||||
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
|
||||
<Switch>
|
||||
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
|
||||
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
</Show>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,322 +320,324 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
[props.classes?.container ?? ""]: !!props.classes?.container,
|
||||
}}
|
||||
>
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={props.diffs}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
<Show when={hasDiffs()} fallback={props.empty}>
|
||||
<Accordion multiple value={open()} onChange={handleChange}>
|
||||
<For each={props.diffs}>
|
||||
{(diff) => {
|
||||
let wrapper: HTMLDivElement | undefined
|
||||
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||
|
||||
const isAdded = () => beforeText().length === 0 && afterText().length > 0
|
||||
const isDeleted = () => afterText().length === 0 && beforeText().length > 0
|
||||
const isImage = () => isImageFile(diff.file)
|
||||
const isAudio = () => isAudioFile(diff.file)
|
||||
const isAdded = () => beforeText().length === 0 && afterText().length > 0
|
||||
const isDeleted = () => afterText().length === 0 && beforeText().length > 0
|
||||
const isImage = () => isImageFile(diff.file)
|
||||
const isAudio = () => isAudioFile(diff.file)
|
||||
|
||||
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
||||
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
||||
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
|
||||
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
||||
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
||||
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
||||
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
|
||||
const draftRange = createMemo(() => {
|
||||
const current = commenting()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
const draftRange = createMemo(() => {
|
||||
const current = commenting()
|
||||
if (!current || current.file !== diff.file) return null
|
||||
return current.range
|
||||
})
|
||||
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
|
||||
const getRoot = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
const getRoot = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
const updateAnchors = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const item of comments()) {
|
||||
const marker = findMarker(root, item.selection)
|
||||
if (!marker) continue
|
||||
next[item.id] = markerTop(el, marker)
|
||||
}
|
||||
setPositions(next)
|
||||
|
||||
const range = draftRange()
|
||||
if (!range) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
const updateAnchors = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const item of comments()) {
|
||||
const marker = findMarker(root, item.selection)
|
||||
if (!marker) continue
|
||||
next[item.id] = markerTop(el, marker)
|
||||
}
|
||||
setPositions(next)
|
||||
|
||||
const range = draftRange()
|
||||
if (!range) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
}
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
}
|
||||
const scheduleAnchors = () => {
|
||||
requestAnimationFrame(updateAnchors)
|
||||
}
|
||||
|
||||
const scheduleAnchors = () => {
|
||||
requestAnimationFrame(updateAnchors)
|
||||
}
|
||||
createEffect(() => {
|
||||
comments()
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
comments()
|
||||
scheduleAnchors()
|
||||
})
|
||||
createEffect(() => {
|
||||
const range = draftRange()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const range = draftRange()
|
||||
if (!range) return
|
||||
setDraft("")
|
||||
scheduleAnchors()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isImage()) return
|
||||
if (imageSrc()) return
|
||||
if (imageStatus() !== "idle") return
|
||||
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isImage()) return
|
||||
if (imageSrc()) return
|
||||
if (imageStatus() !== "idle") return
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
|
||||
setImageStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setImageStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setImageStatus("error")
|
||||
return
|
||||
}
|
||||
setImageSrc(src)
|
||||
setImageStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setImageStatus("error")
|
||||
return
|
||||
}
|
||||
setImageSrc(src)
|
||||
setImageStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setImageStatus("error")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isAudio()) return
|
||||
if (audioSrc()) return
|
||||
if (audioStatus() !== "idle") return
|
||||
createEffect(() => {
|
||||
if (!open().includes(diff.file)) return
|
||||
if (!isAudio()) return
|
||||
if (audioSrc()) return
|
||||
if (audioStatus() !== "idle") return
|
||||
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
const reader = props.readFile
|
||||
if (!reader) return
|
||||
|
||||
setAudioStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setAudioStatus("loading")
|
||||
reader(diff.file)
|
||||
.then((result) => {
|
||||
const src = dataUrl(result)
|
||||
if (!src) {
|
||||
setAudioStatus("error")
|
||||
return
|
||||
}
|
||||
setAudioMime(normalizeMimeType(result?.mimeType))
|
||||
setAudioSrc(src)
|
||||
setAudioStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setAudioStatus("error")
|
||||
return
|
||||
}
|
||||
setAudioMime(normalizeMimeType(result?.mimeType))
|
||||
setAudioSrc(src)
|
||||
setAudioStatus("idle")
|
||||
})
|
||||
.catch(() => {
|
||||
setAudioStatus("error")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
|
||||
if (!range) {
|
||||
setSelection(null)
|
||||
return
|
||||
if (!range) {
|
||||
setSelection(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ file: diff.file, range })
|
||||
}
|
||||
|
||||
setSelection({ file: diff.file, range })
|
||||
}
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
setSelection({ file: diff.file, range })
|
||||
setCommenting({ file: diff.file, range })
|
||||
}
|
||||
|
||||
setSelection({ file: diff.file, range })
|
||||
setCommenting({ file: diff.file, range })
|
||||
}
|
||||
const openComment = (comment: SessionReviewComment) => {
|
||||
setOpened({ file: comment.file, id: comment.id })
|
||||
setSelection({ file: comment.file, range: comment.selection })
|
||||
}
|
||||
|
||||
const openComment = (comment: SessionReviewComment) => {
|
||||
setOpened({ file: comment.file, id: comment.id })
|
||||
setSelection({ file: comment.file, range: comment.selection })
|
||||
}
|
||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||
const current = opened()
|
||||
if (!current) return false
|
||||
return current.file === comment.file && current.id === comment.id
|
||||
}
|
||||
|
||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||
const current = opened()
|
||||
if (!current) return false
|
||||
return current.file === comment.file && current.id === comment.id
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion.Item
|
||||
value={diff.file}
|
||||
id={diffId(diff.file)}
|
||||
data-file={diff.file}
|
||||
data-slot="session-review-accordion-item"
|
||||
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div data-slot="session-review-file-info">
|
||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||
<Show when={props.onViewFile}>
|
||||
<button
|
||||
data-slot="session-review-view-button"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onViewFile?.(diff.file)
|
||||
}}
|
||||
>
|
||||
<Icon name="eye" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
return (
|
||||
<Accordion.Item
|
||||
value={diff.file}
|
||||
id={diffId(diff.file)}
|
||||
data-file={diff.file}
|
||||
data-slot="session-review-accordion-item"
|
||||
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
||||
>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-review-trigger-content">
|
||||
<div data-slot="session-review-file-info">
|
||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||
<div data-slot="session-review-file-name-container">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||
<Show when={props.onViewFile}>
|
||||
<button
|
||||
data-slot="session-review-view-button"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onViewFile?.(diff.file)
|
||||
}}
|
||||
>
|
||||
<Icon name="eye" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={isAdded()}>
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{i18n.t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={diff} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={isAdded()}>
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{i18n.t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={diff} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-review-accordion-content">
|
||||
<div
|
||||
data-slot="session-review-diff-wrapper"
|
||||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(diff.file, el)
|
||||
scheduleAnchors()
|
||||
}}
|
||||
>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
preloadedDiff={diff.preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-review-accordion-content">
|
||||
<div
|
||||
data-slot="session-review-diff-wrapper"
|
||||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(diff.file, el)
|
||||
scheduleAnchors()
|
||||
}}
|
||||
enableLineSelection={props.onLineComment != null}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionEnd={handleLineSelectionEnd}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: typeof diff.before === "string" ? diff.before : "",
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: typeof diff.after === "string" ? diff.after : "",
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
preloadedDiff={diff.preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
scheduleAnchors()
|
||||
}}
|
||||
enableLineSelection={props.onLineComment != null}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionEnd={handleLineSelectionEnd}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: typeof diff.before === "string" ? diff.before : "",
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: typeof diff.after === "string" ? diff.after : "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<For each={comments()}>
|
||||
{(comment) => (
|
||||
<LineComment
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
||||
onClick={() => {
|
||||
if (isCommentOpen(comment)) {
|
||||
setOpened(null)
|
||||
return
|
||||
}
|
||||
<For each={comments()}>
|
||||
{(comment) => (
|
||||
<LineComment
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
||||
onClick={() => {
|
||||
if (isCommentOpen(comment)) {
|
||||
setOpened(null)
|
||||
return
|
||||
}
|
||||
|
||||
openComment(comment)
|
||||
}}
|
||||
open={isCommentOpen(comment)}
|
||||
comment={comment.comment}
|
||||
selection={selectionLabel(comment.selection)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={draftRange()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={selectionLabel(range())}
|
||||
onInput={setDraft}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(comment) => {
|
||||
props.onLineComment?.({
|
||||
file: diff.file,
|
||||
selection: range(),
|
||||
comment,
|
||||
preview: selectionPreview(diff, range()),
|
||||
})
|
||||
setCommenting(null)
|
||||
openComment(comment)
|
||||
}}
|
||||
open={isCommentOpen(comment)}
|
||||
comment={comment.comment}
|
||||
selection={selectionLabel(comment.selection)}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={draftRange()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={selectionLabel(range())}
|
||||
onInput={setDraft}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(comment) => {
|
||||
props.onLineComment?.({
|
||||
file: diff.file,
|
||||
selection: range(),
|
||||
comment,
|
||||
preview: selectionPreview(diff, range()),
|
||||
})
|
||||
setCommenting(null)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -409,10 +409,11 @@
|
||||
|
||||
[data-component="sticky-accordion-header"] {
|
||||
top: var(--sticky-header-height, 0px);
|
||||
}
|
||||
|
||||
&[data-expanded]::before {
|
||||
top: calc(-1 * var(--sticky-header-height, 0px));
|
||||
}
|
||||
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||
top: calc(-1 * var(--sticky-header-height, 0px));
|
||||
}
|
||||
|
||||
[data-slot="session-turn-accordion-trigger-content"] {
|
||||
|
||||
@@ -8,25 +8,16 @@ import {
|
||||
TextPart,
|
||||
ToolPart,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Message, Part } from "./message-part"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Card } from "./card"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Button } from "./button"
|
||||
import { Spinner } from "./spinner"
|
||||
import { Tooltip } from "./tooltip"
|
||||
@@ -143,7 +134,6 @@ export function SessionTurn(
|
||||
) {
|
||||
const i18n = useI18n()
|
||||
const data = useData()
|
||||
const diffComponent = useDiffComponent()
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -153,7 +143,6 @@ export function SessionTurn(
|
||||
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
||||
const emptyQuestions: QuestionRequest[] = []
|
||||
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
||||
const emptyDiffs: FileDiff[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
|
||||
@@ -409,8 +398,7 @@ export function SessionTurn(
|
||||
|
||||
const response = createMemo(() => lastTextPart()?.text)
|
||||
const responsePartId = createMemo(() => lastTextPart()?.id)
|
||||
const messageDiffs = createMemo(() => message()?.summary?.diffs ?? emptyDiffs)
|
||||
const hasDiffs = createMemo(() => messageDiffs().length > 0)
|
||||
const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
|
||||
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
||||
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
@@ -476,28 +464,12 @@ export function SessionTurn(
|
||||
updateStickyHeight(sticky.getBoundingClientRect().height)
|
||||
})
|
||||
|
||||
const diffInit = 20
|
||||
const diffBatch = 20
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
retrySeconds: 0,
|
||||
diffsOpen: [] as string[],
|
||||
diffLimit: diffInit,
|
||||
status: rawStatus(),
|
||||
duration: duration(),
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => message()?.id,
|
||||
() => {
|
||||
setStore("diffsOpen", [])
|
||||
setStore("diffLimit", diffInit)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const r = retry()
|
||||
if (!r) {
|
||||
@@ -727,7 +699,7 @@ export function SessionTurn(
|
||||
<div class="sr-only" aria-live="polite">
|
||||
{!working() && response() ? response() : ""}
|
||||
</div>
|
||||
<Show when={!working() && (response() || hasDiffs())}>
|
||||
<Show when={!working() && response()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
|
||||
@@ -760,80 +732,6 @@ export function SessionTurn(
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Accordion
|
||||
data-slot="session-turn-accordion"
|
||||
multiple
|
||||
value={store.diffsOpen}
|
||||
onChange={(value) => {
|
||||
if (!Array.isArray(value)) return
|
||||
setStore("diffsOpen", value)
|
||||
}}
|
||||
>
|
||||
<For each={messageDiffs().slice(0, store.diffLimit)}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-accordion-trigger-content">
|
||||
<div data-slot="session-turn-file-info">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
data-slot="session-turn-file-icon"
|
||||
/>
|
||||
<div data-slot="session-turn-file-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-turn-accordion-actions">
|
||||
<DiffChanges changes={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content data-slot="session-turn-accordion-content">
|
||||
<Show when={store.diffsOpen.includes(diff.file!)}>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
before={{
|
||||
name: diff.file!,
|
||||
contents: diff.before!,
|
||||
}}
|
||||
after={{
|
||||
name: diff.file!,
|
||||
contents: diff.after!,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
<Show when={messageDiffs().length > store.diffLimit}>
|
||||
<Button
|
||||
data-slot="session-turn-accordion-more"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const total = messageDiffs().length
|
||||
setStore("diffLimit", (limit) => {
|
||||
const next = limit + diffBatch
|
||||
if (next > total) return total
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.sessionTurn.diff.showMore", {
|
||||
count: messageDiffs().length - store.diffLimit,
|
||||
})}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error() && !props.stepsExpanded}>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
[data-component="sticky-accordion-header"] {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
|
||||
&[data-expanded] {
|
||||
z-index: 10;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
z-index: -10;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--background-stronger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="sticky-accordion-header"][data-expanded],
|
||||
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"] {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||
content: "";
|
||||
z-index: -10;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--background-stronger);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "تغييرات الجلسة",
|
||||
"ui.sessionReview.title.lastTurn": "تغييرات آخر دور",
|
||||
"ui.sessionReview.diffStyle.unified": "موجد",
|
||||
"ui.sessionReview.diffStyle.split": "منقسم",
|
||||
"ui.sessionReview.expandAll": "توسيع الكل",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Alterações da sessão",
|
||||
"ui.sessionReview.title.lastTurn": "Alterações do último turno",
|
||||
"ui.sessionReview.diffStyle.unified": "Unificado",
|
||||
"ui.sessionReview.diffStyle.split": "Dividido",
|
||||
"ui.sessionReview.expandAll": "Expandir tudo",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Sessionsændringer",
|
||||
"ui.sessionReview.title.lastTurn": "Ændringer fra sidste tur",
|
||||
"ui.sessionReview.diffStyle.unified": "Samlet",
|
||||
"ui.sessionReview.diffStyle.split": "Opdelt",
|
||||
"ui.sessionReview.expandAll": "Udvid alle",
|
||||
|
||||
@@ -4,6 +4,7 @@ type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Sitzungsänderungen",
|
||||
"ui.sessionReview.title.lastTurn": "Änderungen der letzten Runde",
|
||||
"ui.sessionReview.diffStyle.unified": "Vereinheitlicht",
|
||||
"ui.sessionReview.diffStyle.split": "Geteilt",
|
||||
"ui.sessionReview.expandAll": "Alle erweitern",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Session changes",
|
||||
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||
"ui.sessionReview.diffStyle.split": "Split",
|
||||
"ui.sessionReview.expandAll": "Expand all",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Cambios de la sesión",
|
||||
"ui.sessionReview.title.lastTurn": "Cambios del último turno",
|
||||
"ui.sessionReview.diffStyle.unified": "Unificado",
|
||||
"ui.sessionReview.diffStyle.split": "Dividido",
|
||||
"ui.sessionReview.expandAll": "Expandir todo",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Modifications de la session",
|
||||
"ui.sessionReview.title.lastTurn": "Modifications du dernier tour",
|
||||
"ui.sessionReview.diffStyle.unified": "Unifié",
|
||||
"ui.sessionReview.diffStyle.split": "Divisé",
|
||||
"ui.sessionReview.expandAll": "Tout développer",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "セッションの変更",
|
||||
"ui.sessionReview.title.lastTurn": "前回ターンの変更",
|
||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||
"ui.sessionReview.diffStyle.split": "Split",
|
||||
"ui.sessionReview.expandAll": "すべて展開",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "세션 변경 사항",
|
||||
"ui.sessionReview.title.lastTurn": "마지막 턴 변경 사항",
|
||||
"ui.sessionReview.diffStyle.unified": "통합 보기",
|
||||
"ui.sessionReview.diffStyle.split": "분할 보기",
|
||||
"ui.sessionReview.expandAll": "모두 펼치기",
|
||||
|
||||
@@ -3,6 +3,7 @@ type Keys = keyof typeof en
|
||||
|
||||
export const dict: Record<Keys, string> = {
|
||||
"ui.sessionReview.title": "Sesjonsendringer",
|
||||
"ui.sessionReview.title.lastTurn": "Endringer i siste tur",
|
||||
"ui.sessionReview.diffStyle.unified": "Samlet",
|
||||
"ui.sessionReview.diffStyle.split": "Delt",
|
||||
"ui.sessionReview.expandAll": "Utvid alle",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Zmiany w sesji",
|
||||
"ui.sessionReview.title.lastTurn": "Zmiany z ostatniej tury",
|
||||
"ui.sessionReview.diffStyle.unified": "Ujednolicony",
|
||||
"ui.sessionReview.diffStyle.split": "Podzielony",
|
||||
"ui.sessionReview.expandAll": "Rozwiń wszystko",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "Изменения сессии",
|
||||
"ui.sessionReview.title.lastTurn": "Изменения последнего хода",
|
||||
"ui.sessionReview.diffStyle.unified": "Объединённый",
|
||||
"ui.sessionReview.diffStyle.split": "Разделённый",
|
||||
"ui.sessionReview.expandAll": "Развернуть всё",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "การเปลี่ยนแปลงเซสชัน",
|
||||
"ui.sessionReview.title.lastTurn": "การเปลี่ยนแปลงของเทิร์นล่าสุด",
|
||||
"ui.sessionReview.diffStyle.unified": "แบบรวม",
|
||||
"ui.sessionReview.diffStyle.split": "แบบแยก",
|
||||
"ui.sessionReview.expandAll": "ขยายทั้งหมด",
|
||||
|
||||
@@ -4,6 +4,7 @@ type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "会话变更",
|
||||
"ui.sessionReview.title.lastTurn": "上一轮变更",
|
||||
"ui.sessionReview.diffStyle.unified": "统一",
|
||||
"ui.sessionReview.diffStyle.split": "拆分",
|
||||
"ui.sessionReview.expandAll": "全部展开",
|
||||
|
||||
@@ -4,6 +4,7 @@ type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
"ui.sessionReview.title": "工作階段變更",
|
||||
"ui.sessionReview.title.lastTurn": "上一輪變更",
|
||||
"ui.sessionReview.diffStyle.unified": "整合",
|
||||
"ui.sessionReview.diffStyle.split": "拆分",
|
||||
"ui.sessionReview.expandAll": "全部展開",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -46,7 +46,7 @@ OpenCode comes with two built-in primary agents and two built-in subagents.
|
||||
|
||||
---
|
||||
|
||||
### Build
|
||||
### Use build
|
||||
|
||||
_Mode_: `primary`
|
||||
|
||||
@@ -54,7 +54,7 @@ Build is the **default** primary agent with all tools enabled. This is the stand
|
||||
|
||||
---
|
||||
|
||||
### Plan
|
||||
### Use plan
|
||||
|
||||
_Mode_: `primary`
|
||||
|
||||
@@ -68,7 +68,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or
|
||||
|
||||
---
|
||||
|
||||
### General
|
||||
### Use general
|
||||
|
||||
_Mode_: `subagent`
|
||||
|
||||
@@ -76,7 +76,7 @@ A general-purpose agent for researching complex questions and executing multi-st
|
||||
|
||||
---
|
||||
|
||||
### Explore
|
||||
### Use explore
|
||||
|
||||
_Mode_: `subagent`
|
||||
|
||||
@@ -84,6 +84,30 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w
|
||||
|
||||
---
|
||||
|
||||
### Use compaction
|
||||
|
||||
_Mode_: `primary`
|
||||
|
||||
Hidden system agent that compacts long context into a smaller summary. It runs automatically when needed and is not selectable in the UI.
|
||||
|
||||
---
|
||||
|
||||
### Use title
|
||||
|
||||
_Mode_: `primary`
|
||||
|
||||
Hidden system agent that generates short session titles. It runs automatically and is not selectable in the UI.
|
||||
|
||||
---
|
||||
|
||||
### Use summary
|
||||
|
||||
_Mode_: `primary`
|
||||
|
||||
Hidden system agent that creates session summaries. It runs automatically and is not selectable in the UI.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. For primary agents, use the **Tab** key to cycle through them during a session. You can also use your configured `switch_agent` keybind.
|
||||
|
||||
@@ -17,15 +17,17 @@ OpenCode searches these locations:
|
||||
- Global config: `~/.config/opencode/skills/<name>/SKILL.md`
|
||||
- Project Claude-compatible: `.claude/skills/<name>/SKILL.md`
|
||||
- Global Claude-compatible: `~/.claude/skills/<name>/SKILL.md`
|
||||
- Project agent-compatible: `.agents/skills/<name>/SKILL.md`
|
||||
- Global agent-compatible: `~/.agents/skills/<name>/SKILL.md`
|
||||
|
||||
---
|
||||
|
||||
## Understand discovery
|
||||
|
||||
For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree.
|
||||
It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way.
|
||||
It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` or `.agents/skills/*/SKILL.md` along the way.
|
||||
|
||||
Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`.
|
||||
Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, and `~/.agents/skills/*/SKILL.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ if (!Script.preview) {
|
||||
const file = `${dir}/opencode-release-notes.txt`
|
||||
await Bun.write(file, body)
|
||||
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${file}`
|
||||
const release = await $`gh release view v${Script.version} --json id,tagName`.json()
|
||||
output.push(`release=${release.id}`)
|
||||
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user