Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
commit
eaba99711b
80 changed files with 2362 additions and 1142 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
# web + desktop packages
|
# web + desktop packages
|
||||||
packages/app/ @adamdotdevin
|
packages/app/ @adamdotdevin
|
||||||
packages/tauri/ @adamdotdevin
|
packages/tauri/ @adamdotdevin
|
||||||
|
packages/desktop/src-tauri/ @brendonovich
|
||||||
packages/desktop/ @adamdotdevin
|
packages/desktop/ @adamdotdevin
|
||||||
|
|
|
||||||
34
bun.lock
34
bun.lock
|
|
@ -23,7 +23,7 @@
|
||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
|
|
@ -134,7 +134,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
|
|
@ -158,7 +158,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|
@ -213,7 +213,7 @@
|
||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
|
|
@ -258,7 +258,7 @@
|
||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
|
|
@ -309,7 +309,7 @@
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"ai-gateway-provider": "2.3.1",
|
"ai-gateway-provider": "2.3.1",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.4",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
|
|
@ -363,7 +363,7 @@
|
||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
|
|
@ -383,7 +383,7 @@
|
||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
|
|
@ -394,7 +394,7 @@
|
||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
|
|
@ -407,7 +407,7 @@
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -449,7 +449,7 @@
|
||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
@ -460,7 +460,7 @@
|
||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@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-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=="],
|
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=",
|
"x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=",
|
||||||
"aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=",
|
"aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=",
|
||||||
"aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=",
|
"aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=",
|
||||||
"x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU="
|
"x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,12 @@ import {
|
||||||
import type { createSdk } from "./utils"
|
import type { createSdk } from "./utils"
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
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) {
|
export async function openPalette(page: Page) {
|
||||||
|
|
@ -68,14 +73,50 @@ export async function toggleSidebar(page: Page) {
|
||||||
|
|
||||||
export async function openSidebar(page: Page) {
|
export async function openSidebar(page: Page) {
|
||||||
if (!(await isSidebarClosed(page))) return
|
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 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) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
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 toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(main).toHaveClass(/xl:border-l/)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
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) {
|
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 expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click()
|
await menuTrigger.click()
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test as base, expect } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { seedProjects } from "./actions"
|
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||||
import { promptSelector } from "./selectors"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
|
|
@ -8,6 +8,14 @@ export const settingsKey = "settings.v3"
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
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 = {
|
type WorkerFixtures = {
|
||||||
|
|
@ -33,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
await use(createSdk(directory))
|
await use(createSdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory }, use) => {
|
||||||
await seedProjects(page, { directory })
|
await seedStorage(page, { directory })
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:model",
|
|
||||||
JSON.stringify({
|
|
||||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
|
||||||
user: [],
|
|
||||||
variant: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
|
|
@ -51,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
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 }
|
export { expect }
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,53 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar } from "../actions"
|
import { openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
|
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||||
await gotoSession()
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
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()
|
const header = page.locator(".group\\/project").first()
|
||||||
await header.hover()
|
await expect(header).toContainText(name)
|
||||||
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()
|
const reopened = await open()
|
||||||
await expect(menu).toBeVisible()
|
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
||||||
|
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
||||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
await expect(editItem).toBeVisible()
|
await expect(reopened).toHaveCount(0)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,73 @@
|
||||||
import { test, expect } from "../fixtures"
|
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 { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
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 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
await seedProjects(page, { directory, extra: [other] })
|
|
||||||
|
|
||||||
try {
|
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()
|
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(close).toBeVisible()
|
||||||
await otherButton.hover()
|
await close.click()
|
||||||
|
|
||||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
await expect(otherButton).toHaveCount(0)
|
||||||
await expect(close).toBeVisible()
|
},
|
||||||
await close.click()
|
{ extra: [other] },
|
||||||
|
)
|
||||||
await expect(otherButton).toHaveCount(0)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
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 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherName = other.split("/").pop() ?? other
|
const otherName = other.split("/").pop() ?? other
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
await seedProjects(page, { directory, extra: [other] })
|
|
||||||
|
|
||||||
try {
|
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(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.click()
|
|
||||||
|
|
||||||
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
|
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||||
.locator(".group\\/project")
|
await expect(trigger).toHaveCount(1)
|
||||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
await trigger.focus()
|
||||||
.first()
|
await page.keyboard.press("Enter")
|
||||||
await expect(header).toContainText(otherName)
|
|
||||||
|
|
||||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||||
await trigger.focus()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
await expect(otherButton).toHaveCount(0)
|
||||||
|
},
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
{ extra: [other] },
|
||||||
await expect(otherButton).toHaveCount(0)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,34 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
|
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
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 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
await seedProjects(page, { directory, extra: [other] })
|
|
||||||
|
|
||||||
try {
|
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)
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.click()
|
|
||||||
|
|
||||||
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(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
await expect(currentButton).toBeVisible()
|
},
|
||||||
await currentButton.click()
|
{ extra: [other] },
|
||||||
|
)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,33 +10,20 @@ import {
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
clickMenuItem,
|
clickMenuItem,
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
createTestProject,
|
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
seedProjects,
|
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
const project = await createTestProject()
|
const rootSlug = project.slug
|
||||||
const rootSlug = dirSlug(project)
|
|
||||||
await seedProjects(page, { directory, extra: [project] })
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await openSidebar(page)
|
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 setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: ()
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.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 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const project = await createTestProject()
|
await withProject(async ({ slug }) => {
|
||||||
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 openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
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 setWorkspacesEnabled(page, slug, false)
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
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 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const project = await createTestProject()
|
await withProject(async ({ slug }) => {
|
||||||
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 openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
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 expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
||||||
|
|
||||||
await cleanupTestProject(workspaceDir)
|
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 })
|
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 rename = `e2e workspace ${Date.now()}`
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
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.fill(rename)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
await expect(item).toContainText(rename)
|
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 })
|
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 readme = path.join(createdDir, "README.md")
|
||||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||||
const original = await fs.readFile(readme, "utf8")
|
const original = await fs.readFile(readme, "utf8")
|
||||||
|
|
@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
})
|
})
|
||||||
.toBe(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 })
|
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)
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
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).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
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 page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await withProject(async ({ slug: rootSlug }) => {
|
||||||
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
|
|
||||||
const project = await createTestProject()
|
const listSlugs = async () => {
|
||||||
const rootSlug = dirSlug(project)
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
await seedProjects(page, { directory, extra: [project] })
|
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 waitReady = async (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()
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => {
|
async () => {
|
||||||
const slug = slugFromUrl(page.url())
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 45_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
const drag = async (from: string, to: string) => {
|
||||||
const dir = base64Decode(slug)
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
workspaces.push({ slug, directory: dir })
|
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 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"
|
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 stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const newTitle = `e2e renamed ${stamp}`
|
const newTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await withSession(sdk, originalTitle, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
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 expect(input).toBeVisible()
|
||||||
await input.fill(newTitle)
|
await input.fill(newTitle)
|
||||||
await input.press("Enter")
|
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 stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /archive/i)
|
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 stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await confirmDialog(page, /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}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedMessage(sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
||||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openStatusPopover, defocus } from "../actions"
|
import { openStatusPopover } from "../actions"
|
||||||
|
|
||||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
@ -88,7 +88,7 @@ test("status popover closes when clicking outside", async ({ page, gotoSession }
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
const { popoverBody } = await openStatusPopover(page)
|
||||||
await expect(popoverBody).toBeVisible()
|
await expect(popoverBody).toBeVisible()
|
||||||
|
|
||||||
await defocus(page)
|
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
|
||||||
await expect(popoverBody).toHaveCount(0)
|
await expect(popoverBody).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
const win = process.platform === "win32"
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|
@ -15,8 +14,7 @@ export default defineConfig({
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
fullyParallel: !win,
|
fullyParallel: true,
|
||||||
workers: win ? 1 : undefined,
|
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||||
|
|
|
||||||
|
|
@ -1220,7 +1220,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
})
|
})
|
||||||
return undefined
|
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
|
if (!session) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
|
|
@ -52,6 +53,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Terminal = (props: TerminalProps) => {
|
export const Terminal = (props: TerminalProps) => {
|
||||||
|
const platform = usePlatform()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
@ -135,6 +137,22 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
focusTerminal()
|
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(() => {
|
onMount(() => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const loaded = await loadGhostty()
|
const loaded = await loadGhostty()
|
||||||
|
|
@ -146,6 +164,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
const once = { value: false }
|
const once = { value: false }
|
||||||
|
|
||||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
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) {
|
if (window.__OPENCODE__?.serverPassword) {
|
||||||
url.username = "opencode"
|
url.username = "opencode"
|
||||||
url.password = window.__OPENCODE__?.serverPassword
|
url.password = window.__OPENCODE__?.serverPassword
|
||||||
|
|
@ -239,6 +258,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
||||||
|
|
||||||
|
container.addEventListener("click", handleLinkClick, { capture: true })
|
||||||
|
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
|
||||||
|
|
||||||
handleTextareaFocus = () => {
|
handleTextareaFocus = () => {
|
||||||
t.options.cursorBlink = true
|
t.options.cursorBlink = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,14 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||||
setFocus((current) => (current?.id === id ? null : current))
|
setFocus((current) => (current?.id === id ? null : current))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
batch(() => {
|
||||||
|
setStore("comments", {})
|
||||||
|
setFocus(null)
|
||||||
|
setActive(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const all = createMemo(() => {
|
const all = createMemo(() => {
|
||||||
const files = Object.keys(store.comments)
|
const files = Object.keys(store.comments)
|
||||||
const items = files.flatMap((file) => store.comments[file] ?? [])
|
const items = files.flatMap((file) => store.comments[file] ?? [])
|
||||||
|
|
@ -82,6 +90,7 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||||
all,
|
all,
|
||||||
add,
|
add,
|
||||||
remove,
|
remove,
|
||||||
|
clear,
|
||||||
focus: createMemo(() => state.focus),
|
focus: createMemo(() => state.focus),
|
||||||
setFocus,
|
setFocus,
|
||||||
clearFocus: () => setFocus(null),
|
clearFocus: () => setFocus(null),
|
||||||
|
|
@ -144,6 +153,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||||
all: () => session().all(),
|
all: () => session().all(),
|
||||||
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
||||||
remove: (file: string, id: string) => session().remove(file, id),
|
remove: (file: string, id: string) => session().remove(file, id),
|
||||||
|
clear: () => session().clear(),
|
||||||
focus: () => session().focus(),
|
focus: () => session().focus(),
|
||||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||||
clearFocus: () => session().clearFocus(),
|
clearFocus: () => session().clearFocus(),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ type SessionTabs = {
|
||||||
type SessionView = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
|
pendingMessage?: string
|
||||||
|
pendingMessageAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabHandoff = {
|
||||||
|
dir: string
|
||||||
|
id: string
|
||||||
|
at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
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>,
|
sessionTabs: {} as Record<string, SessionTabs>,
|
||||||
sessionView: {} as Record<string, SessionView>,
|
sessionView: {} as Record<string, SessionView>,
|
||||||
|
handoff: {
|
||||||
|
tabs: undefined as TabHandoff | undefined,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const MAX_SESSION_KEYS = 50
|
const MAX_SESSION_KEYS = 50
|
||||||
|
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||||
const meta = { active: undefined as string | undefined, pruned: false }
|
const meta = { active: undefined as string | undefined, pruned: false }
|
||||||
const used = new Map<string, number>()
|
const used = new Map<string, number>()
|
||||||
|
|
||||||
|
|
@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
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: {
|
projects: {
|
||||||
list,
|
list,
|
||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
|
|
@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
setStore("mobileSidebar", "opened", (x) => !x)
|
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>) {
|
view(sessionKey: string | Accessor<string>) {
|
||||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSound, soundSrc } from "@/utils/sound"
|
||||||
|
import { createAim } from "@/utils/aim"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { agentColor } from "@/utils/agent"
|
import { agentColor } from "@/utils/agent"
|
||||||
|
|
||||||
|
|
@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
const navLeave = { current: undefined as number | undefined }
|
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(() => {
|
onCleanup(() => {
|
||||||
if (navLeave.current === undefined) return
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
clearTimeout(navLeave.current)
|
aim.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||||
|
|
@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.sidebar.opened()) return
|
if (!layout.sidebar.opened()) return
|
||||||
|
aim.reset()
|
||||||
setState("hoverProject", undefined)
|
setState("hoverProject", undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (state.hoverProject !== undefined) return
|
||||||
|
aim.reset()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: params.dir, id: params.id }),
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
() => {
|
() => {
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
if (!state.hoverProject) return
|
if (!state.hoverProject) return
|
||||||
|
aim.reset()
|
||||||
setState("hoverSession", undefined)
|
setState("hoverSession", undefined)
|
||||||
setState("hoverProject", 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(() => {
|
command.register(() => {
|
||||||
const commands: CommandOption[] = [
|
const commands: CommandOption[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -1316,15 +1272,6 @@ export default function Layout(props: ParentProps) {
|
||||||
globalSync.project.meta(project.worktree, { name })
|
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 renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||||
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||||
if (current === next) return
|
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 }) {
|
function DialogDeleteWorkspace(props: { root: string; directory: string }) {
|
||||||
const name = createMemo(() => getFilename(props.directory))
|
const name = createMemo(() => getFilename(props.directory))
|
||||||
const [data, setData] = createStore({
|
const [data, setData] = createStore({
|
||||||
|
|
@ -1855,10 +1775,6 @@ export default function Layout(props: ParentProps) {
|
||||||
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
|
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
|
||||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||||
const isActive = createMemo(() => props.session.id === params.id)
|
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 hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
const cancelHoverPrefetch = () => {
|
const cancelHoverPrefetch = () => {
|
||||||
|
|
@ -1885,7 +1801,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const item = (
|
const item = (
|
||||||
<A
|
<A
|
||||||
href={`${props.slug}/session/${props.session.id}`}
|
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}
|
onPointerEnter={scheduleHoverPrefetch}
|
||||||
onPointerLeave={cancelHoverPrefetch}
|
onPointerLeave={cancelHoverPrefetch}
|
||||||
onMouseEnter={scheduleHoverPrefetch}
|
onMouseEnter={scheduleHoverPrefetch}
|
||||||
|
|
@ -1917,14 +1833,9 @@ export default function Layout(props: ParentProps) {
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
<InlineEditor
|
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||||
id={`session:${props.session.id}`}
|
{props.session.title}
|
||||||
value={() => props.session.title}
|
</span>
|
||||||
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
|
|
||||||
/>
|
|
||||||
<Show when={props.session.summary}>
|
<Show when={props.session.summary}>
|
||||||
{(summary) => (
|
{(summary) => (
|
||||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
<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}
|
getLabel={messageLabel}
|
||||||
onMessageSelect={(message) => {
|
onMessageSelect={(message) => {
|
||||||
if (!isActive()) {
|
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}`)
|
navigate(`${props.slug}/session/${props.session.id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1989,49 +1903,25 @@ export default function Layout(props: ParentProps) {
|
||||||
<div
|
<div
|
||||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100 pointer-events-auto": menu.open,
|
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||||
"opacity-0 pointer-events-none": !menu.open,
|
"opacity-0 pointer-events-none": !props.mobile,
|
||||||
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
"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,
|
"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.archive")} placement="top">
|
||||||
<Tooltip value={language.t("common.moreOptions")} placement="top">
|
<IconButton
|
||||||
<DropdownMenu.Trigger
|
icon="archive"
|
||||||
as={IconButton}
|
variant="ghost"
|
||||||
icon="dot-grid"
|
class="size-6 rounded-md"
|
||||||
variant="ghost"
|
aria-label={language.t("common.archive")}
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
onClick={(event) => {
|
||||||
aria-label={language.t("common.moreOptions")}
|
event.preventDefault()
|
||||||
/>
|
event.stopPropagation()
|
||||||
</Tooltip>
|
void archiveSession(props.session)
|
||||||
<DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
|
}}
|
||||||
<DropdownMenu.Content
|
/>
|
||||||
onCloseAutoFocus={(event) => {
|
</Tooltip>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -2440,17 +2330,17 @@ export default function Layout(props: ParentProps) {
|
||||||
!selected() && !active(),
|
!selected() && !active(),
|
||||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
if (!overlay()) return
|
if (!overlay()) return
|
||||||
globalSync.child(props.project.worktree)
|
aim.enter(props.project.worktree, event)
|
||||||
setState("hoverProject", props.project.worktree)
|
}}
|
||||||
setState("hoverSession", undefined)
|
onMouseLeave={() => {
|
||||||
|
if (!overlay()) return
|
||||||
|
aim.leave(props.project.worktree)
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!overlay()) return
|
if (!overlay()) return
|
||||||
globalSync.child(props.project.worktree)
|
aim.activate(props.project.worktree)
|
||||||
setState("hoverProject", props.project.worktree)
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}}
|
}}
|
||||||
onClick={() => navigateToProject(props.project.worktree)}
|
onClick={() => navigateToProject(props.project.worktree)}
|
||||||
onBlur={() => setOpen(false)}
|
onBlur={() => setOpen(false)}
|
||||||
|
|
@ -2935,7 +2825,7 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full overflow-hidden">
|
<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">
|
<div class="flex-1 min-h-0 w-full">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|
@ -3030,6 +2920,7 @@ export default function Layout(props: ParentProps) {
|
||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
|
aim.reset()
|
||||||
if (!sidebarHovering()) return
|
if (!sidebarHovering()) return
|
||||||
|
|
||||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
|
|
@ -3045,7 +2936,7 @@ export default function Layout(props: ParentProps) {
|
||||||
</div>
|
</div>
|
||||||
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
||||||
{(project) => (
|
{(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} />
|
<SidebarPanel project={project} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,19 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
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 { PromptInput } from "@/components/prompt-input"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
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 { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
|
|
@ -51,7 +55,7 @@ import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
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 { useSDK } from "@/context/sdk"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useComments, type LineComment } from "@/context/comments"
|
import { useComments, type LineComment } from "@/context/comments"
|
||||||
|
|
@ -73,13 +77,36 @@ import { same } from "@/utils/same"
|
||||||
|
|
||||||
type DiffStyle = "unified" | "split"
|
type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
|
type HandoffSession = {
|
||||||
|
prompt: string
|
||||||
|
files: Record<string, SelectedLineRange | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDOFF_MAX = 40
|
||||||
|
|
||||||
const handoff = {
|
const handoff = {
|
||||||
prompt: "",
|
session: new Map<string, HandoffSession>(),
|
||||||
terminals: [] as string[],
|
terminal: new Map<string, string[]>(),
|
||||||
files: {} as Record<string, SelectedLineRange | null>,
|
}
|
||||||
|
|
||||||
|
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 {
|
interface SessionReviewTabProps {
|
||||||
|
title?: JSX.Element
|
||||||
|
empty?: JSX.Element
|
||||||
diffs: () => FileDiff[]
|
diffs: () => FileDiff[]
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
diffStyle: DiffStyle
|
diffStyle: DiffStyle
|
||||||
|
|
@ -196,6 +223,8 @@ function SessionReviewTab(props: SessionReviewTabProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionReview
|
<SessionReview
|
||||||
|
title={props.title}
|
||||||
|
empty={props.empty}
|
||||||
scrollRef={(el) => {
|
scrollRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
props.onScrollRef?.(el)
|
props.onScrollRef?.(el)
|
||||||
|
|
@ -255,6 +284,10 @@ export default function Page() {
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
autoCreated: false,
|
autoCreated: false,
|
||||||
|
scroll: {
|
||||||
|
overflow: false,
|
||||||
|
bottom: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
|
|
@ -280,9 +313,47 @@ export default function Page() {
|
||||||
.finally(() => setUi("responding", false))
|
.finally(() => setUi("responding", false))
|
||||||
}
|
}
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
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 tabs = createMemo(() => layout.tabs(sessionKey))
|
||||||
const view = createMemo(() => layout.view(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) {
|
if (import.meta.env.DEV) {
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
|
|
@ -398,6 +469,213 @@ export default function Page() {
|
||||||
if (!id) return false
|
if (!id) return false
|
||||||
return sync.session.history.loading(id)
|
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 emptyUserMessages: UserMessage[] = []
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
|
|
@ -436,10 +714,14 @@ export default function Page() {
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
turnStart: 0,
|
turnStart: 0,
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
|
changes: "session" as "session" | "turn",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
promptHeight: 0,
|
promptHeight: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
|
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||||
|
|
||||||
const renderedUserMessages = createMemo(
|
const renderedUserMessages = createMemo(
|
||||||
() => {
|
() => {
|
||||||
const msgs = visibleUserMessages()
|
const msgs = visibleUserMessages()
|
||||||
|
|
@ -526,6 +808,7 @@ export default function Page() {
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
let promptDock: HTMLDivElement | undefined
|
let promptDock: HTMLDivElement | undefined
|
||||||
let scroller: HTMLDivElement | undefined
|
let scroller: HTMLDivElement | undefined
|
||||||
|
let content: HTMLDivElement | undefined
|
||||||
|
|
||||||
const scrollGestureWindowMs = 250
|
const scrollGestureWindowMs = 250
|
||||||
|
|
||||||
|
|
@ -545,8 +828,10 @@ export default function Page() {
|
||||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.id) return
|
sdk.directory
|
||||||
sync.session.sync(params.id)
|
const id = params.id
|
||||||
|
if (!id) return
|
||||||
|
sync.session.sync(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -614,10 +899,23 @@ export default function Page() {
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.id,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("expanded", {})
|
setStore("expanded", {})
|
||||||
|
setStore("changes", "session")
|
||||||
|
setUi("autoCreated", false)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => params.dir,
|
||||||
|
(dir) => {
|
||||||
|
if (!dir) return
|
||||||
|
setStore("newSessionWorktree", "main")
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|
@ -1125,34 +1423,84 @@ export default function Page() {
|
||||||
activeDiff: undefined as string | undefined,
|
activeDiff: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const reviewScroll = () => tree.reviewScroll
|
createEffect(
|
||||||
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
|
on(
|
||||||
const pendingDiff = () => tree.pendingDiff
|
sessionKey,
|
||||||
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
|
() => {
|
||||||
const activeDiff = () => tree.activeDiff
|
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||||
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const showAllFiles = () => {
|
const showAllFiles = () => {
|
||||||
if (fileTreeTab() !== "changes") return
|
if (fileTreeTab() !== "changes") return
|
||||||
setFileTreeTab("all")
|
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 = () => (
|
const reviewPanel = () => (
|
||||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
<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">
|
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<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()}>
|
<Match when={hasReview()}>
|
||||||
<Show
|
<Show
|
||||||
when={diffsReady()}
|
when={diffsReady()}
|
||||||
fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
|
fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
|
||||||
>
|
>
|
||||||
<SessionReviewTab
|
<SessionReviewTab
|
||||||
diffs={diffs}
|
title={changesTitle()}
|
||||||
|
diffs={reviewDiffs}
|
||||||
view={view}
|
view={view}
|
||||||
diffStyle={layout.review.diffStyle()}
|
diffStyle={layout.review.diffStyle()}
|
||||||
onDiffStyleChange={layout.review.setDiffStyle}
|
onDiffStyleChange={layout.review.setDiffStyle}
|
||||||
onScrollRef={setReviewScroll}
|
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||||
focusedFile={activeDiff()}
|
focusedFile={tree.activeDiff}
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
|
|
@ -1202,7 +1550,7 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewDiffTop = (path: string) => {
|
const reviewDiffTop = (path: string) => {
|
||||||
const root = reviewScroll()
|
const root = tree.reviewScroll
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|
||||||
const id = reviewDiffId(path)
|
const id = reviewDiffId(path)
|
||||||
|
|
@ -1218,7 +1566,7 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToReviewDiff = (path: string) => {
|
const scrollToReviewDiff = (path: string) => {
|
||||||
const root = reviewScroll()
|
const root = tree.reviewScroll
|
||||||
if (!root) return false
|
if (!root) return false
|
||||||
|
|
||||||
const top = reviewDiffTop(path)
|
const top = reviewDiffTop(path)
|
||||||
|
|
@ -1232,24 +1580,23 @@ export default function Page() {
|
||||||
const focusReviewDiff = (path: string) => {
|
const focusReviewDiff = (path: string) => {
|
||||||
const current = view().review.open() ?? []
|
const current = view().review.open() ?? []
|
||||||
if (!current.includes(path)) view().review.setOpen([...current, path])
|
if (!current.includes(path)) view().review.setOpen([...current, path])
|
||||||
setActiveDiff(path)
|
setTree({ activeDiff: path, pendingDiff: path })
|
||||||
setPendingDiff(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const pending = pendingDiff()
|
const pending = tree.pendingDiff
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
if (!reviewScroll()) return
|
if (!tree.reviewScroll) return
|
||||||
if (!diffsReady()) return
|
if (!diffsReady()) return
|
||||||
|
|
||||||
const attempt = (count: number) => {
|
const attempt = (count: number) => {
|
||||||
if (pendingDiff() !== pending) return
|
if (tree.pendingDiff !== pending) return
|
||||||
if (count > 60) {
|
if (count > 60) {
|
||||||
setPendingDiff(undefined)
|
setTree("pendingDiff", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = reviewScroll()
|
const root = tree.reviewScroll
|
||||||
if (!root) {
|
if (!root) {
|
||||||
requestAnimationFrame(() => attempt(count + 1))
|
requestAnimationFrame(() => attempt(count + 1))
|
||||||
return
|
return
|
||||||
|
|
@ -1267,7 +1614,7 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(root.scrollTop - top) <= 1) {
|
if (Math.abs(root.scrollTop - top) <= 1) {
|
||||||
setPendingDiff(undefined)
|
setTree("pendingDiff", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1310,13 +1657,17 @@ export default function Page() {
|
||||||
void sync.session.diff(id)
|
void sync.session.diff(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let treeDir: string | undefined
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
const dir = sdk.directory
|
||||||
if (!isDesktop()) return
|
if (!isDesktop()) return
|
||||||
if (!layout.fileTree.opened()) return
|
if (!layout.fileTree.opened()) return
|
||||||
if (sync.status === "loading") return
|
if (sync.status === "loading") return
|
||||||
|
|
||||||
fileTreeTab()
|
fileTreeTab()
|
||||||
void file.tree.list("")
|
const refresh = treeDir !== dir
|
||||||
|
treeDir = dir
|
||||||
|
void (refresh ? file.tree.refresh("") : file.tree.list(""))
|
||||||
})
|
})
|
||||||
|
|
||||||
const autoScroll = createAutoScroll({
|
const autoScroll = createAutoScroll({
|
||||||
|
|
@ -1329,10 +1680,40 @@ export default function Page() {
|
||||||
window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
|
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 = () => {
|
const resumeScroll = () => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
clearMessageHash()
|
clearMessageHash()
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the user returns to the bottom, treat the active message as "latest".
|
// 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 scrollSpyFrame: number | undefined
|
||||||
let scrollSpyTarget: HTMLDivElement | 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 anchor = (id: string) => `message-${id}`
|
||||||
|
|
||||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||||
scroller = el
|
scroller = el
|
||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createResizeObserver(
|
||||||
|
() => content,
|
||||||
|
() => {
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const turnInit = 20
|
const turnInit = 20
|
||||||
const turnBatch = 20
|
const turnBatch = 20
|
||||||
let turnHandle: number | undefined
|
let turnHandle: number | undefined
|
||||||
|
|
@ -1458,6 +1860,8 @@ export default function Page() {
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
|
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)}`)
|
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
const sessionID = params.id
|
on(sessionKey, (key) => {
|
||||||
if (!sessionID) return
|
if (!params.id) return
|
||||||
const raw = sessionStorage.getItem("opencode.pendingMessage")
|
const messageID = layout.pendingMessage.consume(key)
|
||||||
if (!raw) return
|
if (!messageID) return
|
||||||
const parts = raw.split("|")
|
setUi("pendingMessage", messageID)
|
||||||
const pendingSessionID = parts[0]
|
}),
|
||||||
const messageID = parts[1]
|
)
|
||||||
if (!pendingSessionID || !messageID) return
|
|
||||||
if (pendingSessionID !== sessionID) return
|
|
||||||
|
|
||||||
sessionStorage.removeItem("opencode.pendingMessage")
|
|
||||||
setUi("pendingMessage", messageID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||||
const root = scroller
|
const root = scroller
|
||||||
|
|
@ -1544,6 +1942,9 @@ export default function Page() {
|
||||||
const hash = window.location.hash.slice(1)
|
const hash = window.location.hash.slice(1)
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1569,6 +1970,9 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
|
|
||||||
autoScroll.forceScrollToBottom()
|
autoScroll.forceScrollToBottom()
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (el) scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closestMessage = (node: Element | null): HTMLElement | null => {
|
const closestMessage = (node: Element | null): HTMLElement | null => {
|
||||||
|
|
@ -1692,7 +2096,7 @@ export default function Page() {
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
handoff.prompt = previewPrompt()
|
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -1712,26 +2116,29 @@ export default function Page() {
|
||||||
return language.t("terminal.title")
|
return language.t("terminal.title")
|
||||||
}
|
}
|
||||||
|
|
||||||
handoff.terminals = terminal.all().map(label)
|
touch(handoff.terminal, params.dir!, terminal.all().map(label))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!file.ready()) return
|
if (!file.ready()) return
|
||||||
handoff.files = Object.fromEntries(
|
setSessionHandoff(sessionKey(), {
|
||||||
tabs()
|
files: Object.fromEntries(
|
||||||
.all()
|
tabs()
|
||||||
.flatMap((tab) => {
|
.all()
|
||||||
const path = file.pathFromTab(tab)
|
.flatMap((tab) => {
|
||||||
if (!path) return []
|
const path = file.pathFromTab(tab)
|
||||||
return [[path, file.selectedLines(path) ?? null] as const]
|
if (!path) return []
|
||||||
}),
|
return [[path, file.selectedLines(path) ?? null] as const]
|
||||||
)
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelTurnBackfill()
|
cancelTurnBackfill()
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
|
||||||
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1771,7 +2178,7 @@ export default function Page() {
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
"@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(),
|
"md:flex-none": layout.fileTree.opened(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1788,6 +2195,31 @@ export default function Page() {
|
||||||
fallback={
|
fallback={
|
||||||
<div class="relative h-full overflow-hidden">
|
<div class="relative h-full overflow-hidden">
|
||||||
<Switch>
|
<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()}>
|
<Match when={hasReview()}>
|
||||||
<Show
|
<Show
|
||||||
when={diffsReady()}
|
when={diffsReady()}
|
||||||
|
|
@ -1798,10 +2230,11 @@ export default function Page() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SessionReviewTab
|
<SessionReviewTab
|
||||||
diffs={diffs}
|
title={changesTitle()}
|
||||||
|
diffs={reviewDiffs}
|
||||||
view={view}
|
view={view}
|
||||||
diffStyle="unified"
|
diffStyle="unified"
|
||||||
focusedFile={activeDiff()}
|
focusedFile={tree.activeDiff}
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
|
|
@ -1836,8 +2269,9 @@ export default function Page() {
|
||||||
<div
|
<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"
|
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={{
|
classList={{
|
||||||
"opacity-100 translate-y-0 scale-100": 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": !autoScroll.userScrolled(),
|
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||||
|
!ui.scroll.overflow || ui.scroll.bottom,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1935,6 +2369,7 @@ export default function Page() {
|
||||||
markScrollGesture(e.currentTarget)
|
markScrollGesture(e.currentTarget)
|
||||||
}}
|
}}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
|
scheduleScrollState(e.currentTarget)
|
||||||
if (!hasScrollGesture()) return
|
if (!hasScrollGesture()) return
|
||||||
autoScroll.handleScroll()
|
autoScroll.handleScroll()
|
||||||
markScrollGesture(e.currentTarget)
|
markScrollGesture(e.currentTarget)
|
||||||
|
|
@ -1954,27 +2389,121 @@ export default function Page() {
|
||||||
centered(),
|
centered(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="h-10 flex items-center gap-1">
|
<div class="h-10 w-full flex items-center justify-between gap-2">
|
||||||
<Show when={info()?.parentID}>
|
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||||
<IconButton
|
<Show when={info()?.parentID}>
|
||||||
tabIndex={-1}
|
<IconButton
|
||||||
icon="arrow-left"
|
tabIndex={-1}
|
||||||
variant="ghost"
|
icon="arrow-left"
|
||||||
onClick={() => {
|
variant="ghost"
|
||||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
onClick={() => {
|
||||||
}}
|
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||||
aria-label={language.t("common.goBack")}
|
}}
|
||||||
/>
|
aria-label={language.t("common.goBack")}
|
||||||
</Show>
|
/>
|
||||||
<Show when={info()?.title}>
|
</Show>
|
||||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={autoScroll.contentRef}
|
ref={(el) => {
|
||||||
|
content = el
|
||||||
|
autoScroll.contentRef(el)
|
||||||
|
|
||||||
|
const root = scroller
|
||||||
|
if (root) scheduleScrollState(root)
|
||||||
|
}}
|
||||||
role="log"
|
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]"
|
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={{
|
classList={{
|
||||||
|
|
@ -2147,7 +2676,7 @@ export default function Page() {
|
||||||
when={prompt.ready()}
|
when={prompt.ready()}
|
||||||
fallback={
|
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">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -2157,7 +2686,10 @@ export default function Page() {
|
||||||
}}
|
}}
|
||||||
newSessionWorktree={newSessionWorktree()}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
onSubmit={resumeScroll}
|
onSubmit={() => {
|
||||||
|
comments.clear()
|
||||||
|
resumeScroll()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2398,7 +2930,7 @@ export default function Page() {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (!p) return null
|
if (!p) return null
|
||||||
if (file.ready()) return file.selectedLines(p) ?? 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
|
let wrap: HTMLDivElement | undefined
|
||||||
|
|
@ -2892,7 +3424,7 @@ export default function Page() {
|
||||||
allowed={diffFiles()}
|
allowed={diffFiles()}
|
||||||
kinds={kinds()}
|
kinds={kinds()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
active={activeDiff()}
|
active={tree.activeDiff}
|
||||||
onFileClick={(node) => focusReviewDiff(node.path)}
|
onFileClick={(node) => focusReviewDiff(node.path)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2952,7 +3484,7 @@ export default function Page() {
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col h-full pointer-events-none">
|
<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">
|
<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) => (
|
{(title) => (
|
||||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||||
{title}
|
{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",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,13 @@
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
|
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
|
||||||
"externalBin": ["sidecars/opencode-cli"],
|
"externalBin": ["sidecars/opencode-cli"],
|
||||||
|
"linux": {
|
||||||
|
"rpm": {
|
||||||
|
"compression": {
|
||||||
|
"type": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"entitlements": "./entitlements.plist"
|
"entitlements": "./entitlements.plist"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@
|
||||||
"files": {
|
"files": {
|
||||||
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
|
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"rpm": {
|
||||||
|
"compression": {
|
||||||
|
"type": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.1.49"
|
version = "1.1.51"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[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"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -94,7 +94,7 @@
|
||||||
"ai": "catalog:",
|
"ai": "catalog:",
|
||||||
"ai-gateway-provider": "2.3.1",
|
"ai-gateway-provider": "2.3.1",
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.4",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
|
|
|
||||||
|
|
@ -95,73 +95,7 @@ if (!Script.preview) {
|
||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
// Source-based PKGBUILD for opencode
|
for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) {
|
||||||
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 (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
try {
|
try {
|
||||||
await $`rm -rf ./dist/aur-${pkg}`
|
await $`rm -rf ./dist/aur-${pkg}`
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { readableStreamToText } from "bun"
|
import { readableStreamToText } from "bun"
|
||||||
import { Lock } from "../util/lock"
|
import { Lock } from "../util/lock"
|
||||||
import { PackageRegistry } from "./registry"
|
import { PackageRegistry } from "./registry"
|
||||||
|
import { proxied } from "@/util/proxied"
|
||||||
|
|
||||||
export namespace BunProc {
|
export namespace BunProc {
|
||||||
const log = Log.create({ service: "bun" })
|
const log = Log.create({ service: "bun" })
|
||||||
|
|
@ -86,20 +87,13 @@ export namespace BunProc {
|
||||||
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
|
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
|
// Build command arguments
|
||||||
const args = [
|
const args = [
|
||||||
"add",
|
"add",
|
||||||
"--force",
|
"--force",
|
||||||
"--exact",
|
"--exact",
|
||||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||||
...(proxied ? ["--no-cache"] : []),
|
...(proxied() ? ["--no-cache"] : []),
|
||||||
"--cwd",
|
"--cwd",
|
||||||
Global.Path.cache,
|
Global.Path.cache,
|
||||||
pkg + "@" + version,
|
pkg + "@" + version,
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,6 @@ function App() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
Clipboard.setRenderer(renderer)
|
|
||||||
renderer.disableStdoutInterception()
|
renderer.disableStdoutInterception()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import type { CliRenderer } from "@opentui/core"
|
|
||||||
import { platform, release } from "os"
|
import { platform, release } from "os"
|
||||||
import clipboardy from "clipboardy"
|
import clipboardy from "clipboardy"
|
||||||
import { lazy } from "../../../../util/lazy.js"
|
import { lazy } from "../../../../util/lazy.js"
|
||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
import path from "path"
|
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 namespace Clipboard {
|
||||||
export interface Content {
|
export interface Content {
|
||||||
|
|
@ -14,10 +25,6 @@ export namespace Clipboard {
|
||||||
mime: string
|
mime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRenderer(renderer: CliRenderer | undefined): void {
|
|
||||||
rendererRef.current = renderer
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function read(): Promise<Content | undefined> {
|
export async function read(): Promise<Content | undefined> {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
|
|
@ -146,12 +153,7 @@ export namespace Clipboard {
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function copy(text: string): Promise<void> {
|
export async function copy(text: string): Promise<void> {
|
||||||
const renderer = rendererRef.current
|
writeOsc52(text)
|
||||||
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)
|
|
||||||
}
|
|
||||||
await getCopyMethod()(text)
|
await getCopyMethod()(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { Bus } from "@/bus"
|
||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { PackageRegistry } from "@/bun/registry"
|
import { PackageRegistry } from "@/bun/registry"
|
||||||
|
import { proxied } from "@/util/proxied"
|
||||||
|
|
||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
@ -247,13 +248,29 @@ export namespace Config {
|
||||||
const hasGitIgnore = await Bun.file(gitignore).exists()
|
const hasGitIgnore = await Bun.file(gitignore).exists()
|
||||||
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||||
|
|
||||||
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
|
await BunProc.run(
|
||||||
cwd: dir,
|
[
|
||||||
}).catch(() => {})
|
"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
|
// Install any additional dependencies defined in the package.json
|
||||||
// This allows local plugins and custom tools to use external packages
|
// 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) {
|
async function needsInstall(dir: string) {
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,9 @@ export namespace Provider {
|
||||||
options: providerOptions,
|
options: providerOptions,
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
// Skip region prefixing if model already has a cross-region inference profile prefix
|
// 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)
|
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) => {
|
"cloudflare-ai-gateway": async (input) => {
|
||||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||||
|
|
@ -985,12 +1010,6 @@ export namespace Provider {
|
||||||
const fetchFn = customFetch ?? fetch
|
const fetchFn = customFetch ?? fetch
|
||||||
const opts = init ?? {}
|
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) {
|
if (options["timeout"] !== undefined && options["timeout"] !== null) {
|
||||||
const signals: AbortSignal[] = []
|
const signals: AbortSignal[] = []
|
||||||
if (opts.signal) signals.push(opts.signal)
|
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") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||||
result["reasoningEffort"] = "medium"
|
result["reasoningEffort"] = "medium"
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@ const FILES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
function globalFiles() {
|
function globalFiles() {
|
||||||
const files = [path.join(Global.Path.config, "AGENTS.md")]
|
const files = []
|
||||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
|
||||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
|
||||||
}
|
|
||||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||||
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
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
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -968,9 +968,11 @@ export namespace SessionPrompt {
|
||||||
// have to normalize, symbol search returns absolute paths
|
// have to normalize, symbol search returns absolute paths
|
||||||
// Decode the pathname since URL constructor doesn't automatically decode it
|
// Decode the pathname since URL constructor doesn't automatically decode it
|
||||||
const filepath = fileURLToPath(part.url)
|
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"
|
part.mime = "application/x-directory"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -989,7 +991,7 @@ export namespace SessionPrompt {
|
||||||
// workspace/symbol searches, so we'll try to find the
|
// workspace/symbol searches, so we'll try to find the
|
||||||
// symbol in the document to get the full range
|
// symbol in the document to get the full range
|
||||||
if (start === end) {
|
if (start === end) {
|
||||||
const symbols = await LSP.documentSymbol(filePathURI)
|
const symbols = await LSP.documentSymbol(filePathURI).catch(() => [])
|
||||||
for (const symbol of symbols) {
|
for (const symbol of symbols) {
|
||||||
let range: LSP.Range | undefined
|
let range: LSP.Range | undefined
|
||||||
if ("range" in symbol) {
|
if ("range" in symbol) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import DESCRIPTION from "./codesearch.txt"
|
import DESCRIPTION from "./codesearch.txt"
|
||||||
|
import { abortAfterAny } from "../util/abort"
|
||||||
|
|
||||||
const API_CONFIG = {
|
const API_CONFIG = {
|
||||||
BASE_URL: "https://mcp.exa.ai",
|
BASE_URL: "https://mcp.exa.ai",
|
||||||
|
|
@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
|
@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(codeRequest),
|
body: JSON.stringify(codeRequest),
|
||||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
clearTimeout()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
|
|
@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||||
metadata: {},
|
metadata: {},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout()
|
||||||
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
throw new Error("Code search request timed out")
|
throw new Error("Code search request timed out")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import z from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
import DESCRIPTION from "./webfetch.txt"
|
import DESCRIPTION from "./webfetch.txt"
|
||||||
|
import { abortAfterAny } from "../util/abort"
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
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 timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
||||||
|
|
||||||
// Build Accept header based on requested format with q parameters for fallbacks
|
// Build Accept header based on requested format with q parameters for fallbacks
|
||||||
let acceptHeader = "*/*"
|
let acceptHeader = "*/*"
|
||||||
|
|
@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||||
acceptHeader =
|
acceptHeader =
|
||||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
|
"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 = {
|
const headers = {
|
||||||
"User-Agent":
|
"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",
|
"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" } })
|
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
|
||||||
: initial
|
: initial
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
clearTimeout()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Request failed with status code: ${response.status}`)
|
throw new Error(`Request failed with status code: ${response.status}`)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import DESCRIPTION from "./websearch.txt"
|
import DESCRIPTION from "./websearch.txt"
|
||||||
|
import { abortAfterAny } from "../util/abort"
|
||||||
|
|
||||||
const API_CONFIG = {
|
const API_CONFIG = {
|
||||||
BASE_URL: "https://mcp.exa.ai",
|
BASE_URL: "https://mcp.exa.ai",
|
||||||
|
|
@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController()
|
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 25000)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
|
@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify(searchRequest),
|
body: JSON.stringify(searchRequest),
|
||||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
clearTimeout()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
|
|
@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||||
metadata: {},
|
metadata: {},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout()
|
||||||
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
throw new Error("Search request timed out")
|
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 path from "path"
|
||||||
import { unlink } from "fs/promises"
|
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 path from "path"
|
||||||
import { InstructionPrompt } from "../../src/session/instruction"
|
import { InstructionPrompt } from "../../src/session/instruction"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
describe("InstructionPrompt.resolve", () => {
|
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",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,8 @@ export const Script = {
|
||||||
get preview() {
|
get preview() {
|
||||||
return IS_PREVIEW
|
return IS_PREVIEW
|
||||||
},
|
},
|
||||||
get release() {
|
get release(): boolean {
|
||||||
return env.OPENCODE_RELEASE
|
return !!env.OPENCODE_RELEASE
|
||||||
},
|
},
|
||||||
get team() {
|
get team() {
|
||||||
return team
|
return team
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* [data-slot="session-review-container"] { */
|
[data-slot="session-review-container"] {
|
||||||
/* height: 100%; */
|
flex: 1 1 auto;
|
||||||
/* } */
|
}
|
||||||
|
|
||||||
[data-slot="session-review-header"] {
|
[data-slot="session-review-header"] {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
|
|
@ -44,10 +44,11 @@
|
||||||
|
|
||||||
[data-component="sticky-accordion-header"] {
|
[data-component="sticky-accordion-header"] {
|
||||||
top: 40px;
|
top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
&[data-expanded]::before {
|
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||||
top: -40px;
|
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||||
}
|
top: -40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="accordion-trigger"] {
|
[data-slot="accordion-trigger"] {
|
||||||
|
|
@ -79,6 +80,7 @@
|
||||||
|
|
||||||
[data-slot="session-review-accordion-content"] {
|
[data-slot="session-review-accordion-content"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,7 +213,9 @@
|
||||||
[data-slot="session-review-diff-wrapper"] {
|
[data-slot="session-review-diff-wrapper"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
--line-comment-z: 5;
|
--line-comment-z: 5;
|
||||||
--line-comment-popover-z: 30;
|
--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 type SessionReviewFocus = { file: string; id: string }
|
||||||
|
|
||||||
export interface SessionReviewProps {
|
export interface SessionReviewProps {
|
||||||
|
title?: JSX.Element
|
||||||
|
empty?: JSX.Element
|
||||||
split?: boolean
|
split?: boolean
|
||||||
diffStyle?: SessionReviewDiffStyle
|
diffStyle?: SessionReviewDiffStyle
|
||||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||||
|
|
@ -184,6 +186,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
|
|
||||||
const open = () => props.open ?? store.open
|
const open = () => props.open ?? store.open
|
||||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||||
|
const hasDiffs = () => props.diffs.length > 0
|
||||||
|
|
||||||
const handleChange = (open: string[]) => {
|
const handleChange = (open: string[]) => {
|
||||||
props.onOpenChange?.(open)
|
props.onOpenChange?.(open)
|
||||||
|
|
@ -287,9 +290,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
[props.classes?.header ?? ""]: !!props.classes?.header,
|
[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">
|
<div data-slot="session-review-actions">
|
||||||
<Show when={props.onDiffStyleChange}>
|
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
options={["unified", "split"] as const}
|
options={["unified", "split"] as const}
|
||||||
current={diffStyle()}
|
current={diffStyle()}
|
||||||
|
|
@ -300,12 +303,14 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
|
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
|
<Show when={hasDiffs()}>
|
||||||
<Switch>
|
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
|
||||||
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
|
<Switch>
|
||||||
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
|
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
|
||||||
</Switch>
|
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
|
||||||
</Button>
|
</Switch>
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
{props.actions}
|
{props.actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -315,322 +320,324 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
[props.classes?.container ?? ""]: !!props.classes?.container,
|
[props.classes?.container ?? ""]: !!props.classes?.container,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Accordion multiple value={open()} onChange={handleChange}>
|
<Show when={hasDiffs()} fallback={props.empty}>
|
||||||
<For each={props.diffs}>
|
<Accordion multiple value={open()} onChange={handleChange}>
|
||||||
{(diff) => {
|
<For each={props.diffs}>
|
||||||
let wrapper: HTMLDivElement | undefined
|
{(diff) => {
|
||||||
|
let wrapper: HTMLDivElement | undefined
|
||||||
|
|
||||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
|
||||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||||
|
|
||||||
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||||
|
|
||||||
const isAdded = () => beforeText().length === 0 && afterText().length > 0
|
const isAdded = () => beforeText().length === 0 && afterText().length > 0
|
||||||
const isDeleted = () => afterText().length === 0 && beforeText().length > 0
|
const isDeleted = () => afterText().length === 0 && beforeText().length > 0
|
||||||
const isImage = () => isImageFile(diff.file)
|
const isImage = () => isImageFile(diff.file)
|
||||||
const isAudio = () => isAudioFile(diff.file)
|
const isAudio = () => isAudioFile(diff.file)
|
||||||
|
|
||||||
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||||
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
|
||||||
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||||
|
|
||||||
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
|
||||||
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
|
||||||
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
||||||
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
||||||
|
|
||||||
const selectedLines = createMemo(() => {
|
const selectedLines = createMemo(() => {
|
||||||
const current = selection()
|
const current = selection()
|
||||||
if (!current || current.file !== diff.file) return null
|
if (!current || current.file !== diff.file) return null
|
||||||
return current.range
|
return current.range
|
||||||
})
|
})
|
||||||
|
|
||||||
const draftRange = createMemo(() => {
|
const draftRange = createMemo(() => {
|
||||||
const current = commenting()
|
const current = commenting()
|
||||||
if (!current || current.file !== diff.file) return null
|
if (!current || current.file !== diff.file) return null
|
||||||
return current.range
|
return current.range
|
||||||
})
|
})
|
||||||
|
|
||||||
const [draft, setDraft] = createSignal("")
|
const [draft, setDraft] = createSignal("")
|
||||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||||
|
|
||||||
const getRoot = () => {
|
const getRoot = () => {
|
||||||
const el = wrapper
|
const el = wrapper
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
const host = el.querySelector("diffs-container")
|
const host = el.querySelector("diffs-container")
|
||||||
if (!(host instanceof HTMLElement)) return
|
if (!(host instanceof HTMLElement)) return
|
||||||
return host.shadowRoot ?? undefined
|
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 marker = findMarker(root, range)
|
const updateAnchors = () => {
|
||||||
if (!marker) {
|
const el = wrapper
|
||||||
setDraftTop(undefined)
|
if (!el) return
|
||||||
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 = () => {
|
createEffect(() => {
|
||||||
requestAnimationFrame(updateAnchors)
|
comments()
|
||||||
}
|
scheduleAnchors()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
comments()
|
const range = draftRange()
|
||||||
scheduleAnchors()
|
if (!range) return
|
||||||
})
|
setDraft("")
|
||||||
|
scheduleAnchors()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const range = draftRange()
|
if (!open().includes(diff.file)) return
|
||||||
if (!range) return
|
if (!isImage()) return
|
||||||
setDraft("")
|
if (imageSrc()) return
|
||||||
scheduleAnchors()
|
if (imageStatus() !== "idle") return
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
const reader = props.readFile
|
||||||
if (!open().includes(diff.file)) return
|
if (!reader) return
|
||||||
if (!isImage()) return
|
|
||||||
if (imageSrc()) return
|
|
||||||
if (imageStatus() !== "idle") return
|
|
||||||
|
|
||||||
const reader = props.readFile
|
setImageStatus("loading")
|
||||||
if (!reader) return
|
reader(diff.file)
|
||||||
|
.then((result) => {
|
||||||
setImageStatus("loading")
|
const src = dataUrl(result)
|
||||||
reader(diff.file)
|
if (!src) {
|
||||||
.then((result) => {
|
setImageStatus("error")
|
||||||
const src = dataUrl(result)
|
return
|
||||||
if (!src) {
|
}
|
||||||
|
setImageSrc(src)
|
||||||
|
setImageStatus("idle")
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setImageStatus("error")
|
setImageStatus("error")
|
||||||
return
|
})
|
||||||
}
|
})
|
||||||
setImageSrc(src)
|
|
||||||
setImageStatus("idle")
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setImageStatus("error")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!open().includes(diff.file)) return
|
if (!open().includes(diff.file)) return
|
||||||
if (!isAudio()) return
|
if (!isAudio()) return
|
||||||
if (audioSrc()) return
|
if (audioSrc()) return
|
||||||
if (audioStatus() !== "idle") return
|
if (audioStatus() !== "idle") return
|
||||||
|
|
||||||
const reader = props.readFile
|
const reader = props.readFile
|
||||||
if (!reader) return
|
if (!reader) return
|
||||||
|
|
||||||
setAudioStatus("loading")
|
setAudioStatus("loading")
|
||||||
reader(diff.file)
|
reader(diff.file)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const src = dataUrl(result)
|
const src = dataUrl(result)
|
||||||
if (!src) {
|
if (!src) {
|
||||||
|
setAudioStatus("error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAudioMime(normalizeMimeType(result?.mimeType))
|
||||||
|
setAudioSrc(src)
|
||||||
|
setAudioStatus("idle")
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
setAudioStatus("error")
|
setAudioStatus("error")
|
||||||
return
|
})
|
||||||
}
|
})
|
||||||
setAudioMime(normalizeMimeType(result?.mimeType))
|
|
||||||
setAudioSrc(src)
|
|
||||||
setAudioStatus("idle")
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setAudioStatus("error")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||||
if (!props.onLineComment) return
|
if (!props.onLineComment) return
|
||||||
|
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
return
|
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 (!range) {
|
||||||
if (!props.onLineComment) return
|
setCommenting(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!range) {
|
setSelection({ file: diff.file, range })
|
||||||
setCommenting(null)
|
setCommenting({ file: diff.file, range })
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection({ file: diff.file, range })
|
const openComment = (comment: SessionReviewComment) => {
|
||||||
setCommenting({ file: diff.file, range })
|
setOpened({ file: comment.file, id: comment.id })
|
||||||
}
|
setSelection({ file: comment.file, range: comment.selection })
|
||||||
|
}
|
||||||
|
|
||||||
const openComment = (comment: SessionReviewComment) => {
|
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||||
setOpened({ file: comment.file, id: comment.id })
|
const current = opened()
|
||||||
setSelection({ file: comment.file, range: comment.selection })
|
if (!current) return false
|
||||||
}
|
return current.file === comment.file && current.id === comment.id
|
||||||
|
}
|
||||||
|
|
||||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
return (
|
||||||
const current = opened()
|
<Accordion.Item
|
||||||
if (!current) return false
|
value={diff.file}
|
||||||
return current.file === comment.file && current.id === comment.id
|
id={diffId(diff.file)}
|
||||||
}
|
data-file={diff.file}
|
||||||
|
data-slot="session-review-accordion-item"
|
||||||
return (
|
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
||||||
<Accordion.Item
|
>
|
||||||
value={diff.file}
|
<StickyAccordionHeader>
|
||||||
id={diffId(diff.file)}
|
<Accordion.Trigger>
|
||||||
data-file={diff.file}
|
<div data-slot="session-review-trigger-content">
|
||||||
data-slot="session-review-accordion-item"
|
<div data-slot="session-review-file-info">
|
||||||
data-selected={props.focusedFile === diff.file ? "" : undefined}
|
<FileIcon node={{ path: diff.file, type: "file" }} />
|
||||||
>
|
<div data-slot="session-review-file-name-container">
|
||||||
<StickyAccordionHeader>
|
<Show when={diff.file.includes("/")}>
|
||||||
<Accordion.Trigger>
|
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||||
<div data-slot="session-review-trigger-content">
|
</Show>
|
||||||
<div data-slot="session-review-file-info">
|
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
||||||
<FileIcon node={{ path: diff.file, type: "file" }} />
|
<Show when={props.onViewFile}>
|
||||||
<div data-slot="session-review-file-name-container">
|
<button
|
||||||
<Show when={diff.file.includes("/")}>
|
data-slot="session-review-view-button"
|
||||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
type="button"
|
||||||
</Show>
|
onClick={(e) => {
|
||||||
<span data-slot="session-review-filename">{getFilename(diff.file)}</span>
|
e.stopPropagation()
|
||||||
<Show when={props.onViewFile}>
|
props.onViewFile?.(diff.file)
|
||||||
<button
|
}}
|
||||||
data-slot="session-review-view-button"
|
>
|
||||||
type="button"
|
<Icon name="eye" size="small" />
|
||||||
onClick={(e) => {
|
</button>
|
||||||
e.stopPropagation()
|
</Show>
|
||||||
props.onViewFile?.(diff.file)
|
</div>
|
||||||
}}
|
</div>
|
||||||
>
|
<div data-slot="session-review-trigger-actions">
|
||||||
<Icon name="eye" size="small" />
|
<Switch>
|
||||||
</button>
|
<Match when={isAdded()}>
|
||||||
</Show>
|
<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>
|
</div>
|
||||||
<div data-slot="session-review-trigger-actions">
|
</Accordion.Trigger>
|
||||||
<Switch>
|
</StickyAccordionHeader>
|
||||||
<Match when={isAdded()}>
|
<Accordion.Content data-slot="session-review-accordion-content">
|
||||||
<span data-slot="session-review-change" data-type="added">
|
<div
|
||||||
{i18n.t("ui.sessionReview.change.added")}
|
data-slot="session-review-diff-wrapper"
|
||||||
</span>
|
ref={(el) => {
|
||||||
</Match>
|
wrapper = el
|
||||||
<Match when={isDeleted()}>
|
anchors.set(diff.file, el)
|
||||||
<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?.()
|
|
||||||
scheduleAnchors()
|
scheduleAnchors()
|
||||||
}}
|
}}
|
||||||
enableLineSelection={props.onLineComment != null}
|
>
|
||||||
onLineSelected={handleLineSelected}
|
<Dynamic
|
||||||
onLineSelectionEnd={handleLineSelectionEnd}
|
component={diffComponent}
|
||||||
selectedLines={selectedLines()}
|
preloadedDiff={diff.preloaded}
|
||||||
commentedLines={commentedLines()}
|
diffStyle={diffStyle()}
|
||||||
before={{
|
onRendered={() => {
|
||||||
name: diff.file!,
|
props.onDiffRendered?.()
|
||||||
contents: typeof diff.before === "string" ? diff.before : "",
|
scheduleAnchors()
|
||||||
}}
|
}}
|
||||||
after={{
|
enableLineSelection={props.onLineComment != null}
|
||||||
name: diff.file!,
|
onLineSelected={handleLineSelected}
|
||||||
contents: typeof diff.after === "string" ? diff.after : "",
|
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()}>
|
<For each={comments()}>
|
||||||
{(comment) => (
|
{(comment) => (
|
||||||
<LineComment
|
<LineComment
|
||||||
id={comment.id}
|
id={comment.id}
|
||||||
top={positions()[comment.id]}
|
top={positions()[comment.id]}
|
||||||
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isCommentOpen(comment)) {
|
if (isCommentOpen(comment)) {
|
||||||
setOpened(null)
|
setOpened(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
openComment(comment)
|
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)
|
|
||||||
}}
|
}}
|
||||||
|
open={isCommentOpen(comment)}
|
||||||
|
comment={comment.comment}
|
||||||
|
selection={selectionLabel(comment.selection)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
)}
|
||||||
)}
|
</For>
|
||||||
</Show>
|
|
||||||
</div>
|
<Show when={draftRange()}>
|
||||||
</Accordion.Content>
|
{(range) => (
|
||||||
</Accordion.Item>
|
<Show when={draftTop() !== undefined}>
|
||||||
)
|
<LineCommentEditor
|
||||||
}}
|
top={draftTop()}
|
||||||
</For>
|
value={draft()}
|
||||||
</Accordion>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -409,10 +409,11 @@
|
||||||
|
|
||||||
[data-component="sticky-accordion-header"] {
|
[data-component="sticky-accordion-header"] {
|
||||||
top: var(--sticky-header-height, 0px);
|
top: var(--sticky-header-height, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
&[data-expanded]::before {
|
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||||
top: calc(-1 * var(--sticky-header-height, 0px));
|
[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"] {
|
[data-slot="session-turn-accordion-trigger-content"] {
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,16 @@ import {
|
||||||
TextPart,
|
TextPart,
|
||||||
ToolPart,
|
ToolPart,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { type FileDiff } from "@opencode-ai/sdk/v2"
|
|
||||||
import { useData } from "../context"
|
import { useData } from "../context"
|
||||||
import { useDiffComponent } from "../context/diff"
|
|
||||||
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
|
import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n"
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
||||||
|
|
||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
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 { Message, Part } from "./message-part"
|
||||||
import { Markdown } from "./markdown"
|
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 { IconButton } from "./icon-button"
|
||||||
import { Card } from "./card"
|
import { Card } from "./card"
|
||||||
import { Dynamic } from "solid-js/web"
|
|
||||||
import { Button } from "./button"
|
import { Button } from "./button"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
import { Tooltip } from "./tooltip"
|
import { Tooltip } from "./tooltip"
|
||||||
|
|
@ -143,7 +134,6 @@ export function SessionTurn(
|
||||||
) {
|
) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const diffComponent = useDiffComponent()
|
|
||||||
|
|
||||||
const emptyMessages: MessageType[] = []
|
const emptyMessages: MessageType[] = []
|
||||||
const emptyParts: PartType[] = []
|
const emptyParts: PartType[] = []
|
||||||
|
|
@ -153,7 +143,6 @@ export function SessionTurn(
|
||||||
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
||||||
const emptyQuestions: QuestionRequest[] = []
|
const emptyQuestions: QuestionRequest[] = []
|
||||||
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
||||||
const emptyDiffs: FileDiff[] = []
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
|
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages)
|
||||||
|
|
@ -409,8 +398,7 @@ export function SessionTurn(
|
||||||
|
|
||||||
const response = createMemo(() => lastTextPart()?.text)
|
const response = createMemo(() => lastTextPart()?.text)
|
||||||
const responsePartId = createMemo(() => lastTextPart()?.id)
|
const responsePartId = createMemo(() => lastTextPart()?.id)
|
||||||
const messageDiffs = createMemo(() => message()?.summary?.diffs ?? emptyDiffs)
|
const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0)
|
||||||
const hasDiffs = createMemo(() => messageDiffs().length > 0)
|
|
||||||
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
||||||
|
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
@ -476,28 +464,12 @@ export function SessionTurn(
|
||||||
updateStickyHeight(sticky.getBoundingClientRect().height)
|
updateStickyHeight(sticky.getBoundingClientRect().height)
|
||||||
})
|
})
|
||||||
|
|
||||||
const diffInit = 20
|
|
||||||
const diffBatch = 20
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
retrySeconds: 0,
|
retrySeconds: 0,
|
||||||
diffsOpen: [] as string[],
|
|
||||||
diffLimit: diffInit,
|
|
||||||
status: rawStatus(),
|
status: rawStatus(),
|
||||||
duration: duration(),
|
duration: duration(),
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => message()?.id,
|
|
||||||
() => {
|
|
||||||
setStore("diffsOpen", [])
|
|
||||||
setStore("diffLimit", diffInit)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const r = retry()
|
const r = retry()
|
||||||
if (!r) {
|
if (!r) {
|
||||||
|
|
@ -727,7 +699,7 @@ export function SessionTurn(
|
||||||
<div class="sr-only" aria-live="polite">
|
<div class="sr-only" aria-live="polite">
|
||||||
{!working() && response() ? response() : ""}
|
{!working() && response() ? response() : ""}
|
||||||
</div>
|
</div>
|
||||||
<Show when={!working() && (response() || hasDiffs())}>
|
<Show when={!working() && response()}>
|
||||||
<div data-slot="session-turn-summary-section">
|
<div data-slot="session-turn-summary-section">
|
||||||
<div data-slot="session-turn-summary-header">
|
<div data-slot="session-turn-summary-header">
|
||||||
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
|
<h2 data-slot="session-turn-summary-title">{i18n.t("ui.sessionTurn.summary.response")}</h2>
|
||||||
|
|
@ -760,80 +732,6 @@ export function SessionTurn(
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={error() && !props.stepsExpanded}>
|
<Show when={error() && !props.stepsExpanded}>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
[data-component="sticky-accordion-header"] {
|
[data-component="sticky-accordion-header"] {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
|
}
|
||||||
&[data-expanded] {
|
|
||||||
z-index: 10;
|
[data-component="sticky-accordion-header"][data-expanded],
|
||||||
|
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"] {
|
||||||
&::before {
|
z-index: 10;
|
||||||
content: "";
|
}
|
||||||
z-index: -10;
|
|
||||||
position: absolute;
|
[data-component="sticky-accordion-header"][data-expanded]::before,
|
||||||
inset: 0;
|
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before {
|
||||||
background-color: var(--background-stronger);
|
content: "";
|
||||||
}
|
z-index: -10;
|
||||||
}
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: var(--background-stronger);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "تغييرات الجلسة",
|
"ui.sessionReview.title": "تغييرات الجلسة",
|
||||||
|
"ui.sessionReview.title.lastTurn": "تغييرات آخر دور",
|
||||||
"ui.sessionReview.diffStyle.unified": "موجد",
|
"ui.sessionReview.diffStyle.unified": "موجد",
|
||||||
"ui.sessionReview.diffStyle.split": "منقسم",
|
"ui.sessionReview.diffStyle.split": "منقسم",
|
||||||
"ui.sessionReview.expandAll": "توسيع الكل",
|
"ui.sessionReview.expandAll": "توسيع الكل",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Alterações da sessão",
|
"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.unified": "Unificado",
|
||||||
"ui.sessionReview.diffStyle.split": "Dividido",
|
"ui.sessionReview.diffStyle.split": "Dividido",
|
||||||
"ui.sessionReview.expandAll": "Expandir tudo",
|
"ui.sessionReview.expandAll": "Expandir tudo",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Sessionsændringer",
|
"ui.sessionReview.title": "Sessionsændringer",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Ændringer fra sidste tur",
|
||||||
"ui.sessionReview.diffStyle.unified": "Samlet",
|
"ui.sessionReview.diffStyle.unified": "Samlet",
|
||||||
"ui.sessionReview.diffStyle.split": "Opdelt",
|
"ui.sessionReview.diffStyle.split": "Opdelt",
|
||||||
"ui.sessionReview.expandAll": "Udvid alle",
|
"ui.sessionReview.expandAll": "Udvid alle",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ type Keys = keyof typeof en
|
||||||
|
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Sitzungsänderungen",
|
"ui.sessionReview.title": "Sitzungsänderungen",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Änderungen der letzten Runde",
|
||||||
"ui.sessionReview.diffStyle.unified": "Vereinheitlicht",
|
"ui.sessionReview.diffStyle.unified": "Vereinheitlicht",
|
||||||
"ui.sessionReview.diffStyle.split": "Geteilt",
|
"ui.sessionReview.diffStyle.split": "Geteilt",
|
||||||
"ui.sessionReview.expandAll": "Alle erweitern",
|
"ui.sessionReview.expandAll": "Alle erweitern",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Session changes",
|
"ui.sessionReview.title": "Session changes",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Last turn changes",
|
||||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||||
"ui.sessionReview.diffStyle.split": "Split",
|
"ui.sessionReview.diffStyle.split": "Split",
|
||||||
"ui.sessionReview.expandAll": "Expand all",
|
"ui.sessionReview.expandAll": "Expand all",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Cambios de la sesión",
|
"ui.sessionReview.title": "Cambios de la sesión",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Cambios del último turno",
|
||||||
"ui.sessionReview.diffStyle.unified": "Unificado",
|
"ui.sessionReview.diffStyle.unified": "Unificado",
|
||||||
"ui.sessionReview.diffStyle.split": "Dividido",
|
"ui.sessionReview.diffStyle.split": "Dividido",
|
||||||
"ui.sessionReview.expandAll": "Expandir todo",
|
"ui.sessionReview.expandAll": "Expandir todo",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Modifications de la session",
|
"ui.sessionReview.title": "Modifications de la session",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Modifications du dernier tour",
|
||||||
"ui.sessionReview.diffStyle.unified": "Unifié",
|
"ui.sessionReview.diffStyle.unified": "Unifié",
|
||||||
"ui.sessionReview.diffStyle.split": "Divisé",
|
"ui.sessionReview.diffStyle.split": "Divisé",
|
||||||
"ui.sessionReview.expandAll": "Tout développer",
|
"ui.sessionReview.expandAll": "Tout développer",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "セッションの変更",
|
"ui.sessionReview.title": "セッションの変更",
|
||||||
|
"ui.sessionReview.title.lastTurn": "前回ターンの変更",
|
||||||
"ui.sessionReview.diffStyle.unified": "Unified",
|
"ui.sessionReview.diffStyle.unified": "Unified",
|
||||||
"ui.sessionReview.diffStyle.split": "Split",
|
"ui.sessionReview.diffStyle.split": "Split",
|
||||||
"ui.sessionReview.expandAll": "すべて展開",
|
"ui.sessionReview.expandAll": "すべて展開",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "세션 변경 사항",
|
"ui.sessionReview.title": "세션 변경 사항",
|
||||||
|
"ui.sessionReview.title.lastTurn": "마지막 턴 변경 사항",
|
||||||
"ui.sessionReview.diffStyle.unified": "통합 보기",
|
"ui.sessionReview.diffStyle.unified": "통합 보기",
|
||||||
"ui.sessionReview.diffStyle.split": "분할 보기",
|
"ui.sessionReview.diffStyle.split": "분할 보기",
|
||||||
"ui.sessionReview.expandAll": "모두 펼치기",
|
"ui.sessionReview.expandAll": "모두 펼치기",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ type Keys = keyof typeof en
|
||||||
|
|
||||||
export const dict: Record<Keys, string> = {
|
export const dict: Record<Keys, string> = {
|
||||||
"ui.sessionReview.title": "Sesjonsendringer",
|
"ui.sessionReview.title": "Sesjonsendringer",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Endringer i siste tur",
|
||||||
"ui.sessionReview.diffStyle.unified": "Samlet",
|
"ui.sessionReview.diffStyle.unified": "Samlet",
|
||||||
"ui.sessionReview.diffStyle.split": "Delt",
|
"ui.sessionReview.diffStyle.split": "Delt",
|
||||||
"ui.sessionReview.expandAll": "Utvid alle",
|
"ui.sessionReview.expandAll": "Utvid alle",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Zmiany w sesji",
|
"ui.sessionReview.title": "Zmiany w sesji",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Zmiany z ostatniej tury",
|
||||||
"ui.sessionReview.diffStyle.unified": "Ujednolicony",
|
"ui.sessionReview.diffStyle.unified": "Ujednolicony",
|
||||||
"ui.sessionReview.diffStyle.split": "Podzielony",
|
"ui.sessionReview.diffStyle.split": "Podzielony",
|
||||||
"ui.sessionReview.expandAll": "Rozwiń wszystko",
|
"ui.sessionReview.expandAll": "Rozwiń wszystko",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "Изменения сессии",
|
"ui.sessionReview.title": "Изменения сессии",
|
||||||
|
"ui.sessionReview.title.lastTurn": "Изменения последнего хода",
|
||||||
"ui.sessionReview.diffStyle.unified": "Объединённый",
|
"ui.sessionReview.diffStyle.unified": "Объединённый",
|
||||||
"ui.sessionReview.diffStyle.split": "Разделённый",
|
"ui.sessionReview.diffStyle.split": "Разделённый",
|
||||||
"ui.sessionReview.expandAll": "Развернуть всё",
|
"ui.sessionReview.expandAll": "Развернуть всё",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "การเปลี่ยนแปลงเซสชัน",
|
"ui.sessionReview.title": "การเปลี่ยนแปลงเซสชัน",
|
||||||
|
"ui.sessionReview.title.lastTurn": "การเปลี่ยนแปลงของเทิร์นล่าสุด",
|
||||||
"ui.sessionReview.diffStyle.unified": "แบบรวม",
|
"ui.sessionReview.diffStyle.unified": "แบบรวม",
|
||||||
"ui.sessionReview.diffStyle.split": "แบบแยก",
|
"ui.sessionReview.diffStyle.split": "แบบแยก",
|
||||||
"ui.sessionReview.expandAll": "ขยายทั้งหมด",
|
"ui.sessionReview.expandAll": "ขยายทั้งหมด",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ type Keys = keyof typeof en
|
||||||
|
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "会话变更",
|
"ui.sessionReview.title": "会话变更",
|
||||||
|
"ui.sessionReview.title.lastTurn": "上一轮变更",
|
||||||
"ui.sessionReview.diffStyle.unified": "统一",
|
"ui.sessionReview.diffStyle.unified": "统一",
|
||||||
"ui.sessionReview.diffStyle.split": "拆分",
|
"ui.sessionReview.diffStyle.split": "拆分",
|
||||||
"ui.sessionReview.expandAll": "全部展开",
|
"ui.sessionReview.expandAll": "全部展开",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ type Keys = keyof typeof en
|
||||||
|
|
||||||
export const dict = {
|
export const dict = {
|
||||||
"ui.sessionReview.title": "工作階段變更",
|
"ui.sessionReview.title": "工作階段變更",
|
||||||
|
"ui.sessionReview.title.lastTurn": "上一輪變更",
|
||||||
"ui.sessionReview.diffStyle.unified": "整合",
|
"ui.sessionReview.diffStyle.unified": "整合",
|
||||||
"ui.sessionReview.diffStyle.split": "拆分",
|
"ui.sessionReview.diffStyle.split": "拆分",
|
||||||
"ui.sessionReview.expandAll": "全部展開",
|
"ui.sessionReview.expandAll": "全部展開",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai 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`
|
_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`
|
_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`
|
_Mode_: `subagent`
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ A general-purpose agent for researching complex questions and executing multi-st
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Explore
|
### Use explore
|
||||||
|
|
||||||
_Mode_: `subagent`
|
_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
|
## 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.
|
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`
|
- Global config: `~/.config/opencode/skills/<name>/SKILL.md`
|
||||||
- Project Claude-compatible: `.claude/skills/<name>/SKILL.md`
|
- Project Claude-compatible: `.claude/skills/<name>/SKILL.md`
|
||||||
- Global 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
|
## Understand discovery
|
||||||
|
|
||||||
For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree.
|
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`
|
const file = `${dir}/opencode-release-notes.txt`
|
||||||
await Bun.write(file, body)
|
await Bun.write(file, body)
|
||||||
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${file}`
|
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()
|
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
|
||||||
output.push(`release=${release.id}`)
|
output.push(`release=${release.databaseId}`)
|
||||||
output.push(`tag=${release.tagName}`)
|
output.push(`tag=${release.tagName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.1.49",
|
"version": "1.1.51",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue