Merge branch 'dev' into default-explore-models
This commit is contained in:
commit
64d689171f
128 changed files with 2942 additions and 1226 deletions
|
|
@ -71,6 +71,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||
- `clickListItem(container, filter)` - Click list item by key/text
|
||||
|
||||
**Selectors** (`selectors.ts`):
|
||||
|
|
@ -109,7 +112,7 @@ import { test, expect } from "@playwright/test"
|
|||
|
||||
### Error Handling
|
||||
|
||||
Tests should clean up after themselves:
|
||||
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||
|
||||
```typescript
|
||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
|
|
@ -120,6 +123,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||
})
|
||||
```
|
||||
|
||||
- Prefer `withSession(...)` for temp sessions
|
||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||
- Avoid calling `sdk.session.delete(...)` directly
|
||||
|
||||
### Timeouts
|
||||
|
||||
Default: 60s per test, 10s per assertion. Override when needed:
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import fs from "node:fs/promises"
|
|||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { modKey, serverUrl } from "./utils"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
popoverBodySelector,
|
||||
|
|
@ -18,7 +18,6 @@ import {
|
|||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
|
|
@ -61,9 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const main = page.locator("main")
|
||||
const classes = (await main.getAttribute("class")) ?? ""
|
||||
return classes.includes("xl:border-l")
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toBeVisible()
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
|
|
@ -75,48 +74,34 @@ export async function openSidebar(page: Page) {
|
|||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
await button.click()
|
||||
|
||||
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 })
|
||||
const opened = await expect(button)
|
||||
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
await button.click()
|
||||
|
||||
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 })
|
||||
const closed = await expect(button)
|
||||
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(main).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
|
|
@ -204,7 +189,7 @@ export async function createTestProject() {
|
|||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return root
|
||||
return resolveDirectory(root)
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
|
|
@ -220,7 +205,7 @@ export function sessionIDFromUrl(url: string) {
|
|||
}
|
||||
|
||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||
await expect(sessionEl).toBeVisible()
|
||||
await sessionEl.hover()
|
||||
return sessionEl
|
||||
|
|
@ -321,6 +306,57 @@ export async function clickListItem(
|
|||
return item
|
||||
}
|
||||
|
||||
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||
const data = await sdk.session
|
||||
.status()
|
||||
.then((x) => x.data ?? {})
|
||||
.catch(() => undefined)
|
||||
return data?.[sessionID]
|
||||
}
|
||||
|
||||
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||
let prev = ""
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!info) return true
|
||||
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||
if (next !== prev) {
|
||||
prev = next
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function cleanupSession(input: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
sdk?: ReturnType<typeof createSdk>
|
||||
}) {
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||
if (current && current.type !== "idle") {
|
||||
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||
}
|
||||
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function withSession<T>(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
title: string,
|
||||
|
|
@ -332,7 +368,7 @@ export async function withSession<T>(
|
|||
try {
|
||||
return await callback(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: session.id })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,6 +481,57 @@ export async function seedSessionPermission(
|
|||
return { id: result.id }
|
||||
}
|
||||
|
||||
export async function seedSessionTask(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
sessionID: string
|
||||
description: string
|
||||
prompt: string
|
||||
subagentType?: string
|
||||
},
|
||||
) {
|
||||
const text = [
|
||||
"Your only valid response is one task tool call.",
|
||||
`Use this JSON input: ${JSON.stringify({
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
subagent_type: input.subagentType ?? "general",
|
||||
})}`,
|
||||
"Do not output plain text.",
|
||||
"Wait for the task to start and return the child session id.",
|
||||
].join("\n")
|
||||
|
||||
const result = await seed({
|
||||
sdk,
|
||||
sessionID: input.sessionID,
|
||||
prompt: text,
|
||||
timeout: 90_000,
|
||||
probe: async () => {
|
||||
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
|
||||
const part = messages
|
||||
.flatMap((message) => message.parts)
|
||||
.find((part) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return false
|
||||
if (part.state.input?.description !== input.description) return false
|
||||
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||
})
|
||||
|
||||
if (!part) return
|
||||
const id = part.state.metadata?.sessionId
|
||||
if (typeof id !== "string" || !id) return
|
||||
const child = await sdk.session
|
||||
.get({ sessionID: id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!child?.id) return
|
||||
return { sessionID: id }
|
||||
},
|
||||
})
|
||||
|
||||
if (!result) throw new Error("Timed out seeding task tool")
|
||||
return result
|
||||
}
|
||||
|
||||
export async function seedSessionTodos(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
input: {
|
||||
|
|
@ -519,32 +606,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||
.first()
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (clicked) {
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (opened) {
|
||||
await expect(close).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) {
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
await expect(close).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
await trigger.click({ force: true })
|
||||
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||
}
|
||||
|
||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||
|
|
@ -557,11 +654,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
|||
|
||||
if (current === enabled) return
|
||||
|
||||
await openProjectMenu(page, projectSlug)
|
||||
const flip = async (timeout?: number) => {
|
||||
const menu = await openProjectMenu(page, projectSlug)
|
||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
return toggle.click({ force: true, timeout })
|
||||
}
|
||||
|
||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click({ force: true })
|
||||
const flipped = await flip(1500)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!flipped) await flip()
|
||||
|
||||
const expected = enabled ? "New workspace" : "New session"
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
|||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
|
|
@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||
|
||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||
await expect(second).toBeVisible()
|
||||
await second.scrollIntoViewIfNeeded()
|
||||
await second.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||
|
|
@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||
|
||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||
await expect(third).toBeVisible()
|
||||
await third.scrollIntoViewIfNeeded()
|
||||
await third.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||
|
|
@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
|
|||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
|
|
@ -13,6 +13,8 @@ type TestFixtures = {
|
|||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
|
|
@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||
},
|
||||
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 root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
return await callback({ directory, slug, gotoSession })
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
await cleanupTestProject(directory)
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,25 +1,15 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async () => {
|
||||
await withProject(async ({ slug }) => {
|
||||
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 menu = await openProjectMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -1,20 +1,45 @@
|
|||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
defocus,
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
setWorkspacesEnabled,
|
||||
sessionIDFromUrl,
|
||||
} from "../actions"
|
||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl } from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||
await page.evaluate(
|
||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||
const key = "opencode.global.dat:layout"
|
||||
const raw = localStorage.getItem(key)
|
||||
const data = raw ? JSON.parse(raw) : {}
|
||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||
const current =
|
||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||
? sidebar.workspaces
|
||||
: {}
|
||||
const next = { ...current }
|
||||
|
||||
if (enabled) next[directory] = true
|
||||
if (!enabled) delete next[directory]
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
...data,
|
||||
sidebar: {
|
||||
...sidebar,
|
||||
workspaces: next,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, enabled },
|
||||
)
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
|
|
@ -51,17 +76,16 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
let rootDir: string | undefined
|
||||
let workspaceDir: string | undefined
|
||||
let sessionID: string | undefined
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory, slug }) => {
|
||||
rootDir = directory
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
|
|
@ -80,6 +104,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||
const workspaceSlug = slugFromUrl(page.url())
|
||||
workspaceDir = base64Decode(workspaceSlug)
|
||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
||||
trackDirectory(workspaceDir)
|
||||
await openSidebar(page)
|
||||
|
||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
|
|
@ -103,7 +128,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
sessionID = created
|
||||
trackSession(created, workspaceDir)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
|
|
@ -124,20 +149,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
if (sessionID) {
|
||||
const id = sessionID
|
||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
||||
await Promise.all(
|
||||
dirs.map((directory) =>
|
||||
createSdk(directory)
|
||||
.session.delete({ sessionID: id })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (workspaceDir) {
|
||||
await cleanupTestProject(workspaceDir)
|
||||
}
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
|
|
@ -9,6 +9,26 @@ function slugFromUrl(url: string) {
|
|||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
if (slug !== prev) {
|
||||
prev = slug
|
||||
return ""
|
||||
}
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
return slugFromUrl(page.url())
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
|
|
@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (slug === root) return ""
|
||||
if (seen.includes(slug)) return ""
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const slug = await waitSlug(page, [root, ...seen])
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
|
|
@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
|
|||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||
const next = await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
return next
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
await openWorkspaceNewSession(page, slug)
|
||||
const next = await openWorkspaceNewSession(page, slug)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
|
|
@ -76,13 +84,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return sessionID
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return { sessionID, slug: next }
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
|
|
@ -97,48 +105,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
|
|||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root }) => {
|
||||
const workspaces = [] as { slug: string; directory: string }[]
|
||||
const sessions = [] as string[]
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
const first = await createWorkspace(page, root, [])
|
||||
trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
workspaces.push(first)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
workspaces.push(second)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, first.directory)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
sessions.push(firstSession)
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
trackSession(secondSession.sessionID, second.directory)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
sessions.push(secondSession)
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
trackSession(thirdSession.sessionID, first.directory)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
sessions.push(thirdSession)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
||||
} finally {
|
||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
||||
await Promise.all(
|
||||
sessions.map((sessionID) =>
|
||||
Promise.all(
|
||||
dirs.map((dir) =>
|
||||
createSdk(dir)
|
||||
.session.delete({ sessionID })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
||||
}
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,6 +22,26 @@ function slugFromUrl(url: string) {
|
|||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
if (slug !== prev) {
|
||||
prev = slug
|
||||
return ""
|
||||
}
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
return slugFromUrl(page.url())
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
|
|
@ -29,17 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const slug = await waitSlug(page, [rootSlug])
|
||||
const dir = base64Decode(slug)
|
||||
|
||||
await openSidebar(page)
|
||||
|
|
@ -91,18 +101,7 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const currentSlug = slugFromUrl(page.url())
|
||||
return currentSlug.length > 0 && currentSlug !== slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
const workspaceSlug = await waitSlug(page, [slug])
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
|
||||
await openSidebar(page)
|
||||
|
|
@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
|
|
@ -336,9 +335,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
|
|
@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
|||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
const value = `restore ${Date.now()}`
|
||||
|
||||
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await gotoSession(session.id)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(value)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
181
packages/app/e2e/prompt/prompt-history.spec.ts
Normal file
181
packages/app/e2e/prompt/prompt-history.spec.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
if (!("type" in part) || part.type !== "tool") return false
|
||||
if (!("tool" in part) || part.tool !== "bash") return false
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
async function edge(page: Page, pos: "start" | "end") {
|
||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
const nodes: Text[] = []
|
||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||
nodes.push(node as Text)
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const node = document.createTextNode("")
|
||||
el.appendChild(node)
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||
const range = document.createRange()
|
||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}, pos)
|
||||
}
|
||||
|
||||
async function wait(page: Page, value: string) {
|
||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||
}
|
||||
|
||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
const part = messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter(isBash)
|
||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||
|
||||
if (!part || part.state.status !== "completed") return
|
||||
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
})
|
||||
})
|
||||
|
||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, first, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, second, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
})
|
||||
62
packages/app/e2e/prompt/prompt-shell.spec.ts
Normal file
62
packages/app/e2e/prompt/prompt-shell.spec.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
if (!("type" in part) || part.type !== "tool") return false
|
||||
if (!("tool" in part) || part.tool !== "bash") return false
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
const sdk = createSdk(directory)
|
||||
const prompt = page.locator(promptSelector)
|
||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||
|
||||
await gotoSession()
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
trackSession(id, directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||
const msg = list.findLast(
|
||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||
)
|
||||
if (!msg) return
|
||||
|
||||
const part = msg.parts
|
||||
.filter(isBash)
|
||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||
|
||||
if (!part || part.state.status !== "completed") return
|
||||
const output =
|
||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
if (!output.includes("README.md")) return
|
||||
|
||||
return { cwd: directory, output }
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||
|
||||
await expect(prompt).toHaveText("")
|
||||
})
|
||||
})
|
||||
64
packages/app/e2e/prompt/prompt-slash-share.spec.ts
Normal file
64
packages/app/e2e/prompt/prompt-slash-share.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "e2e share 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("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await seed(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl, withSession } from "../actions"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
|
@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||
.toContain(token)
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
|
|
|
|||
37
packages/app/e2e/session/session-child-navigation.spec.ts
Normal file
37
packages/app/e2e/session/session-child-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { seedSessionTask, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const errs: string[] = []
|
||||
const onError = (err: Error) => {
|
||||
errs.push(err.message)
|
||||
}
|
||||
page.on("pageerror", onError)
|
||||
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
})
|
||||
|
||||
try {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(errs).toEqual([])
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
|
|
@ -26,7 +26,7 @@ async function withDockSession<T>(
|
|||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: session.id })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||
},
|
||||
)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const main = page.locator("main")
|
||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
||||
|
||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
||||
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
||||
|
||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||
const finalClosed = finalClasses.includes("xl:border-l")
|
||||
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
expect(finalClosed).toBe(initiallyClosed)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
|
|
@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||
await gotoSession(one.id)
|
||||
await closeSidebar(page)
|
||||
|
||||
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
||||
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
|
||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
await expect(oneItem).toBeVisible()
|
||||
await expect(twoItem).toBeVisible()
|
||||
|
||||
const item = await hoverSessionItem(page, one.id)
|
||||
await item
|
||||
|
|
@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||
.first()
|
||||
.click()
|
||||
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
await expect(twoItem).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: one.id })
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { cleanupSession, openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
|
|
@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
|||
|
||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
await target.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: one.id })
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|||
await gotoSession()
|
||||
|
||||
await openSidebar(page)
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||
|
|
@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
|
|||
await gotoSession(session1.id)
|
||||
|
||||
await openSidebar(page)
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await gotoSession(session2.id)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
const opened = await page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||
|
|
|
|||
120
packages/app/e2e/terminal/terminal-tabs.spec.ts
Normal file
120
packages/app/e2e/terminal/terminal-tabs.spec.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
|
||||
type State = {
|
||||
active?: string
|
||||
all: Array<{
|
||||
id: string
|
||||
title: string
|
||||
titleNumber: number
|
||||
buffer?: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function open(page: Page) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const visible = await terminal.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
}
|
||||
|
||||
async function run(page: Page, cmd: string) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).toBeVisible()
|
||||
await terminal.click()
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
}
|
||||
|
||||
async function store(page: Page, key: string) {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) return JSON.parse(raw) as State
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const next = localStorage.key(i)
|
||||
if (!next?.endsWith(":workspace:terminal")) continue
|
||||
const value = localStorage.getItem(next)
|
||||
if (!value) continue
|
||||
return JSON.parse(value) as State
|
||||
}
|
||||
}, key)
|
||||
}
|
||||
|
||||
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await run(page, `echo ${one}`)
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
await run(page, `echo ${two}`)
|
||||
|
||||
await tabs
|
||||
.filter({ hasText: /Terminal 1/ })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return first.includes(one) && second.includes(two)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await second.hover()
|
||||
await page
|
||||
.getByRole("button", { name: /close terminal/i })
|
||||
.nth(1)
|
||||
.click({ force: true })
|
||||
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
await expect(tabs).toHaveCount(1)
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return {
|
||||
count: state?.all.length ?? 0,
|
||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||
}
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
|
|
@ -14,6 +14,12 @@ export function createSdk(directory?: string) {
|
|||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string) {
|
||||
return createSdk(directory)
|
||||
.path.get()
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
const result = await sdk.path.get()
|
||||
|
|
@ -33,3 +39,9 @@ export function dirPath(directory: string) {
|
|||
export function sessionPath(directory: string, sessionID?: string) {
|
||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||
}
|
||||
|
||||
export function workspacePersistKey(directory: string, key: string) {
|
||||
const head = directory.slice(0, 12) || "workspace"
|
||||
const sum = checksum(directory) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -244,7 +244,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
pendingAutoAccept: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
|
|
@ -253,7 +252,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
|
|
@ -306,12 +304,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(sessionKey, () => {
|
||||
setStore("pendingAutoAccept", false)
|
||||
}),
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
|
|
@ -961,7 +953,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return store.pendingAutoAccept
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
|
||||
|
|
@ -1211,9 +1203,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
contenteditable="true"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
|
|
@ -1336,7 +1328,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!params.id) {
|
||||
setStore("pendingAutoAccept", (value) => !value)
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
return
|
||||
}
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ const DICT: Record<Locale, Dictionary> = {
|
|||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||
|
|
@ -217,6 +218,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
|||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { autoRespondsPermission } from "./permission-auto-respond"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
|
|
@ -60,4 +60,43 @@ describe("autoRespondsPermission", () => {
|
|||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("session-level override takes precedence over directory-level", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDirectoryAutoAccepting", () => {
|
||||
test("returns true when directory key is set", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when directory key is not set", () => {
|
||||
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when directory key is explicitly false", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,9 +5,19 @@ export function acceptKey(sessionID: string, directory?: string) {
|
|||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
export function directoryAcceptKey(directory: string) {
|
||||
return `${base64Encode(directory)}/*`
|
||||
}
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
return autoAccept[key] ?? false
|
||||
}
|
||||
|
||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
|
|
@ -7,7 +7,12 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
|||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
} from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
|
|
@ -76,6 +81,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
}),
|
||||
)
|
||||
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
|
|
@ -119,6 +143,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
|
|
@ -142,6 +170,36 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
|
|
@ -185,6 +243,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
isAutoAcceptingDirectory,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
|
|
@ -193,6 +252,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
|
|
@ -201,6 +267,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||
disable(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -511,11 +511,13 @@ export const dict = {
|
|||
"session.review.change.other": "Changes",
|
||||
"session.review.loadingChanges": "Loading changes...",
|
||||
"session.review.empty": "No changes in this session yet",
|
||||
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
||||
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
"session.files.empty": "No files",
|
||||
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
||||
|
||||
"session.messages.renderEarlier": "Render earlier messages",
|
||||
|
|
|
|||
|
|
@ -1 +1,29 @@
|
|||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
|
||||
@layer components {
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* gap-3 */
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@container getting-started (min-width: 17rem) {
|
||||
[data-component="getting-started-actions"] {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
|
|
@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
|||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ invalid: "" })
|
||||
const directory = createMemo(() => {
|
||||
return decode64(params.dir) ?? ""
|
||||
})
|
||||
const globalSDK = useGlobalSDK()
|
||||
const directory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.dir) return
|
||||
if (directory()) return
|
||||
if (store.invalid === params.dir) return
|
||||
setStore("invalid", params.dir)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
const raw = directory()
|
||||
if (!raw) {
|
||||
if (state.invalid === params.dir) return
|
||||
setState("invalid", params.dir)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const current = params.dir
|
||||
globalSDK
|
||||
.createClient({
|
||||
directory: raw,
|
||||
throwOnError: true,
|
||||
})
|
||||
.path.get()
|
||||
.then((x) => {
|
||||
if (params.dir !== current) return
|
||||
const next = x.data?.directory ?? raw
|
||||
batch(() => {
|
||||
setState("invalid", "")
|
||||
setState("resolved", next)
|
||||
})
|
||||
if (next === raw) return
|
||||
const path = location.pathname.slice(current.length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
if (params.dir !== current) return
|
||||
batch(() => {
|
||||
setState("invalid", "")
|
||||
setState("resolved", raw)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={directory}>
|
||||
<SyncProvider>
|
||||
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
<Show when={state.resolved}>
|
||||
{(resolved) => (
|
||||
<SDKProvider directory={resolved}>
|
||||
<SyncProvider>
|
||||
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,8 @@ import {
|
|||
ParentProps,
|
||||
Show,
|
||||
untrack,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
|
@ -20,7 +19,6 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|||
import { decode64 } from "@/utils/base64"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
|
|
@ -59,7 +57,6 @@ import { Titlebar } from "@/components/titlebar"
|
|||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import {
|
||||
childMapByParent,
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
|
|
@ -96,6 +93,7 @@ export default function Layout(props: ParentProps) {
|
|||
workspaceName: {} as Record<string, string>,
|
||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||
workspaceExpanded: {} as Record<string, boolean>,
|
||||
gettingStartedDismissed: false,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -157,6 +155,8 @@ export default function Layout(props: ParentProps) {
|
|||
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
|
||||
const navLeave = { current: undefined as number | undefined }
|
||||
const [sortNow, setSortNow] = createSignal(Date.now())
|
||||
const [sizing, setSizing] = createSignal(false)
|
||||
let sizet: number | undefined
|
||||
let sortNowInterval: ReturnType<typeof setInterval> | undefined
|
||||
const sortNowTimeout = setTimeout(
|
||||
() => {
|
||||
|
|
@ -169,7 +169,7 @@ export default function Layout(props: ParentProps) {
|
|||
const aim = createAim({
|
||||
enabled: () => !layout.sidebar.opened(),
|
||||
active: () => state.hoverProject,
|
||||
el: () => state.nav,
|
||||
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||
onActivate: (directory) => {
|
||||
globalSync.child(directory)
|
||||
setState("hoverProject", directory)
|
||||
|
|
@ -181,9 +181,23 @@ export default function Layout(props: ParentProps) {
|
|||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
clearTimeout(sortNowTimeout)
|
||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
if (peekt !== undefined) clearTimeout(peekt)
|
||||
aim.reset()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const stop = () => setSizing(false)
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
})
|
||||
})
|
||||
|
||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
|
||||
const setHoverProject = (value: string | undefined) => {
|
||||
|
|
@ -194,12 +208,54 @@ export default function Layout(props: ParentProps) {
|
|||
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
||||
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
||||
|
||||
const disarm = () => {
|
||||
if (navLeave.current === undefined) return
|
||||
clearTimeout(navLeave.current)
|
||||
navLeave.current = undefined
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (state.hoverProject === undefined) return
|
||||
disarm()
|
||||
navLeave.current = window.setTimeout(() => {
|
||||
navLeave.current = undefined
|
||||
setHoverProject(undefined)
|
||||
setState("hoverSession", undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
|
||||
const [peeked, setPeeked] = createSignal(false)
|
||||
let peekt: number | undefined
|
||||
|
||||
const hoverProjectData = createMemo(() => {
|
||||
const id = state.hoverProject
|
||||
if (!id) return
|
||||
return layout.projects.list().find((project) => project.worktree === id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const p = hoverProjectData()
|
||||
if (p) {
|
||||
if (peekt !== undefined) {
|
||||
clearTimeout(peekt)
|
||||
peekt = undefined
|
||||
}
|
||||
setPeek(p)
|
||||
setPeeked(true)
|
||||
return
|
||||
}
|
||||
|
||||
setPeeked(false)
|
||||
if (peek() === undefined) return
|
||||
if (peekt !== undefined) clearTimeout(peekt)
|
||||
peekt = window.setTimeout(() => {
|
||||
peekt = undefined
|
||||
setPeek(undefined)
|
||||
}, 180)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.sidebar.opened()) return
|
||||
setHoverProject(undefined)
|
||||
|
|
@ -1125,6 +1181,12 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const [data] = globalSync.child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await globalSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
|
|
@ -1815,7 +1877,8 @@ export default function Layout(props: ParentProps) {
|
|||
setHoverSession,
|
||||
}
|
||||
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||
const projectName = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return ""
|
||||
|
|
@ -1841,12 +1904,19 @@ export default function Layout(props: ParentProps) {
|
|||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
|
||||
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
|
||||
"border border-b-0 border-border-weak-base": !merged(),
|
||||
"border-l border-t border-border-weaker-base": merged(),
|
||||
"bg-background-base": merged(),
|
||||
"bg-background-stronger": !merged(),
|
||||
"flex-1 min-w-0": panelProps.mobile,
|
||||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
style={{
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
}}
|
||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||
>
|
||||
<Show when={panelProps.project} keyed>
|
||||
<Show when={panelProps.project}>
|
||||
{(p) => (
|
||||
<>
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
|
|
@ -1855,7 +1925,7 @@ export default function Layout(props: ParentProps) {
|
|||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => renameProject(p, next)}
|
||||
onSave={(next) => renameProject(p(), next)}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
|
|
@ -1864,7 +1934,7 @@ export default function Layout(props: ParentProps) {
|
|||
<Tooltip
|
||||
placement="bottom"
|
||||
gutter={2}
|
||||
value={p.worktree}
|
||||
value={p().worktree}
|
||||
class="shrink-0"
|
||||
contentStyle={{
|
||||
"max-width": "640px",
|
||||
|
|
@ -1872,7 +1942,7 @@ export default function Layout(props: ParentProps) {
|
|||
}}
|
||||
>
|
||||
<span class="text-12-regular text-text-base truncate select-text">
|
||||
{p.worktree.replace(homedir(), "~")}
|
||||
{p().worktree.replace(homedir(), "~")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -1883,33 +1953,33 @@ export default function Layout(props: ParentProps) {
|
|||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
||||
onSelect={() => toggleProjectWorkspaces(p)}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
|
||||
onSelect={() => toggleProjectWorkspaces(p())}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(p.worktree)()
|
||||
{layout.sidebar.workspaces(p().worktree)()
|
||||
? language.t("sidebar.workspaces.disable")
|
||||
: language.t("sidebar.workspaces.enable")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-clear-notifications"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
disabled={unseenCount() === 0}
|
||||
onSelect={clearNotifications}
|
||||
>
|
||||
|
|
@ -1920,8 +1990,8 @@ export default function Layout(props: ParentProps) {
|
|||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(p.worktree)}
|
||||
onSelect={() => closeProject(p.worktree)}
|
||||
data-project={base64Encode(p().worktree)}
|
||||
onSelect={() => closeProject(p().worktree)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
|
|
@ -1941,7 +2011,7 @@ export default function Layout(props: ParentProps) {
|
|||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
|
|
@ -1949,7 +2019,7 @@ export default function Layout(props: ParentProps) {
|
|||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
project={p}
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
|
|
@ -1959,7 +2029,7 @@ export default function Layout(props: ParentProps) {
|
|||
>
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -1984,7 +2054,7 @@ export default function Layout(props: ParentProps) {
|
|||
<SortableWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
directory={directory}
|
||||
project={p}
|
||||
project={p()}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
|
|
@ -2009,25 +2079,31 @@ export default function Layout(props: ParentProps) {
|
|||
</Show>
|
||||
|
||||
<div
|
||||
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
||||
class="shrink-0 px-3 py-3"
|
||||
classList={{
|
||||
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
}}
|
||||
>
|
||||
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
||||
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||
<div class="p-3 flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("sidebar.gettingStarted.line1")}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("sidebar.gettingStarted.line2")}
|
||||
</div>
|
||||
</div>
|
||||
<div data-component="getting-started-actions">
|
||||
<Button size="large" icon="plus-small" onClick={connectProvider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
|
||||
Not yet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
|
||||
size="large"
|
||||
icon="plus"
|
||||
onClick={connectProvider}
|
||||
>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2037,33 +2113,27 @@ export default function Layout(props: ParentProps) {
|
|||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar />
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<div class="flex-1 min-h-0 relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"relative shrink-0": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (navLeave.current === undefined) return
|
||||
clearTimeout(navLeave.current)
|
||||
navLeave.current = undefined
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
navLeave.current = window.setTimeout(() => {
|
||||
navLeave.current = undefined
|
||||
setHoverProject(undefined)
|
||||
setState("hoverSession", undefined)
|
||||
}, 300)
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
|
|
@ -2090,30 +2160,36 @@ export default function Layout(props: ParentProps) {
|
|||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} />}
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
||||
{(worktree) => (
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||
<SidebarPanel project={hoverProjectData()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={layout.sidebar.resize}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setSizing(true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
|
|
@ -2129,7 +2205,7 @@ export default function Layout(props: ParentProps) {
|
|||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
|
|
@ -2162,16 +2238,66 @@ export default function Layout(props: ParentProps) {
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
<main
|
||||
<div
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
||||
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!sizing(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -163,7 +163,6 @@ const SessionHoverPreview = (props: {
|
|||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={props.trigger}
|
||||
mount={!props.mobile ? props.nav() : undefined}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ const ProjectTile = (props: {
|
|||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
|
|
@ -35,10 +35,22 @@ export const SidebarContent = (props: {
|
|||
}): JSX.Element => {
|
||||
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
||||
const placement = () => (props.mobile ? "bottom" : "right")
|
||||
let panel: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = panel
|
||||
if (!el) return
|
||||
if (expanded()) {
|
||||
el.removeAttribute("inert")
|
||||
return
|
||||
}
|
||||
el.setAttribute("inert", "")
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full overflow-hidden">
|
||||
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
||||
<div
|
||||
data-component="sidebar-rail"
|
||||
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
||||
onMouseMove={props.aimMove}
|
||||
>
|
||||
|
|
@ -100,7 +112,15 @@ export const SidebarContent = (props: {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>{props.renderPanel()}</Show>
|
||||
<div
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
|
|||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!props.pendingRename()) return
|
||||
|
|
@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
|
|||
loadMore: () => Promise<void>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<nav class="flex flex-col gap-1 px-3">
|
||||
<Show when={props.showNew()}>
|
||||
<NewSessionItem
|
||||
slug={props.slug()}
|
||||
|
|
@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
|
|||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<nav class="flex flex-col gap-1 px-3">
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import {
|
||||
onCleanup,
|
||||
|
|
@ -20,11 +20,13 @@ import { createStore } from "solid-js/store"
|
|||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
|
|
@ -41,6 +43,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
|||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
|
|
@ -252,6 +255,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|||
}
|
||||
|
||||
export default function Page() {
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
|
|
@ -278,6 +282,7 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
git: false,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
scrollGesture: 0,
|
||||
scroll: {
|
||||
|
|
@ -490,10 +495,51 @@ export default function Page() {
|
|||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (!project || project.vcs) return "session.review.empty"
|
||||
return "session.review.noVcs"
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.empty"
|
||||
})
|
||||
|
||||
function upsert(next: Project) {
|
||||
const list = globalSync.data.project
|
||||
sync.set("project", next.id)
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx >= 0) {
|
||||
globalSync.set(
|
||||
"project",
|
||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||
)
|
||||
return
|
||||
}
|
||||
const at = list.findIndex((item) => item.id > next.id)
|
||||
if (at >= 0) {
|
||||
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
return
|
||||
}
|
||||
globalSync.set("project", [...list, next])
|
||||
}
|
||||
|
||||
function initGit() {
|
||||
if (ui.git) return
|
||||
setUi("git", true)
|
||||
void sdk.client.project
|
||||
.initGit()
|
||||
.then((x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setUi("git", false)
|
||||
})
|
||||
}
|
||||
|
||||
let inputRef!: HTMLDivElement
|
||||
let promptDock: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
|
|
@ -727,23 +773,28 @@ export default function Page() {
|
|||
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="small"
|
||||
valueClass="text-14-medium"
|
||||
/>
|
||||
)
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<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="small"
|
||||
valueClass="text-14-medium"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
|
@ -809,9 +860,23 @@ export default function Page() {
|
|||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : reviewEmptyKey() === "session.review.noVcs" ? (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||
<div
|
||||
class="text-14-regular text-text-base max-w-md"
|
||||
style={{ "line-height": "var(--line-height-normal)" }}
|
||||
>
|
||||
Track, review, and undo changes in this project
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -611,7 +611,7 @@ export function MessageTimeline(props: {
|
|||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
|
|
@ -646,12 +646,12 @@ export function MessageTimeline(props: {
|
|||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<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} />)}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ export function SessionSidePanel(props: {
|
|||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
|
|
@ -87,6 +93,21 @@ export function SessionSidePanel(props: {
|
|||
return out
|
||||
})
|
||||
|
||||
const empty = (msg: string) => (
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="h-12 shrink-0" aria-hidden />
|
||||
<div class="flex-1 pb-30 flex items-center justify-center text-center">
|
||||
<div class="text-12-regular text-text-weak">{msg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const nofiles = createMemo(() => {
|
||||
const state = file.tree.state("")
|
||||
if (!state?.loaded) return false
|
||||
return file.tree.children("").length === 0
|
||||
})
|
||||
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
|
|
@ -145,17 +166,8 @@ export function SessionSidePanel(props: {
|
|||
|
||||
const [store, setStore] = createStore({
|
||||
activeDraggable: undefined as string | undefined,
|
||||
fileTreeScrolled: false,
|
||||
})
|
||||
|
||||
let changesEl: HTMLDivElement | undefined
|
||||
let allEl: HTMLDivElement | undefined
|
||||
|
||||
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
|
||||
const next = (el?.scrollTop ?? 0) > 0
|
||||
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
|
|
@ -176,11 +188,6 @@ export function SessionSidePanel(props: {
|
|||
setStore("activeDraggable", undefined)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.fileTree.opened()) return
|
||||
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
|
|
@ -207,7 +214,7 @@ export function SessionSidePanel(props: {
|
|||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
class="relative min-w-0 h-full border-l border-border-weak-base flex"
|
||||
class="relative min-w-0 h-full border-l border-border-weaker-base flex"
|
||||
classList={{
|
||||
"flex-1": reviewOpen(),
|
||||
"shrink-0": !reviewOpen(),
|
||||
|
|
@ -331,9 +338,7 @@ export function SessionSidePanel(props: {
|
|||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path()} keyed>
|
||||
{(p) => <FileVisual active path={p} />}
|
||||
</Show>
|
||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
|
@ -347,7 +352,7 @@ export function SessionSidePanel(props: {
|
|||
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weak-base": reviewOpen() }}
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
|
|
@ -356,7 +361,7 @@ export function SessionSidePanel(props: {
|
|||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
|
|
@ -365,12 +370,7 @@ export function SessionSidePanel(props: {
|
|||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content
|
||||
value="changes"
|
||||
ref={(el: HTMLDivElement) => (changesEl = el)}
|
||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
||||
class="bg-background-stronger px-3 py-0"
|
||||
>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
|
|
@ -384,6 +384,7 @@ export function SessionSidePanel(props: {
|
|||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
|
|
@ -392,25 +393,22 @@ export function SessionSidePanel(props: {
|
|||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
||||
{language.t("session.review.noChanges")}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>{empty(language.t(reviewEmptyKey()))}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
value="all"
|
||||
ref={(el: HTMLDivElement) => (allEl = el)}
|
||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
||||
class="bg-background-stronger px-3 py-0"
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export function TerminalPanel() {
|
|||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
|
|
@ -187,12 +187,12 @@ export function TerminalPanel() {
|
|||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10">
|
||||
<Tabs.List class="h-10 border-b border-border-weaker-base">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)} keyed>
|
||||
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -217,10 +217,10 @@ export function TerminalPanel() {
|
|||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)} keyed>
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -229,14 +229,14 @@ export function TerminalPanel() {
|
|||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId)} keyed>
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t.title,
|
||||
titleNumber: t.titleNumber,
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -261,24 +261,35 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
}),
|
||||
])
|
||||
|
||||
const isAutoAcceptActive = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
}
|
||||
|
||||
const permissionCommands = createMemo(() => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title:
|
||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
disabled: false,
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
if (sessionID) {
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
} else {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
}
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
showToast({
|
||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
title: active
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
description: active
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function PrivacyPolicy() {
|
|||
<section data-component="brand-content">
|
||||
<article data-component="privacy-policy">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="effective-date">Effective date: Dec 16, 2025</p>
|
||||
<p class="effective-date">Effective date: Mar 6, 2026</p>
|
||||
|
||||
<p>
|
||||
At OpenCode, we take your privacy seriously. Please read this Privacy Policy to learn how we treat your
|
||||
|
|
@ -30,7 +30,10 @@ export default function PrivacyPolicy() {
|
|||
By using or accessing our Services in any manner, you acknowledge that you accept the practices and
|
||||
policies outlined below, and you hereby consent that we will collect, use and disclose your
|
||||
information as described in this Privacy Policy.
|
||||
</strong>
|
||||
</strong>{" "}
|
||||
For clarity, our open source software that is not provided to you on a hosted basis is subject to the
|
||||
open source license and terms set forth on the applicable repository where you access such open source
|
||||
software, and such license and terms will exclusively govern your use of such open source software.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
|
@ -382,9 +385,7 @@ export default function PrivacyPolicy() {
|
|||
</ul>
|
||||
|
||||
<h3>Parties You Authorize, Access or Authenticate</h3>
|
||||
<ul>
|
||||
<li>Home buyers</li>
|
||||
</ul>
|
||||
<p>Parties You Authorize, Access or Authenticate.</p>
|
||||
|
||||
<h3>Legal Obligations</h3>
|
||||
<p>
|
||||
|
|
@ -1502,6 +1503,7 @@ export default function PrivacyPolicy() {
|
|||
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
|
||||
</li>
|
||||
<li>Phone: +1 415 794-0209</li>
|
||||
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ export default function TermsOfService() {
|
|||
<section data-component="brand-content">
|
||||
<article data-component="terms-of-service">
|
||||
<h1>Terms of Use</h1>
|
||||
<p class="effective-date">Effective date: Dec 16, 2025</p>
|
||||
<p class="effective-date">Effective date: Mar 6, 2026</p>
|
||||
|
||||
<p>
|
||||
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of OpenCode
|
||||
(the "Services"). If you have any questions, comments, or concerns regarding these terms or the
|
||||
Services, please contact us at:
|
||||
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
|
||||
OpenCode's website, inference product and hosted software offering (the "Services"). If you have
|
||||
any questions, comments, or concerns regarding these terms or the Services, please contact us at:
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
|
@ -44,7 +44,10 @@ export default function TermsOfService() {
|
|||
and/or conditions ("Additional Terms"), which are incorporated herein by reference, and you understand
|
||||
and agree that by using or participating in any such Services, you agree to also comply with these
|
||||
Additional Terms.
|
||||
</strong>
|
||||
</strong>{" "}
|
||||
For clarity, our open source software that is not provided to you on a hosted basis is subject to the
|
||||
open source license and terms set forth on the applicable repository where you access such open source
|
||||
software, and such license and terms will exclusively govern your use of such open source software.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
|
@ -460,10 +463,10 @@ export default function TermsOfService() {
|
|||
<h4>Opt-out</h4>
|
||||
<p>
|
||||
You have the right to opt out of the provisions of this Section by sending written notice of your
|
||||
decision to opt out to the following address: [ADDRESS], [CITY], Canada [ZIP CODE] postmarked within
|
||||
thirty (30) days of first accepting these Terms. You must include (i) your name and residence address,
|
||||
(ii) the email address and/or telephone number associated with your account, and (iii) a clear statement
|
||||
that you want to opt out of these Terms' arbitration agreement.
|
||||
decision to opt out to the following address: 2443 Fillmore St #380-6343, San Francisco, CA 94115,
|
||||
United States postmarked within thirty (30) days of first accepting these Terms. You must include (i)
|
||||
your name and residence address, (ii) the email address and/or telephone number associated with your
|
||||
account, and (iii) a clear statement that you want to opt out of these Terms' arbitration agreement.
|
||||
</p>
|
||||
|
||||
<h4>Exclusive Venue</h4>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ function detectLocale(): Locale {
|
|||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("en")) return "en"
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ function detectLocale(): Locale {
|
|||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("en")) return "en"
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
||||
import { fn } from "@opencode-ai/util/fn"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Identifier } from "@opencode-ai/util/identifier"
|
||||
import z from "zod"
|
||||
import { Storage } from "./storage"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
|
||||
export namespace Share {
|
||||
export const Info = z.object({
|
||||
|
|
@ -38,6 +36,81 @@ export namespace Share {
|
|||
])
|
||||
export type Data = z.infer<typeof Data>
|
||||
|
||||
type Snapshot = {
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
type Compaction = {
|
||||
event?: string
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
function key(item: Data) {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `part/${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
|
||||
function merge(...items: Data[][]) {
|
||||
const map = new Map<string, Data>()
|
||||
for (const list of items) {
|
||||
for (const item of list) {
|
||||
map.set(key(item), item)
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([, item]) => item)
|
||||
}
|
||||
|
||||
async function readSnapshot(shareID: string) {
|
||||
return (await Storage.read<Snapshot>(["share_snapshot", shareID]))?.data
|
||||
}
|
||||
|
||||
async function writeSnapshot(shareID: string, data: Data[]) {
|
||||
await Storage.write<Snapshot>(["share_snapshot", shareID], { data })
|
||||
}
|
||||
|
||||
async function legacy(shareID: string) {
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
|
||||
data: [],
|
||||
event: undefined,
|
||||
}
|
||||
const list = await Storage.list({
|
||||
prefix: ["share_event", shareID],
|
||||
before: compaction.event,
|
||||
}).then((x) => x.toReversed())
|
||||
if (list.length === 0) {
|
||||
if (compaction.data.length > 0) await writeSnapshot(shareID, compaction.data)
|
||||
return compaction.data
|
||||
}
|
||||
|
||||
const next = merge(
|
||||
compaction.data,
|
||||
await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) =>
|
||||
x.flatMap((item) => item ?? []),
|
||||
),
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
Storage.write(["share_compaction", shareID], {
|
||||
event: list.at(-1)?.at(-1),
|
||||
data: next,
|
||||
}),
|
||||
writeSnapshot(shareID, next),
|
||||
])
|
||||
return next
|
||||
}
|
||||
|
||||
export const create = fn(z.object({ sessionID: z.string() }), async (body) => {
|
||||
const isTest = process.env.NODE_ENV === "test" || body.sessionID.startsWith("test_")
|
||||
const info: Info = {
|
||||
|
|
@ -47,7 +120,7 @@ export namespace Share {
|
|||
}
|
||||
const exists = await get(info.id)
|
||||
if (exists) throw new Errors.AlreadyExists(info.id)
|
||||
await Storage.write(["share", info.id], info)
|
||||
await Promise.all([Storage.write(["share", info.id], info), writeSnapshot(info.id, [])])
|
||||
return info
|
||||
})
|
||||
|
||||
|
|
@ -60,8 +133,13 @@ export namespace Share {
|
|||
if (!share) throw new Errors.NotFound(body.id)
|
||||
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
|
||||
await Storage.remove(["share", body.id])
|
||||
const list = await Storage.list({ prefix: ["share_data", body.id] })
|
||||
for (const item of list) {
|
||||
const groups = await Promise.all([
|
||||
Storage.list({ prefix: ["share_snapshot", body.id] }),
|
||||
Storage.list({ prefix: ["share_compaction", body.id] }),
|
||||
Storage.list({ prefix: ["share_event", body.id] }),
|
||||
Storage.list({ prefix: ["share_data", body.id] }),
|
||||
])
|
||||
for (const item of groups.flat()) {
|
||||
await Storage.remove(item)
|
||||
}
|
||||
})
|
||||
|
|
@ -75,59 +153,13 @@ export namespace Share {
|
|||
const share = await get(input.share.id)
|
||||
if (!share) throw new Errors.NotFound(input.share.id)
|
||||
if (share.secret !== input.share.secret) throw new Errors.InvalidSecret(input.share.id)
|
||||
await Storage.write(["share_event", input.share.id, Identifier.descending()], input.data)
|
||||
const data = (await readSnapshot(input.share.id)) ?? (await legacy(input.share.id))
|
||||
await writeSnapshot(input.share.id, merge(data, input.data))
|
||||
},
|
||||
)
|
||||
|
||||
type Compaction = {
|
||||
event?: string
|
||||
data: Data[]
|
||||
}
|
||||
|
||||
export async function data(shareID: string) {
|
||||
console.log("reading compaction")
|
||||
const compaction: Compaction = (await Storage.read<Compaction>(["share_compaction", shareID])) ?? {
|
||||
data: [],
|
||||
event: undefined,
|
||||
}
|
||||
console.log("reading pending events")
|
||||
const list = await Storage.list({
|
||||
prefix: ["share_event", shareID],
|
||||
before: compaction.event,
|
||||
}).then((x) => x.toReversed())
|
||||
|
||||
console.log("compacting", list.length)
|
||||
|
||||
if (list.length > 0) {
|
||||
const data = await Promise.all(list.map(async (event) => await Storage.read<Data[]>(event))).then((x) => x.flat())
|
||||
for (const item of data) {
|
||||
if (!item) continue
|
||||
const key = (item: Data) => {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
const id = key(item)
|
||||
const result = Binary.search(compaction.data, id, key)
|
||||
if (result.found) {
|
||||
compaction.data[result.index] = item
|
||||
} else {
|
||||
compaction.data.splice(result.index, 0, item)
|
||||
}
|
||||
}
|
||||
compaction.event = list.at(-1)?.at(-1)
|
||||
await Storage.write(["share_compaction", shareID], compaction)
|
||||
}
|
||||
return compaction.data
|
||||
return (await readSnapshot(shareID)) ?? legacy(shareID)
|
||||
}
|
||||
|
||||
export const syncOld = fn(
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ app
|
|||
validator("param", z.object({ shareID: z.string() })),
|
||||
async (c) => {
|
||||
const { shareID } = c.req.valid("param")
|
||||
c.header("Cache-Control", "public, max-age=30, s-maxage=300, stale-while-revalidate=86400")
|
||||
return c.json(await Share.data(shareID))
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,11 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
|||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { Share } from "~/core/share"
|
||||
import { Logo, Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { createDefaultOptions } from "@opencode-ai/ui/pierre"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
|
@ -20,11 +19,11 @@ import z from "zod"
|
|||
import NotFound from "../[...404]"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { FileSSR } from "@opencode-ai/ui/file-ssr"
|
||||
import { clientOnly } from "@solidjs/start"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { Base64 } from "js-base64"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
|
||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||
|
|
@ -54,12 +53,6 @@ const getData = query(async (shareID) => {
|
|||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
}
|
||||
session_diff_preload: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
session_diff_preload_split: {
|
||||
[sessionID: string]: PreloadMultiFileDiffResult<any>[]
|
||||
}
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
|
|
@ -79,12 +72,6 @@ const getData = query(async (shareID) => {
|
|||
session_diff: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_diff_preload: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_diff_preload_split: {
|
||||
[share.sessionID]: [],
|
||||
},
|
||||
session_status: {
|
||||
[share.sessionID]: {
|
||||
type: "idle",
|
||||
|
|
@ -101,28 +88,6 @@ const getData = query(async (shareID) => {
|
|||
break
|
||||
case "session_diff":
|
||||
result.session_diff[share.sessionID] = item.data
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
item.data.map(async (diff) =>
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: createDefaultOptions("unified"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
).then((r) => (result.session_diff_preload[share.sessionID] = r)),
|
||||
Promise.all(
|
||||
item.data.map(async (diff) =>
|
||||
preloadMultiFileDiff<any>({
|
||||
oldFile: { name: diff.file, contents: diff.before },
|
||||
newFile: { name: diff.file, contents: diff.after },
|
||||
options: createDefaultOptions("split"),
|
||||
// annotations,
|
||||
}),
|
||||
),
|
||||
).then((r) => (result.session_diff_preload_split[share.sessionID] = r)),
|
||||
])
|
||||
break
|
||||
case "message":
|
||||
result.message[item.data.sessionID] = result.message[item.data.sessionID] ?? []
|
||||
|
|
@ -143,17 +108,15 @@ const getData = query(async (shareID) => {
|
|||
}, "getShareData")
|
||||
|
||||
export default function () {
|
||||
getRequestEvent()?.response.headers.set(
|
||||
"Cache-Control",
|
||||
"public, max-age=30, s-maxage=300, stale-while-revalidate=86400",
|
||||
)
|
||||
|
||||
const params = useParams()
|
||||
const data = createAsync(async () => {
|
||||
if (!params.shareID) throw new Error("Missing shareID")
|
||||
const now = Date.now()
|
||||
const data = getData(params.shareID)
|
||||
console.log("getData", Date.now() - now)
|
||||
return data
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
console.log(data())
|
||||
return getData(params.shareID)
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -241,22 +204,8 @@ export default function () {
|
|||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const splitDiffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const diffs = createMemo(() => data().session_diff[data().sessionID] ?? [])
|
||||
const [diffStyle, setDiffStyle] = createSignal<"unified" | "split">("unified")
|
||||
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
|
|
@ -380,18 +329,9 @@ export default function () {
|
|||
<Show when={diffs().length > 0}>
|
||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
class="@4xl:hidden"
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
<SessionReview
|
||||
split
|
||||
class="hidden @4xl:flex"
|
||||
diffs={splitDiffs()}
|
||||
diffStyle={diffStyle()}
|
||||
onDiffStyleChange={setDiffStyle}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
|
|
@ -419,11 +359,7 @@ export default function () {
|
|||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<Tabs.Content value="review" class="!overflow-hidden hidden data-[selected]:block">
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ describe.concurrent("core.share", () => {
|
|||
data,
|
||||
})
|
||||
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(1)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
|
@ -64,8 +64,8 @@ describe.concurrent("core.share", () => {
|
|||
data: data2,
|
||||
})
|
||||
|
||||
const events = await Storage.list({ prefix: ["share_event", share.id] })
|
||||
expect(events.length).toBe(2)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
expect(snapshot?.data).toHaveLength(2)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
|
@ -194,6 +194,28 @@ describe.concurrent("core.share", () => {
|
|||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should migrate legacy event data into the snapshot", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
const data: Share.Data[] = [
|
||||
{
|
||||
type: "part",
|
||||
data: { id: "part1", sessionID, messageID: "msg1", type: "text", text: "Hello" },
|
||||
},
|
||||
]
|
||||
|
||||
await Storage.remove(["share_snapshot", share.id])
|
||||
await Storage.write(["share_event", share.id, Identifier.descending()], data)
|
||||
|
||||
const result = await Share.data(share.id)
|
||||
const snapshot = await Storage.read<{ data: Share.Data[] }>(["share_snapshot", share.id])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(snapshot?.data).toHaveLength(1)
|
||||
|
||||
await Share.remove({ id: share.id, secret: share.secret })
|
||||
})
|
||||
|
||||
test("should throw error for invalid secret", async () => {
|
||||
const sessionID = Identifier.descending()
|
||||
const share = await Share.create({ sessionID })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.19"
|
||||
version = "1.2.20"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
|||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
@ -45,8 +45,8 @@
|
|||
"@types/yargs": "17.0.33",
|
||||
"@types/which": "3.0.4",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"typescript": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"fuzzysort": "3.1.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
|
|
@ -135,6 +135,6 @@
|
|||
"zod-to-json-schema": "3.24.5"
|
||||
},
|
||||
"overrides": {
|
||||
"drizzle-orm": "1.0.0-beta.12-a5629fb"
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { $ } from "bun"
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
import solidPlugin from "@opentui/solid/bun-plugin"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
|
@ -51,7 +51,7 @@ const migrations = await Promise.all(
|
|||
Number(match[6]),
|
||||
)
|
||||
: 0
|
||||
return { sql, timestamp }
|
||||
return { sql, timestamp, name }
|
||||
}),
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
|
@ -161,7 +161,9 @@ for (const item of targets) {
|
|||
console.log(`building ${name}`)
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
|
||||
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
|
||||
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
|
||||
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||
|
||||
// Use platform-specific bunfs root path based on target OS
|
||||
|
|
|
|||
|
|
@ -111,8 +111,10 @@ export const TuiThreadCommand = cmd({
|
|||
}
|
||||
|
||||
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
|
||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||
const cwd = args.project
|
||||
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
|
||||
: root
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
|
|||
setStore("active", order[nextIndex])
|
||||
evt.preventDefault()
|
||||
}
|
||||
if (evt.name === "space") {
|
||||
if (evt.name === "space" || evt.name === " ") {
|
||||
if (store.active === "thinking") setStore("thinking", !store.thinking)
|
||||
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
|
||||
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export namespace Flag {
|
|||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
|
|
|
|||
|
|
@ -18,24 +18,61 @@ const disposal = {
|
|||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function emit(directory: string) {
|
||||
GlobalBus.emit("event", {
|
||||
directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
return ctx
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Context>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
})
|
||||
cache.set(directory, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||
let existing = cache.get(input.directory)
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
let existing = cache.get(directory)
|
||||
if (!existing) {
|
||||
Log.Default.info("creating instance", { directory: input.directory })
|
||||
existing = iife(async () => {
|
||||
const { project, sandbox } = await Project.fromDirectory(input.directory)
|
||||
const ctx = {
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
return ctx
|
||||
})
|
||||
cache.set(input.directory, existing)
|
||||
Log.Default.info("creating instance", { directory })
|
||||
existing = track(
|
||||
directory,
|
||||
boot({
|
||||
directory,
|
||||
init: input.init,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const ctx = await existing
|
||||
return context.provide(ctx, async () => {
|
||||
|
|
@ -66,19 +103,20 @@ export const Instance = {
|
|||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await State.dispose(directory)
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
emit(directory)
|
||||
return await next
|
||||
},
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await State.dispose(Instance.directory)
|
||||
cache.delete(Instance.directory)
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload: {
|
||||
type: "server.instance.disposed",
|
||||
properties: {
|
||||
directory: Instance.directory,
|
||||
},
|
||||
},
|
||||
})
|
||||
emit(Instance.directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
if (disposal.all) return disposal.all
|
||||
|
|
|
|||
|
|
@ -347,6 +347,21 @@ export namespace Project {
|
|||
return fromRow(row)
|
||||
}
|
||||
|
||||
export async function initGit(input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!which("git")) throw new Error("Git is not installed")
|
||||
|
||||
const result = await git(["init", "--quiet"], {
|
||||
cwd: input.directory,
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
const text = result.stderr.toString().trim() || result.text().trim()
|
||||
throw new Error(text || "Failed to initialize git repository")
|
||||
}
|
||||
|
||||
return (await fromDirectory(input.directory)).project
|
||||
}
|
||||
|
||||
export const update = fn(
|
||||
z.object({
|
||||
projectID: z.string(),
|
||||
|
|
|
|||
|
|
@ -195,18 +195,11 @@ export namespace Pty {
|
|||
session.bufferCursor += excess
|
||||
})
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
Bus.publish(Event.Exited, { id, exitCode })
|
||||
state().delete(id)
|
||||
remove(id)
|
||||
})
|
||||
Bus.publish(Event.Created, { info })
|
||||
return info
|
||||
|
|
@ -228,6 +221,7 @@ export namespace Pty {
|
|||
export async function remove(id: string) {
|
||||
const session = state().get(id)
|
||||
if (!session) return
|
||||
state().delete(id)
|
||||
log.info("removing session", { id })
|
||||
try {
|
||||
session.process.kill()
|
||||
|
|
@ -240,7 +234,6 @@ export namespace Pty {
|
|||
}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
state().delete(id)
|
||||
Bus.publish(Event.Deleted, { id })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Project } from "../../project/project"
|
|||
import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
|
|
@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() =>
|
|||
return c.json(Instance.project)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/git/init",
|
||||
describeRoute({
|
||||
summary: "Initialize git repository",
|
||||
description: "Create a git repository for the current project and return the refreshed project info.",
|
||||
operationId: "project.initGit",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Project information after git initialization",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Project.Info),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await Project.initGit({
|
||||
directory: dir,
|
||||
project: prev,
|
||||
})
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await Instance.reload({
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
project: next,
|
||||
init: InstanceBootstrap,
|
||||
})
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
"/:projectID",
|
||||
describeRoute({
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|||
import { websocket } from "hono/bun"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
|
|
@ -198,13 +199,15 @@ export namespace Server {
|
|||
if (c.req.path === "/log") return next()
|
||||
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = (() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})()
|
||||
const directory = Filesystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { ulid } from "ulid"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
|
|
@ -122,20 +121,35 @@ export namespace ShareNext {
|
|||
data: SDK.Model[]
|
||||
}
|
||||
|
||||
function key(item: Data) {
|
||||
switch (item.type) {
|
||||
case "session":
|
||||
return "session"
|
||||
case "message":
|
||||
return `message/${item.data.id}`
|
||||
case "part":
|
||||
return `part/${item.data.messageID}/${item.data.id}`
|
||||
case "session_diff":
|
||||
return "session_diff"
|
||||
case "model":
|
||||
return "model"
|
||||
}
|
||||
}
|
||||
|
||||
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
|
||||
async function sync(sessionID: string, data: Data[]) {
|
||||
if (disabled) return
|
||||
const existing = queue.get(sessionID)
|
||||
if (existing) {
|
||||
for (const item of data) {
|
||||
existing.data.set("id" in item ? (item.id as string) : ulid(), item)
|
||||
existing.data.set(key(item), item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dataMap = new Map<string, Data>()
|
||||
for (const item of data) {
|
||||
dataMap.set("id" in item ? (item.id as string) : ulid(), item)
|
||||
dataMap.set(key(item), item)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
|
|
@ -182,10 +196,14 @@ export namespace ShareNext {
|
|||
const diffs = await Session.diff(sessionID)
|
||||
const messages = await Array.fromAsync(MessageV2.stream(sessionID))
|
||||
const models = await Promise.all(
|
||||
messages
|
||||
.filter((m) => m.info.role === "user")
|
||||
.map((m) => (m.info as SDK.UserMessage).model)
|
||||
.map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
|
||||
Array.from(
|
||||
new Map(
|
||||
messages
|
||||
.filter((m) => m.info.role === "user")
|
||||
.map((m) => (m.info as SDK.UserMessage).model)
|
||||
.map((m) => [`${m.providerID}/${m.modelID}`, m] as const),
|
||||
).values(),
|
||||
).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)),
|
||||
)
|
||||
await sync(sessionID, [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import z from "zod"
|
|||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import * as schema from "./schema"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
|
||||
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
|
||||
|
||||
export const NotFoundError = NamedError.create(
|
||||
"NotFoundError",
|
||||
|
|
@ -25,13 +27,22 @@ export const NotFoundError = NamedError.create(
|
|||
const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = path.join(Global.Path.data, "opencode.db")
|
||||
export function file(channel: string) {
|
||||
if (channel === "latest" || Flag.OPENCODE_DISABLE_CHANNEL_DB) return "opencode.db"
|
||||
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return `opencode-${safe}.db`
|
||||
}
|
||||
|
||||
export const Path = (() => {
|
||||
return path.join(Global.Path.data, file(Installation.CHANNEL))
|
||||
})()
|
||||
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
|
||||
type Client = SQLiteBunDatabase<Schema>
|
||||
|
||||
type Journal = { sql: string; timestamp: number }[]
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
const state = {
|
||||
sqlite: undefined as BunDatabase | undefined,
|
||||
|
|
@ -62,6 +73,7 @@ export namespace Database {
|
|||
return {
|
||||
sql: readFileSync(file, "utf-8"),
|
||||
timestamp: time(name),
|
||||
name,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Journal
|
||||
|
|
@ -70,9 +82,9 @@ export namespace Database {
|
|||
}
|
||||
|
||||
export const Client = lazy(() => {
|
||||
log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
|
||||
log.info("opening database", { path: Path })
|
||||
|
||||
const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
|
||||
const sqlite = new BunDatabase(Path, { create: true })
|
||||
state.sqlite = sqlite
|
||||
|
||||
sqlite.run("PRAGMA journal_mode = WAL")
|
||||
|
|
@ -143,7 +155,7 @@ export namespace Database {
|
|||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = Client().transaction((tx) => {
|
||||
const result = (Client().transaction as any)((tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
})
|
||||
for (const effect of effects) effect()
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
|
|||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { lookup } from "mime-types"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, join, relative } from "path"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "./glob"
|
||||
|
|
@ -113,16 +113,22 @@ export namespace Filesystem {
|
|||
}
|
||||
}
|
||||
|
||||
// We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
|
||||
export function resolve(p: string): string {
|
||||
return normalizePath(pathResolve(windowsPath(p)))
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return (
|
||||
p
|
||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
// Git Bash for Windows paths are typically /<drive>/...
|
||||
.replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
// Cygwin git paths are typically /cygdrive/<drive>/...
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
// WSL paths are typically /mnt/<drive>/...
|
||||
.replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
)
|
||||
}
|
||||
export function overlaps(a: string, b: string) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import { z } from "zod"
|
|||
|
||||
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
|
||||
const result = (input: z.infer<T>) => {
|
||||
const parsed = schema.parse(input)
|
||||
let parsed
|
||||
try {
|
||||
parsed = schema.parse(input)
|
||||
} catch (e) {
|
||||
console.trace("schema validation failure stack trace:")
|
||||
throw e
|
||||
}
|
||||
|
||||
return cb(parsed)
|
||||
}
|
||||
result.force = (input: z.infer<T>) => cb(input)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import whichPkg from "which"
|
|||
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
|
||||
const result = whichPkg.sync(cmd, {
|
||||
nothrow: true,
|
||||
path: env?.PATH,
|
||||
pathExt: env?.PATHEXT,
|
||||
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
|
||||
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
|
||||
})
|
||||
return typeof result === "string" ? result : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
|
|||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||
}
|
||||
|
||||
async function check(map: (dir: string) => string) {
|
||||
if (process.platform !== "win32") return
|
||||
await using globalTmp = await tmpdir()
|
||||
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
Config.global.reset()
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
snapshot: false,
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: map(tmp.path),
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
expect(cfg.snapshot).toBe(true)
|
||||
expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
|
||||
expect(Instance.project.id).not.toBe("global")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll()
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
Config.global.reset()
|
||||
}
|
||||
}
|
||||
|
||||
test("loads config with defaults when no files exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
|
@ -56,6 +84,23 @@ test("loads JSON config file", async () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
await check((dir) => {
|
||||
const drive = dir[0].toLowerCase()
|
||||
const rest = dir.slice(2).replaceAll("\\", "/")
|
||||
return `/${drive}${rest}`
|
||||
})
|
||||
})
|
||||
|
||||
test("loads project config from Cygwin paths on Windows", async () => {
|
||||
await check((dir) => {
|
||||
const drive = dir[0].toLowerCase()
|
||||
const rest = dir.slice(2).replaceAll("\\", "/")
|
||||
return `/cygdrive/${drive}${rest}`
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores legacy tui keys in opencode config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const cacheDir = path.join(dir, "cache", "opencode")
|
|||
await fs.mkdir(cacheDir, { recursive: true })
|
||||
await fs.writeFile(path.join(cacheDir, "version"), "14")
|
||||
|
||||
// Clear provider env vars to ensure clean test state
|
||||
// Clear provider and server auth env vars to ensure clean test state
|
||||
delete process.env["ANTHROPIC_API_KEY"]
|
||||
delete process.env["OPENAI_API_KEY"]
|
||||
delete process.env["GOOGLE_API_KEY"]
|
||||
|
|
@ -70,6 +70,8 @@ delete process.env["DEEPSEEK_API_KEY"]
|
|||
delete process.env["FIREWORKS_API_KEY"]
|
||||
delete process.env["CEREBRAS_API_KEY"]
|
||||
delete process.env["SAMBANOVA_API_KEY"]
|
||||
delete process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
delete process.env["OPENCODE_SERVER_USERNAME"]
|
||||
|
||||
// Now safe to import from src/
|
||||
const { Log } = await import("../src/util/log")
|
||||
|
|
|
|||
87
packages/opencode/test/pty/pty-session.test.ts
Normal file
87
packages/opencode/test/pty/pty-session.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const wait = async (fn: () => boolean, ms = 2000) => {
|
||||
const end = Date.now() + ms
|
||||
while (Date.now() < end) {
|
||||
if (fn()) return
|
||||
await sleep(25)
|
||||
}
|
||||
throw new Error("timeout waiting for pty events")
|
||||
}
|
||||
|
||||
const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => {
|
||||
return log.filter((evt) => evt.id === id).map((evt) => evt.type)
|
||||
}
|
||||
|
||||
describe("pty", () => {
|
||||
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using dir = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
|
||||
const off = [
|
||||
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
|
||||
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
|
||||
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
|
||||
]
|
||||
|
||||
let id = ""
|
||||
try {
|
||||
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
|
||||
id = info.id
|
||||
|
||||
await wait(() => pick(log, id).includes("exited"))
|
||||
|
||||
await Pty.remove(id)
|
||||
await wait(() => pick(log, id).length >= 3)
|
||||
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
|
||||
} finally {
|
||||
off.forEach((x) => x())
|
||||
if (id) await Pty.remove(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("publishes created, exited, deleted in order for /bin/sh + remove", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using dir = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir.path,
|
||||
fn: async () => {
|
||||
const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = []
|
||||
const off = [
|
||||
Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })),
|
||||
Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })),
|
||||
Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })),
|
||||
]
|
||||
|
||||
let id = ""
|
||||
try {
|
||||
const info = await Pty.create({ command: "/bin/sh", title: "sh" })
|
||||
id = info.id
|
||||
|
||||
await sleep(100)
|
||||
|
||||
await Pty.remove(id)
|
||||
await wait(() => pick(log, id).length >= 3)
|
||||
expect(pick(log, id)).toEqual(["created", "exited", "deleted"])
|
||||
} finally {
|
||||
off.forEach((x) => x())
|
||||
if (id) await Pty.remove(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
119
packages/opencode/test/server/project-init-git.test.ts
Normal file
119
packages/opencode/test/server/project-init-git.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("project.initGit endpoint", () => {
|
||||
test("initializes git and reloads immediately", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = Server.App()
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
}
|
||||
const reload = Instance.reload
|
||||
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
|
||||
GlobalBus.on("event", fn)
|
||||
|
||||
try {
|
||||
const init = await app.request("/project/git/init", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
const body = await init.json()
|
||||
expect(init.status).toBe(200)
|
||||
expect(body).toMatchObject({
|
||||
id: "global",
|
||||
vcs: "git",
|
||||
worktree: tmp.path,
|
||||
})
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||
expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap)
|
||||
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
|
||||
true,
|
||||
)
|
||||
expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false)
|
||||
|
||||
const current = await app.request("/project/current", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
expect(current.status).toBe(200)
|
||||
expect(await current.json()).toMatchObject({
|
||||
id: "global",
|
||||
vcs: "git",
|
||||
worktree: tmp.path,
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
expect(await Snapshot.track()).toBeTruthy()
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
reloadSpy.mockRestore()
|
||||
GlobalBus.off("event", fn)
|
||||
}
|
||||
})
|
||||
|
||||
test("does not reload when the project is already git", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const app = Server.App()
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
}
|
||||
const reload = Instance.reload
|
||||
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
|
||||
GlobalBus.on("event", fn)
|
||||
|
||||
try {
|
||||
const init = await app.request("/project/git/init", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
expect(init.status).toBe(200)
|
||||
expect(await init.json()).toMatchObject({
|
||||
vcs: "git",
|
||||
worktree: tmp.path,
|
||||
})
|
||||
expect(
|
||||
seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length,
|
||||
).toBe(0)
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
const current = await app.request("/project/current", {
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
})
|
||||
expect(current.status).toBe(200)
|
||||
expect(await current.json()).toMatchObject({
|
||||
vcs: "git",
|
||||
worktree: tmp.path,
|
||||
})
|
||||
} finally {
|
||||
reloadSpy.mockRestore()
|
||||
GlobalBus.off("event", fn)
|
||||
}
|
||||
})
|
||||
})
|
||||
12
packages/opencode/test/storage/db.test.ts
Normal file
12
packages/opencode/test/storage/db.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "../../src/storage/db"
|
||||
|
||||
describe("Database.file", () => {
|
||||
test("uses the shared database for latest", () => {
|
||||
expect(Database.file("latest")).toBe("opencode.db")
|
||||
})
|
||||
|
||||
test("sanitizes preview channels for filenames", () => {
|
||||
expect(Database.file("fix/windows-modified-files-tracking")).toBe("opencode-fix-windows-modified-files-tracking.db")
|
||||
})
|
||||
})
|
||||
|
|
@ -84,6 +84,7 @@ function createTestDb() {
|
|||
.map((entry) => ({
|
||||
sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
|
||||
timestamp: Number(entry.name.split("_")[0]),
|
||||
name: entry.name,
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
migrate(drizzle({ client: sqlite }), migrations)
|
||||
|
|
|
|||
|
|
@ -440,4 +440,67 @@ describe("filesystem", () => {
|
|||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolve()", () => {
|
||||
test("resolves slash-prefixed drive paths on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const forward = tmp.path.replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves slash-prefixed drive roots on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toUpperCase()
|
||||
expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
|
||||
})
|
||||
|
||||
test("resolves Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
const rest = tmp.path.slice(2).replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive> paths on Windows.
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
|
||||
})
|
||||
|
||||
test("resolves Cygwin paths on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
const rest = tmp.path.slice(2).replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves Cygwin drive roots on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
|
||||
})
|
||||
|
||||
test("resolves WSL mount paths on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
const rest = tmp.path.slice(2).replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves WSL mount roots on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv {
|
|||
}
|
||||
}
|
||||
|
||||
function envPath(Path: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
Path,
|
||||
PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
|
||||
}
|
||||
}
|
||||
|
||||
function same(a: string | null, b: string) {
|
||||
if (process.platform === "win32") {
|
||||
expect(a?.toLowerCase()).toBe(b.toLowerCase())
|
||||
|
|
@ -79,4 +86,15 @@ describe("util.which", () => {
|
|||
|
||||
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
|
||||
})
|
||||
|
||||
test("uses Windows Path casing fallback", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const bin = path.join(tmp.path, "bin")
|
||||
await fs.mkdir(bin)
|
||||
const file = await cmd(bin, "mixed")
|
||||
|
||||
same(which("mixed", envPath(bin)), file)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import type {
|
|||
PermissionRespondResponses,
|
||||
PermissionRuleset,
|
||||
ProjectCurrentResponses,
|
||||
ProjectInitGitResponses,
|
||||
ProjectListResponses,
|
||||
ProjectUpdateErrors,
|
||||
ProjectUpdateResponses,
|
||||
|
|
@ -425,6 +426,36 @@ export class Project extends HeyApiClient {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize git repository
|
||||
*
|
||||
* Create a git repository for the current project and return the refreshed project info.
|
||||
*/
|
||||
public initGit<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
|
||||
url: "/project/git/init",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update project
|
||||
*
|
||||
|
|
|
|||
|
|
@ -2087,6 +2087,25 @@ export type ProjectCurrentResponses = {
|
|||
|
||||
export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]
|
||||
|
||||
export type ProjectInitGitData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/project/git/init"
|
||||
}
|
||||
|
||||
export type ProjectInitGitResponses = {
|
||||
/**
|
||||
* Project information after git initialization
|
||||
*/
|
||||
200: Project
|
||||
}
|
||||
|
||||
export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses]
|
||||
|
||||
export type ProjectUpdateData = {
|
||||
body?: {
|
||||
name?: string
|
||||
|
|
|
|||
|
|
@ -340,6 +340,47 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/project/git/init": {
|
||||
"post": {
|
||||
"operationId": "project.initGit",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "directory",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "workspace",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": "Initialize git repository",
|
||||
"description": "Create a git repository for the current project and return the refreshed project info.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Project information after git initialization",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/project/{projectID}": {
|
||||
"patch": {
|
||||
"operationId": "project.update",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@
|
|||
--border-weak-selected: var(--cobalt-light-alpha-5);
|
||||
--border-weak-disabled: var(--smoke-light-alpha-6);
|
||||
--border-weak-focus: var(--smoke-light-alpha-7);
|
||||
--border-weaker-base: var(--smoke-light-alpha-3);
|
||||
--border-interactive-base: var(--cobalt-light-7);
|
||||
--border-interactive-hover: var(--cobalt-light-8);
|
||||
--border-interactive-active: var(--cobalt-light-9);
|
||||
|
|
@ -224,11 +225,5 @@
|
|||
--markdown-image-text: #318795;
|
||||
--markdown-code-block: #1A1A1A;
|
||||
--border-color: #FFFFFF;
|
||||
--border-weaker-base: var(--smoke-light-alpha-3);
|
||||
--border-weaker-hover: var(--smoke-light-alpha-4);
|
||||
--border-weaker-active: var(--smoke-light-alpha-6);
|
||||
--border-weaker-selected: var(--cobalt-light-alpha-4);
|
||||
--border-weaker-disabled: var(--smoke-light-alpha-2);
|
||||
--border-weaker-focus: var(--smoke-light-alpha-6);
|
||||
--button-ghost-hover: var(--smoke-light-alpha-2);
|
||||
--button-ghost-hover2: var(--smoke-light-alpha-3);
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
|||
}}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
event.stopPropagation()
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ function sanitize(html: string) {
|
|||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
|
|
@ -237,7 +250,7 @@ export function Markdown(
|
|||
const [html] = createResource(
|
||||
() => local.text,
|
||||
async (markdown) => {
|
||||
if (isServer) return ""
|
||||
if (isServer) return fallback(markdown)
|
||||
|
||||
const hash = checksum(markdown)
|
||||
const key = local.cacheKey ?? hash
|
||||
|
|
@ -255,7 +268,7 @@ export function Markdown(
|
|||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
},
|
||||
{ initialValue: "" },
|
||||
{ initialValue: isServer ? fallback(local.text) : "" },
|
||||
)
|
||||
|
||||
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
|
|
|||
|
|
@ -494,7 +494,8 @@ export function AssistantParts(props: {
|
|||
{(() => {
|
||||
const parts = createMemo(
|
||||
() => {
|
||||
const entry = entryAccessor() as { type: "context"; refs: PartRef[] }
|
||||
const entry = entryAccessor()
|
||||
if (entry.type !== "context") return emptyTools
|
||||
return entry.refs
|
||||
.map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
|
||||
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
|
||||
|
|
@ -514,29 +515,27 @@ export function AssistantParts(props: {
|
|||
<Match when={entryType() === "part"}>
|
||||
{(() => {
|
||||
const message = createMemo(() => {
|
||||
const entry = entryAccessor() as { type: "part"; ref: PartRef }
|
||||
const entry = entryAccessor()
|
||||
if (entry.type !== "part") return
|
||||
return props.messages.find((item) => item.id === entry.ref.messageID)
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const entry = entryAccessor() as { type: "part"; ref: PartRef }
|
||||
const entry = entryAccessor()
|
||||
if (entry.type !== "part") return
|
||||
return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={message()}>
|
||||
{(msg) => (
|
||||
<Show when={part()}>
|
||||
{(p) => (
|
||||
<Part
|
||||
part={p()}
|
||||
message={msg()}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
defaultOpen={partDefaultOpen(p(), props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
<Show when={part()}>
|
||||
<Part
|
||||
part={part()!}
|
||||
message={message()!}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
|
|
@ -711,7 +710,8 @@ export function AssistantMessageDisplay(props: {
|
|||
{(() => {
|
||||
const parts = createMemo(
|
||||
() => {
|
||||
const entry = entryAccessor() as { type: "context"; refs: PartRef[] }
|
||||
const entry = entryAccessor()
|
||||
if (entry.type !== "context") return emptyTools
|
||||
return entry.refs
|
||||
.map((ref) => partByID(props.parts, ref.partID))
|
||||
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
|
||||
|
|
@ -730,19 +730,18 @@ export function AssistantMessageDisplay(props: {
|
|||
<Match when={entryType() === "part"}>
|
||||
{(() => {
|
||||
const part = createMemo(() => {
|
||||
const entry = entryAccessor() as { type: "part"; ref: PartRef }
|
||||
const entry = entryAccessor()
|
||||
if (entry.type !== "part") return
|
||||
return partByID(props.parts, entry.ref.partID)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(p) => (
|
||||
<Part
|
||||
part={p()}
|
||||
message={props.message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
/>
|
||||
)}
|
||||
<Part
|
||||
part={part()!}
|
||||
message={props.message}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
|
|
@ -1405,11 +1404,9 @@ ToolRegistry.register({
|
|||
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={output()} />
|
||||
</div>
|
||||
)}
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={props.output!} />
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
|
|
@ -1431,11 +1428,9 @@ ToolRegistry.register({
|
|||
}}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={output()} />
|
||||
</div>
|
||||
)}
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={props.output!} />
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
|
|
@ -1460,11 +1455,9 @@ ToolRegistry.register({
|
|||
}}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={output()} />
|
||||
</div>
|
||||
)}
|
||||
<div data-component="tool-output" data-scrollable>
|
||||
<Markdown text={props.output!} />
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
|
|
@ -1608,16 +1601,14 @@ ToolRegistry.register({
|
|||
<Show when={description()}>
|
||||
<Switch>
|
||||
<Match when={href()}>
|
||||
{(url) => (
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="clickable subagent-link"
|
||||
href={url()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{description()}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="clickable subagent-link"
|
||||
href={href()!}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{description()}
|
||||
</a>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span data-slot="basic-tool-tool-subtitle">{description()}</span>
|
||||
|
|
@ -1742,7 +1733,9 @@ ToolRegistry.register({
|
|||
<ToolFileAccordion
|
||||
path={path()}
|
||||
actions={
|
||||
<Show when={!pending() && props.metadata.filediff}>{(diff) => <DiffChanges changes={diff()} />}</Show>
|
||||
<Show when={!pending() && props.metadata.filediff}>
|
||||
<DiffChanges changes={props.metadata.filediff!} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-component="edit-content">
|
||||
|
|
@ -1969,74 +1962,72 @@ ToolRegistry.register({
|
|||
</div>
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && file().relativePath.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{getDirectory(file().relativePath)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<div data-component="apply-patch-tool">
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">
|
||||
<span data-slot="message-part-title-text">
|
||||
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} />
|
||||
<span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && single()!.relativePath.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<Show when={!pending()}>
|
||||
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ToolFileAccordion
|
||||
path={single()!.relativePath}
|
||||
actions={
|
||||
<Switch>
|
||||
<Match when={single()!.type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={single()!.type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={single()!.type === "move"}>
|
||||
<span data-slot="apply-patch-change" data-type="modified">
|
||||
{i18n.t("ui.patch.action.moved")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<ToolFileAccordion
|
||||
path={file().relativePath}
|
||||
actions={
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "move"}>
|
||||
<span data-slot="apply-patch-change" data-type="modified">
|
||||
{i18n.t("ui.patch.action.moved")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} />
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: file().filePath, contents: file().before }}
|
||||
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</BasicTool>
|
||||
</div>
|
||||
)}
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: single()!.filePath, contents: single()!.before }}
|
||||
after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</BasicTool>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
const searchHandles = new Map<string, FileSearchHandle>()
|
||||
const readyFiles = new Set<string>()
|
||||
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
|
||||
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
||||
open: [],
|
||||
force: {},
|
||||
})
|
||||
|
||||
|
|
@ -554,7 +554,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||
return (
|
||||
<div data-component="session-review" class={props.class} classList={props.classList}>
|
||||
<div data-slot="session-review-header" class={props.classes?.header}>
|
||||
<div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
|
||||
<div data-slot="session-review-title">
|
||||
{props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title}
|
||||
</div>
|
||||
<div data-slot="session-review-actions">
|
||||
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
||||
<RadioGroup
|
||||
|
|
|
|||
|
|
@ -388,157 +388,149 @@ export function SessionTurn(
|
|||
>
|
||||
<div onClick={autoScroll.handleInteraction}>
|
||||
<Show when={message()}>
|
||||
{(msg) => (
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={msg().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={msg()} parts={parts()} interrupted={interrupted()} queued={queued()} />
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={message()!.id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={compaction()!} message={message()!} hideDetails />
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
{(part) => (
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={part()} message={msg()} hideDetails />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
turnDurationMs={turnDurationMs()}
|
||||
working={working()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
turnDurationMs={turnDurationMs()}
|
||||
working={working()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries()}>
|
||||
<TextReveal
|
||||
text={reasoningHeading()}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={700}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries()}>
|
||||
<TextReveal
|
||||
text={reasoningHeading()}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={700}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">
|
||||
{i18n.t("ui.sessionReview.change.modified")}
|
||||
</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">
|
||||
{getFilename(diff.file)}
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@
|
|||
--tabs-review-fade: 16px;
|
||||
gap: var(--tabs-review-gap);
|
||||
background-color: var(--background-stronger);
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
border-bottom: 1px solid var(--border-weaker-base);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
|
|
@ -407,11 +407,7 @@
|
|||
align-items: center;
|
||||
background-color: var(--background-stronger);
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid transparent;
|
||||
|
||||
&[data-scrolled] {
|
||||
border-bottom-color: var(--border-weak-base);
|
||||
}
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger-wrapper"] {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Generated by script/colors.ts */
|
||||
/* Generated by script/tailwind.ts */
|
||||
/* Do not edit this file manually */
|
||||
|
||||
@theme {
|
||||
|
|
@ -77,10 +77,6 @@
|
|||
--color-text-weaker: var(--text-weaker);
|
||||
--color-text-strong: var(--text-strong);
|
||||
--color-text-interactive-base: var(--text-interactive-base);
|
||||
--color-text-invert-base: var(--text-invert-base);
|
||||
--color-text-invert-weak: var(--text-invert-weak);
|
||||
--color-text-invert-weaker: var(--text-invert-weaker);
|
||||
--color-text-invert-strong: var(--text-invert-strong);
|
||||
--color-text-on-brand-base: var(--text-on-brand-base);
|
||||
--color-text-on-interactive-base: var(--text-on-interactive-base);
|
||||
--color-text-on-interactive-weak: var(--text-on-interactive-weak);
|
||||
|
|
@ -123,6 +119,7 @@
|
|||
--color-border-weak-selected: var(--border-weak-selected);
|
||||
--color-border-weak-disabled: var(--border-weak-disabled);
|
||||
--color-border-weak-focus: var(--border-weak-focus);
|
||||
--color-border-weaker-base: var(--border-weaker-base);
|
||||
--color-border-interactive-base: var(--border-interactive-base);
|
||||
--color-border-interactive-hover: var(--border-interactive-hover);
|
||||
--color-border-interactive-active: var(--border-interactive-active);
|
||||
|
|
@ -233,12 +230,6 @@
|
|||
--color-markdown-image-text: var(--markdown-image-text);
|
||||
--color-markdown-code-block: var(--markdown-code-block);
|
||||
--color-border-color: var(--border-color);
|
||||
--color-border-weaker-base: var(--border-weaker-base);
|
||||
--color-border-weaker-hover: var(--border-weaker-hover);
|
||||
--color-border-weaker-active: var(--border-weaker-active);
|
||||
--color-border-weaker-selected: var(--border-weaker-selected);
|
||||
--color-border-weaker-disabled: var(--border-weaker-disabled);
|
||||
--color-border-weaker-focus: var(--border-weaker-focus);
|
||||
--color-button-ghost-hover: var(--button-ghost-hover);
|
||||
--color-button-ghost-hover2: var(--button-ghost-hover2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@
|
|||
0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03),
|
||||
0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05),
|
||||
0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09);
|
||||
--shadow-sidebar-overlay:
|
||||
0 100px 80px 0 rgba(0, 0, 0, 0.29), 0 41.778px 33.422px 0 rgba(0, 0, 0, 0.21),
|
||||
0 22.336px 17.869px 0 rgba(0, 0, 0, 0.17), 0 12.522px 10.017px 0 rgba(0, 0, 0, 0.14),
|
||||
0 6.65px 5.32px 0 rgba(0, 0, 0, 0.12), 0 2.767px 2.214px 0 rgba(0, 0, 0, 0.08);
|
||||
|
||||
color-scheme: light;
|
||||
--text-mix-blend-mode: multiply;
|
||||
|
|
@ -212,6 +216,7 @@
|
|||
--border-weak-selected: var(--cobalt-light-alpha-5);
|
||||
--border-weak-disabled: var(--smoke-light-alpha-6);
|
||||
--border-weak-focus: var(--smoke-light-alpha-7);
|
||||
--border-weaker-base: var(--smoke-light-alpha-3);
|
||||
--border-interactive-base: var(--cobalt-light-7);
|
||||
--border-interactive-hover: var(--cobalt-light-8);
|
||||
--border-interactive-active: var(--cobalt-light-9);
|
||||
|
|
@ -323,12 +328,6 @@
|
|||
--markdown-image-text: #318795;
|
||||
--markdown-code-block: #1a1a1a;
|
||||
--border-color: #ffffff;
|
||||
--border-weaker-base: var(--smoke-light-alpha-3);
|
||||
--border-weaker-hover: var(--smoke-light-alpha-4);
|
||||
--border-weaker-active: var(--smoke-light-alpha-6);
|
||||
--border-weaker-selected: var(--cobalt-light-alpha-4);
|
||||
--border-weaker-disabled: var(--smoke-light-alpha-2);
|
||||
--border-weaker-focus: var(--smoke-light-alpha-6);
|
||||
--button-ghost-hover: var(--smoke-light-alpha-2);
|
||||
--button-ghost-hover2: var(--smoke-light-alpha-3);
|
||||
--avatar-background-pink: #feeef8;
|
||||
|
|
@ -582,12 +581,7 @@
|
|||
--markdown-image-text: #56b6c2;
|
||||
--markdown-code-block: #eeeeee;
|
||||
--border-color: #ffffff;
|
||||
--border-weaker-base: var(--smoke-dark-alpha-3);
|
||||
--border-weaker-hover: var(--smoke-dark-alpha-4);
|
||||
--border-weaker-active: var(--smoke-dark-alpha-6);
|
||||
--border-weaker-selected: var(--cobalt-dark-alpha-3);
|
||||
--border-weaker-disabled: var(--smoke-dark-alpha-2);
|
||||
--border-weaker-focus: var(--smoke-dark-alpha-6);
|
||||
--border-weaker-base: var(--smoke-dark-alpha-2);
|
||||
--button-ghost-hover: var(--smoke-dark-alpha-2);
|
||||
--button-ghost-hover2: var(--smoke-dark-alpha-3);
|
||||
--avatar-background-pink: #501b3f;
|
||||
|
|
|
|||
|
|
@ -152,11 +152,6 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
|
|||
tokens["border-weak-disabled"] = neutralAlpha[5]
|
||||
tokens["border-weak-focus"] = neutralAlpha[isDark ? 7 : 6]
|
||||
tokens["border-weaker-base"] = neutralAlpha[2]
|
||||
tokens["border-weaker-hover"] = neutralAlpha[3]
|
||||
tokens["border-weaker-active"] = neutralAlpha[5]
|
||||
tokens["border-weaker-selected"] = withAlpha(interactive[3], isDark ? 0.3 : 0.4) as ColorValue
|
||||
tokens["border-weaker-disabled"] = neutralAlpha[1]
|
||||
tokens["border-weaker-focus"] = neutralAlpha[5]
|
||||
|
||||
tokens["border-interactive-base"] = interactive[6]
|
||||
tokens["border-interactive-hover"] = interactive[7]
|
||||
|
|
|
|||
|
|
@ -247,11 +247,6 @@
|
|||
"markdown-code-block": "#1a1a1a",
|
||||
"border-color": "#ffffff",
|
||||
"border-weaker-base": "var(--smoke-light-alpha-3)",
|
||||
"border-weaker-hover": "var(--smoke-light-alpha-4)",
|
||||
"border-weaker-active": "var(--smoke-light-alpha-6)",
|
||||
"border-weaker-selected": "var(--cobalt-light-alpha-4)",
|
||||
"border-weaker-disabled": "var(--smoke-light-alpha-2)",
|
||||
"border-weaker-focus": "var(--smoke-light-alpha-6)",
|
||||
"button-ghost-hover": "var(--smoke-light-alpha-2)",
|
||||
"button-ghost-hover2": "var(--smoke-light-alpha-3)",
|
||||
"avatar-background-pink": "#feeef8",
|
||||
|
|
@ -513,11 +508,6 @@
|
|||
"markdown-code-block": "#eeeeee",
|
||||
"border-color": "#ffffff",
|
||||
"border-weaker-base": "var(--smoke-dark-alpha-3)",
|
||||
"border-weaker-hover": "var(--smoke-dark-alpha-4)",
|
||||
"border-weaker-active": "var(--smoke-dark-alpha-6)",
|
||||
"border-weaker-selected": "var(--cobalt-dark-alpha-3)",
|
||||
"border-weaker-disabled": "var(--smoke-dark-alpha-2)",
|
||||
"border-weaker-focus": "var(--smoke-dark-alpha-6)",
|
||||
"button-ghost-hover": "var(--smoke-dark-alpha-2)",
|
||||
"button-ghost-hover2": "var(--smoke-dark-alpha-3)",
|
||||
"avatar-background-pink": "#501b3f",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"id": "oc-2",
|
||||
"light": {
|
||||
"seeds": {
|
||||
"neutral": "#8e8b8b",
|
||||
"neutral": "#8f8f8f",
|
||||
"primary": "#dcde8d",
|
||||
"success": "#12c905",
|
||||
"warning": "#ffdc17",
|
||||
|
|
@ -15,32 +15,32 @@
|
|||
"diffDelete": "#fc533a"
|
||||
},
|
||||
"overrides": {
|
||||
"background-base": "#f8f7f7",
|
||||
"background-weak": "var(--gray-light-3)",
|
||||
"background-strong": "var(--gray-light-1)",
|
||||
"background-base": "#f8f8f8",
|
||||
"background-weak": "#f3f3f3",
|
||||
"background-strong": "#fcfcfc",
|
||||
"background-stronger": "#fcfcfc",
|
||||
"surface-base": "var(--gray-light-alpha-2)",
|
||||
"base": "var(--gray-light-alpha-2)",
|
||||
"surface-base-hover": "#0500000f",
|
||||
"surface-base-active": "var(--gray-light-alpha-3)",
|
||||
"surface-base": "#00000008",
|
||||
"base": "#00000008",
|
||||
"surface-base-hover": "#0000000f",
|
||||
"surface-base-active": "#0000000d",
|
||||
"surface-base-interactive-active": "var(--cobalt-light-alpha-3)",
|
||||
"base2": "var(--gray-light-alpha-2)",
|
||||
"base3": "var(--gray-light-alpha-2)",
|
||||
"surface-inset-base": "var(--gray-light-alpha-2)",
|
||||
"surface-inset-base-hover": "var(--gray-light-alpha-3)",
|
||||
"surface-inset-strong": "#1f000017",
|
||||
"surface-inset-strong-hover": "#1f000017",
|
||||
"surface-raised-base": "var(--gray-light-alpha-2)",
|
||||
"surface-float-base": "var(--gray-dark-1)",
|
||||
"surface-float-base-hover": "var(--gray-dark-2)",
|
||||
"surface-raised-base-hover": "var(--gray-light-alpha-3)",
|
||||
"surface-raised-base-active": "var(--gray-light-alpha-5)",
|
||||
"surface-raised-strong": "var(--gray-light-1)",
|
||||
"base2": "#00000008",
|
||||
"base3": "#00000008",
|
||||
"surface-inset-base": "#00000008",
|
||||
"surface-inset-base-hover": "#0000000d",
|
||||
"surface-inset-strong": "#00000017",
|
||||
"surface-inset-strong-hover": "#00000017",
|
||||
"surface-raised-base": "#00000008",
|
||||
"surface-float-base": "#161616",
|
||||
"surface-float-base-hover": "#1c1c1c",
|
||||
"surface-raised-base-hover": "#0000000d",
|
||||
"surface-raised-base-active": "#00000017",
|
||||
"surface-raised-strong": "#fcfcfc",
|
||||
"surface-raised-strong-hover": "var(--white)",
|
||||
"surface-raised-stronger": "var(--white)",
|
||||
"surface-raised-stronger-hover": "var(--white)",
|
||||
"surface-weak": "var(--gray-light-alpha-3)",
|
||||
"surface-weaker": "var(--gray-light-alpha-4)",
|
||||
"surface-weak": "#0000000d",
|
||||
"surface-weaker": "#00000012",
|
||||
"surface-strong": "#ffffff",
|
||||
"surface-raised-stronger-non-alpha": "var(--white)",
|
||||
"surface-brand-base": "var(--yuzu-light-9)",
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
"surface-info-weak": "var(--lilac-light-2)",
|
||||
"surface-info-strong": "var(--lilac-light-9)",
|
||||
"surface-diff-unchanged-base": "#ffffff00",
|
||||
"surface-diff-skip-base": "var(--gray-light-2)",
|
||||
"surface-diff-skip-base": "#f8f8f8",
|
||||
"surface-diff-hidden-base": "var(--blue-light-3)",
|
||||
"surface-diff-hidden-weak": "var(--blue-light-2)",
|
||||
"surface-diff-hidden-weaker": "var(--blue-light-1)",
|
||||
|
|
@ -78,69 +78,69 @@
|
|||
"surface-diff-delete-weaker": "var(--ember-light-1)",
|
||||
"surface-diff-delete-strong": "var(--ember-light-6)",
|
||||
"surface-diff-delete-stronger": "var(--ember-light-9)",
|
||||
"input-base": "var(--gray-light-1)",
|
||||
"input-hover": "var(--gray-light-2)",
|
||||
"input-base": "#fcfcfc",
|
||||
"input-hover": "#f8f8f8",
|
||||
"input-active": "var(--cobalt-light-1)",
|
||||
"input-selected": "var(--cobalt-light-4)",
|
||||
"input-focus": "var(--cobalt-light-1)",
|
||||
"input-disabled": "var(--gray-light-4)",
|
||||
"text-base": "var(--gray-light-11)",
|
||||
"text-weak": "var(--gray-light-9)",
|
||||
"text-weaker": "var(--gray-light-8)",
|
||||
"text-strong": "var(--gray-light-12)",
|
||||
"text-invert-base": "var(--gray-dark-alpha-11)",
|
||||
"text-invert-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-invert-weaker": "var(--gray-dark-alpha-8)",
|
||||
"text-invert-strong": "var(--gray-dark-alpha-12)",
|
||||
"input-disabled": "#ededed",
|
||||
"text-base": "#6f6f6f",
|
||||
"text-weak": "#8f8f8f",
|
||||
"text-weaker": "#c7c7c7",
|
||||
"text-strong": "#171717",
|
||||
"text-invert-base": "#ffffff96",
|
||||
"text-invert-weak": "#ffffff63",
|
||||
"text-invert-weaker": "#ffffff40",
|
||||
"text-invert-strong": "#ffffffeb",
|
||||
"text-interactive-base": "var(--cobalt-light-9)",
|
||||
"text-on-brand-base": "var(--gray-light-alpha-11)",
|
||||
"text-on-interactive-base": "var(--gray-light-1)",
|
||||
"text-on-interactive-weak": "var(--gray-dark-alpha-11)",
|
||||
"text-on-brand-base": "#0000008f",
|
||||
"text-on-interactive-base": "#fcfcfc",
|
||||
"text-on-interactive-weak": "#ffffff96",
|
||||
"text-on-success-base": "var(--apple-light-10)",
|
||||
"text-on-critical-base": "var(--ember-light-10)",
|
||||
"text-on-critical-weak": "var(--ember-light-8)",
|
||||
"text-on-critical-strong": "var(--ember-light-12)",
|
||||
"text-on-warning-base": "var(--gray-dark-alpha-11)",
|
||||
"text-on-info-base": "var(--gray-dark-alpha-11)",
|
||||
"text-on-warning-base": "#ffffff96",
|
||||
"text-on-info-base": "#ffffff96",
|
||||
"text-diff-add-base": "var(--mint-light-11)",
|
||||
"text-diff-delete-base": "var(--ember-light-10)",
|
||||
"text-diff-delete-strong": "var(--ember-light-12)",
|
||||
"text-diff-add-strong": "var(--mint-light-12)",
|
||||
"text-on-info-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-on-info-strong": "var(--gray-dark-alpha-12)",
|
||||
"text-on-warning-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-on-warning-strong": "var(--gray-dark-alpha-12)",
|
||||
"text-on-info-weak": "#ffffff63",
|
||||
"text-on-info-strong": "#ffffffeb",
|
||||
"text-on-warning-weak": "#ffffff63",
|
||||
"text-on-warning-strong": "#ffffffeb",
|
||||
"text-on-success-weak": "var(--apple-light-6)",
|
||||
"text-on-success-strong": "var(--apple-light-12)",
|
||||
"text-on-brand-weak": "var(--gray-light-alpha-9)",
|
||||
"text-on-brand-weaker": "var(--gray-light-alpha-8)",
|
||||
"text-on-brand-strong": "var(--gray-light-alpha-12)",
|
||||
"button-primary-base": "var(--gray-light-12)",
|
||||
"button-secondary-base": "var(--gray-light-1)",
|
||||
"text-on-brand-weak": "#00000070",
|
||||
"text-on-brand-weaker": "#00000038",
|
||||
"text-on-brand-strong": "#000000e8",
|
||||
"button-primary-base": "#171717",
|
||||
"button-secondary-base": "#fcfcfc",
|
||||
"button-secondary-hover": "FFFFFF0A",
|
||||
"border-base": "var(--gray-light-alpha-7)",
|
||||
"border-hover": "var(--gray-light-alpha-8)",
|
||||
"border-active": "var(--gray-light-alpha-9)",
|
||||
"border-base": "#00000024",
|
||||
"border-hover": "#00000038",
|
||||
"border-active": "#00000070",
|
||||
"border-selected": "var(--cobalt-light-alpha-9)",
|
||||
"border-disabled": "var(--gray-light-alpha-8)",
|
||||
"border-focus": "var(--gray-light-alpha-9)",
|
||||
"border-weak-base": "var(--gray-light-alpha-5)",
|
||||
"border-strong-base": "var(--gray-light-alpha-7)",
|
||||
"border-strong-hover": "var(--gray-light-alpha-8)",
|
||||
"border-strong-active": "var(--gray-light-alpha-7)",
|
||||
"border-disabled": "#00000038",
|
||||
"border-focus": "#00000070",
|
||||
"border-weak-base": "#e5e5e5",
|
||||
"border-strong-base": "#00000024",
|
||||
"border-strong-hover": "#00000038",
|
||||
"border-strong-active": "#00000024",
|
||||
"border-strong-selected": "var(--cobalt-light-alpha-6)",
|
||||
"border-strong-disabled": "var(--gray-light-alpha-6)",
|
||||
"border-strong-focus": "var(--gray-light-alpha-7)",
|
||||
"border-weak-hover": "var(--gray-light-alpha-6)",
|
||||
"border-weak-active": "var(--gray-light-alpha-7)",
|
||||
"border-strong-disabled": "#0000001c",
|
||||
"border-strong-focus": "#00000024",
|
||||
"border-weak-hover": "#0000001c",
|
||||
"border-weak-active": "#00000024",
|
||||
"border-weak-selected": "var(--cobalt-light-alpha-5)",
|
||||
"border-weak-disabled": "var(--gray-light-alpha-6)",
|
||||
"border-weak-focus": "var(--gray-light-alpha-7)",
|
||||
"border-weak-disabled": "#0000001c",
|
||||
"border-weak-focus": "#00000024",
|
||||
"border-interactive-base": "var(--cobalt-light-7)",
|
||||
"border-interactive-hover": "var(--cobalt-light-8)",
|
||||
"border-interactive-active": "var(--cobalt-light-9)",
|
||||
"border-interactive-selected": "var(--cobalt-light-9)",
|
||||
"border-interactive-disabled": "var(--gray-light-8)",
|
||||
"border-interactive-disabled": "#c7c7c7",
|
||||
"border-interactive-focus": "var(--cobalt-light-9)",
|
||||
"border-success-base": "var(--apple-light-6)",
|
||||
"border-success-hover": "var(--apple-light-7)",
|
||||
|
|
@ -154,26 +154,26 @@
|
|||
"border-info-base": "var(--lilac-light-6)",
|
||||
"border-info-hover": "var(--lilac-light-7)",
|
||||
"border-info-selected": "var(--lilac-light-9)",
|
||||
"icon-base": "var(--gray-light-9)",
|
||||
"icon-hover": "var(--gray-light-11)",
|
||||
"icon-active": "var(--gray-light-12)",
|
||||
"icon-selected": "var(--gray-light-12)",
|
||||
"icon-disabled": "var(--gray-light-8)",
|
||||
"icon-focus": "var(--gray-light-12)",
|
||||
"icon-base": "#8f8f8f",
|
||||
"icon-hover": "#6f6f6f",
|
||||
"icon-active": "#171717",
|
||||
"icon-selected": "#171717",
|
||||
"icon-disabled": "#c7c7c7",
|
||||
"icon-focus": "#171717",
|
||||
"icon-invert-base": "#ffffff",
|
||||
"icon-weak-base": "var(--gray-light-7)",
|
||||
"icon-weak-hover": "var(--gray-light-8)",
|
||||
"icon-weak-active": "var(--gray-light-9)",
|
||||
"icon-weak-selected": "var(--gray-light-10)",
|
||||
"icon-weak-disabled": "var(--gray-light-6)",
|
||||
"icon-weak-focus": "var(--gray-light-9)",
|
||||
"icon-strong-base": "var(--gray-light-12)",
|
||||
"icon-strong-hover": "#151313",
|
||||
"icon-weak-base": "#dbdbdb",
|
||||
"icon-weak-hover": "#c7c7c7",
|
||||
"icon-weak-active": "#8f8f8f",
|
||||
"icon-weak-selected": "#858585",
|
||||
"icon-weak-disabled": "#e2e2e2",
|
||||
"icon-weak-focus": "#8f8f8f",
|
||||
"icon-strong-base": "#171717",
|
||||
"icon-strong-hover": "#151515",
|
||||
"icon-strong-active": "#020202",
|
||||
"icon-strong-selected": "#020202",
|
||||
"icon-strong-disabled": "var(--gray-light-6)",
|
||||
"icon-strong-disabled": "#e2e2e2",
|
||||
"icon-strong-focus": "#020202",
|
||||
"icon-brand-base": "var(--gray-light-12)",
|
||||
"icon-brand-base": "#171717",
|
||||
"icon-interactive-base": "var(--cobalt-light-9)",
|
||||
"icon-success-base": "var(--apple-light-7)",
|
||||
"icon-success-hover": "var(--apple-light-8)",
|
||||
|
|
@ -187,10 +187,10 @@
|
|||
"icon-info-base": "var(--lilac-light-7)",
|
||||
"icon-info-hover": "var(--lilac-light-8)",
|
||||
"icon-info-active": "var(--lilac-light-11)",
|
||||
"icon-on-brand-base": "var(--gray-light-alpha-11)",
|
||||
"icon-on-brand-hover": "var(--gray-light-alpha-12)",
|
||||
"icon-on-brand-selected": "var(--gray-light-alpha-12)",
|
||||
"icon-on-interactive-base": "var(--gray-light-1)",
|
||||
"icon-on-brand-base": "#0000008f",
|
||||
"icon-on-brand-hover": "#000000e8",
|
||||
"icon-on-brand-selected": "#000000e8",
|
||||
"icon-on-interactive-base": "#fcfcfc",
|
||||
"icon-agent-plan-base": "var(--purple-light-9)",
|
||||
"icon-agent-docs-base": "var(--amber-light-9)",
|
||||
"icon-agent-ask-base": "var(--cyan-light-9)",
|
||||
|
|
@ -246,14 +246,9 @@
|
|||
"markdown-image-text": "#318795",
|
||||
"markdown-code-block": "#1a1a1a",
|
||||
"border-color": "#ffffff",
|
||||
"border-weaker-base": "var(--gray-light-alpha-3)",
|
||||
"border-weaker-hover": "var(--gray-light-alpha-4)",
|
||||
"border-weaker-active": "var(--gray-light-alpha-6)",
|
||||
"border-weaker-selected": "var(--cobalt-light-alpha-4)",
|
||||
"border-weaker-disabled": "var(--gray-light-alpha-2)",
|
||||
"border-weaker-focus": "var(--gray-light-alpha-6)",
|
||||
"button-ghost-hover": "var(--gray-light-alpha-2)",
|
||||
"button-ghost-hover2": "var(--gray-light-alpha-3)",
|
||||
"border-weaker-base": "#efefef",
|
||||
"button-ghost-hover": "#00000008",
|
||||
"button-ghost-hover2": "#0000000d",
|
||||
"avatar-background-pink": "#feeef8",
|
||||
"avatar-background-mint": "#e1fbf4",
|
||||
"avatar-background-orange": "#fff1e7",
|
||||
|
|
@ -270,7 +265,7 @@
|
|||
},
|
||||
"dark": {
|
||||
"seeds": {
|
||||
"neutral": "#716c6b",
|
||||
"neutral": "#707070",
|
||||
"primary": "#fab283",
|
||||
"success": "#12c905",
|
||||
"warning": "#fcd53a",
|
||||
|
|
@ -281,33 +276,33 @@
|
|||
"diffDelete": "#fc533a"
|
||||
},
|
||||
"overrides": {
|
||||
"base": "var(--gray-dark-alpha-2)",
|
||||
"base2": "var(--gray-dark-alpha-2)",
|
||||
"base3": "var(--gray-dark-alpha-2)",
|
||||
"base": "#ffffff08",
|
||||
"base2": "#ffffff08",
|
||||
"base3": "#ffffff08",
|
||||
"background-base": "#101010",
|
||||
"background-weak": "#1E1E1E",
|
||||
"background-strong": "#121212",
|
||||
"background-stronger": "#151515",
|
||||
"surface-base": "var(--gray-dark-alpha-2)",
|
||||
"surface-base": "#ffffff08",
|
||||
"surface-base-hover": "#FFFFFF0A",
|
||||
"surface-base-active": "var(--gray-dark-alpha-3)",
|
||||
"surface-base-active": "#ffffff0f",
|
||||
"surface-base-interactive-active": "var(--cobalt-dark-alpha-2)",
|
||||
"surface-inset-base": "#0e0b0b7f",
|
||||
"surface-inset-base-hover": "#0e0b0b7f",
|
||||
"surface-inset-strong": "#060505cc",
|
||||
"surface-inset-strong-hover": "#060505cc",
|
||||
"surface-raised-base": "var(--gray-dark-alpha-3)",
|
||||
"surface-float-base": "var(--gray-dark-1)",
|
||||
"surface-float-base-hover": "var(--gray-dark-2)",
|
||||
"surface-raised-base-hover": "var(--gray-dark-alpha-4)",
|
||||
"surface-raised-base-active": "var(--gray-dark-alpha-5)",
|
||||
"surface-raised-strong": "var(--gray-dark-alpha-4)",
|
||||
"surface-raised-strong-hover": "var(--gray-dark-alpha-6)",
|
||||
"surface-raised-stronger": "var(--gray-dark-alpha-6)",
|
||||
"surface-raised-stronger-hover": "var(--gray-dark-alpha-7)",
|
||||
"surface-weak": "var(--gray-dark-alpha-4)",
|
||||
"surface-weaker": "var(--gray-dark-alpha-5)",
|
||||
"surface-strong": "var(--gray-dark-alpha-7)",
|
||||
"surface-inset-base": "#0000007f",
|
||||
"surface-inset-base-hover": "#0000007f",
|
||||
"surface-inset-strong": "#000000cc",
|
||||
"surface-inset-strong-hover": "#000000cc",
|
||||
"surface-raised-base": "#ffffff0f",
|
||||
"surface-float-base": "#161616",
|
||||
"surface-float-base-hover": "#1c1c1c",
|
||||
"surface-raised-base-hover": "#ffffff14",
|
||||
"surface-raised-base-active": "#ffffff1a",
|
||||
"surface-raised-strong": "#ffffff14",
|
||||
"surface-raised-strong-hover": "#ffffff21",
|
||||
"surface-raised-stronger": "#ffffff21",
|
||||
"surface-raised-stronger-hover": "#ffffff2b",
|
||||
"surface-weak": "#ffffff14",
|
||||
"surface-weaker": "#ffffff1a",
|
||||
"surface-strong": "#ffffff2b",
|
||||
"surface-raised-stronger-non-alpha": "#1B1B1B",
|
||||
"surface-brand-base": "var(--yuzu-light-9)",
|
||||
"surface-brand-hover": "var(--yuzu-light-10)",
|
||||
|
|
@ -327,8 +322,8 @@
|
|||
"surface-info-base": "var(--lilac-light-3)",
|
||||
"surface-info-weak": "var(--lilac-light-2)",
|
||||
"surface-info-strong": "var(--lilac-light-9)",
|
||||
"surface-diff-unchanged-base": "var(--gray-dark-1)",
|
||||
"surface-diff-skip-base": "var(--gray-dark-alpha-1)",
|
||||
"surface-diff-unchanged-base": "#161616",
|
||||
"surface-diff-skip-base": "#00000000",
|
||||
"surface-diff-hidden-base": "var(--blue-dark-2)",
|
||||
"surface-diff-hidden-weak": "var(--blue-dark-1)",
|
||||
"surface-diff-hidden-weaker": "var(--blue-dark-3)",
|
||||
|
|
@ -344,64 +339,64 @@
|
|||
"surface-diff-delete-weaker": "var(--ember-dark-3)",
|
||||
"surface-diff-delete-strong": "var(--ember-dark-5)",
|
||||
"surface-diff-delete-stronger": "var(--ember-dark-11)",
|
||||
"input-base": "var(--gray-dark-2)",
|
||||
"input-hover": "var(--gray-dark-2)",
|
||||
"input-base": "#1c1c1c",
|
||||
"input-hover": "#1c1c1c",
|
||||
"input-active": "var(--cobalt-dark-1)",
|
||||
"input-selected": "var(--cobalt-dark-2)",
|
||||
"input-focus": "var(--cobalt-dark-1)",
|
||||
"input-disabled": "var(--gray-dark-4)",
|
||||
"text-base": "var(--gray-dark-alpha-11)",
|
||||
"text-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-weaker": "var(--gray-dark-alpha-8)",
|
||||
"text-strong": "var(--gray-dark-alpha-12)",
|
||||
"text-invert-base": "var(--gray-dark-alpha-11)",
|
||||
"text-invert-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-invert-weaker": "var(--gray-dark-alpha-8)",
|
||||
"text-invert-strong": "var(--gray-dark-alpha-12)",
|
||||
"input-disabled": "#282828",
|
||||
"text-base": "#ffffff96",
|
||||
"text-weak": "#ffffff63",
|
||||
"text-weaker": "#ffffff40",
|
||||
"text-strong": "#ffffffeb",
|
||||
"text-invert-base": "#ffffff96",
|
||||
"text-invert-weak": "#ffffff63",
|
||||
"text-invert-weaker": "#ffffff40",
|
||||
"text-invert-strong": "#ffffffeb",
|
||||
"text-interactive-base": "var(--cobalt-dark-11)",
|
||||
"text-on-brand-base": "var(--gray-dark-alpha-11)",
|
||||
"text-on-interactive-base": "var(--gray-dark-12)",
|
||||
"text-on-interactive-weak": "var(--gray-dark-alpha-11)",
|
||||
"text-on-brand-base": "#ffffff96",
|
||||
"text-on-interactive-base": "#ededed",
|
||||
"text-on-interactive-weak": "#ffffff96",
|
||||
"text-on-success-base": "var(--apple-dark-9)",
|
||||
"text-on-critical-base": "var(--ember-dark-9)",
|
||||
"text-on-critical-weak": "var(--ember-dark-8)",
|
||||
"text-on-critical-strong": "var(--ember-dark-12)",
|
||||
"text-on-warning-base": "var(--gray-dark-alpha-11)",
|
||||
"text-on-info-base": "var(--gray-dark-alpha-11)",
|
||||
"text-on-warning-base": "#ffffff96",
|
||||
"text-on-info-base": "#ffffff96",
|
||||
"text-diff-add-base": "var(--mint-dark-11)",
|
||||
"text-diff-delete-base": "var(--ember-dark-9)",
|
||||
"text-diff-delete-strong": "var(--ember-dark-12)",
|
||||
"text-diff-add-strong": "var(--mint-dark-8)",
|
||||
"text-on-info-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-on-info-strong": "var(--gray-dark-alpha-12)",
|
||||
"text-on-warning-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-on-warning-strong": "var(--gray-dark-alpha-12)",
|
||||
"text-on-info-weak": "#ffffff63",
|
||||
"text-on-info-strong": "#ffffffeb",
|
||||
"text-on-warning-weak": "#ffffff63",
|
||||
"text-on-warning-strong": "#ffffffeb",
|
||||
"text-on-success-weak": "var(--apple-dark-8)",
|
||||
"text-on-success-strong": "var(--apple-dark-12)",
|
||||
"text-on-brand-weak": "var(--gray-dark-alpha-9)",
|
||||
"text-on-brand-weaker": "var(--gray-dark-alpha-8)",
|
||||
"text-on-brand-strong": "var(--gray-dark-alpha-12)",
|
||||
"button-primary-base": "var(--gray-dark-12)",
|
||||
"button-secondary-base": "var(--gray-dark-2)",
|
||||
"text-on-brand-weak": "#ffffff63",
|
||||
"text-on-brand-weaker": "#ffffff40",
|
||||
"text-on-brand-strong": "#ffffffeb",
|
||||
"button-primary-base": "#ededed",
|
||||
"button-secondary-base": "#1c1c1c",
|
||||
"button-secondary-hover": "#FFFFFF0A",
|
||||
"border-base": "var(--gray-dark-alpha-7)",
|
||||
"border-hover": "var(--gray-dark-alpha-8)",
|
||||
"border-active": "var(--gray-dark-alpha-9)",
|
||||
"border-base": "#ffffff2b",
|
||||
"border-hover": "#ffffff40",
|
||||
"border-active": "#ffffff63",
|
||||
"border-selected": "var(--cobalt-dark-alpha-11)",
|
||||
"border-disabled": "var(--gray-dark-alpha-8)",
|
||||
"border-focus": "var(--gray-dark-alpha-9)",
|
||||
"border-weak-base": "var(--gray-dark-alpha-5)",
|
||||
"border-weak-hover": "var(--gray-dark-alpha-7)",
|
||||
"border-weak-active": "var(--gray-dark-alpha-8)",
|
||||
"border-disabled": "#ffffff40",
|
||||
"border-focus": "#ffffff63",
|
||||
"border-weak-base": "#282828",
|
||||
"border-weak-hover": "#ffffff2b",
|
||||
"border-weak-active": "#ffffff40",
|
||||
"border-weak-selected": "var(--cobalt-dark-alpha-6)",
|
||||
"border-weak-disabled": "var(--gray-dark-alpha-6)",
|
||||
"border-weak-focus": "var(--gray-dark-alpha-8)",
|
||||
"border-strong-base": "var(--gray-dark-alpha-8)",
|
||||
"border-weak-disabled": "#ffffff21",
|
||||
"border-weak-focus": "#ffffff40",
|
||||
"border-strong-base": "#ffffff40",
|
||||
"border-interactive-base": "var(--cobalt-light-7)",
|
||||
"border-interactive-hover": "var(--cobalt-light-8)",
|
||||
"border-interactive-active": "var(--cobalt-light-9)",
|
||||
"border-interactive-selected": "var(--cobalt-light-9)",
|
||||
"border-interactive-disabled": "var(--gray-light-8)",
|
||||
"border-interactive-disabled": "#c7c7c7",
|
||||
"border-interactive-focus": "var(--cobalt-light-9)",
|
||||
"border-success-base": "var(--apple-light-6)",
|
||||
"border-success-hover": "var(--apple-light-7)",
|
||||
|
|
@ -415,24 +410,24 @@
|
|||
"border-info-base": "var(--lilac-light-6)",
|
||||
"border-info-hover": "var(--lilac-light-7)",
|
||||
"border-info-selected": "var(--lilac-light-9)",
|
||||
"icon-base": "var(--gray-dark-10)",
|
||||
"icon-hover": "var(--gray-dark-11)",
|
||||
"icon-active": "var(--gray-dark-12)",
|
||||
"icon-selected": "var(--gray-dark-12)",
|
||||
"icon-disabled": "var(--gray-dark-8)",
|
||||
"icon-focus": "var(--gray-dark-12)",
|
||||
"icon-invert-base": "var(--gray-dark-1)",
|
||||
"icon-weak-base": "var(--gray-dark-6)",
|
||||
"icon-weak-hover": "var(--gray-light-7)",
|
||||
"icon-weak-active": "var(--gray-light-8)",
|
||||
"icon-weak-selected": "var(--gray-light-9)",
|
||||
"icon-weak-disabled": "var(--gray-light-4)",
|
||||
"icon-weak-focus": "var(--gray-light-9)",
|
||||
"icon-strong-base": "var(--gray-dark-12)",
|
||||
"icon-base": "#7e7e7e",
|
||||
"icon-hover": "#a0a0a0",
|
||||
"icon-active": "#ededed",
|
||||
"icon-selected": "#ededed",
|
||||
"icon-disabled": "#505050",
|
||||
"icon-focus": "#ededed",
|
||||
"icon-invert-base": "#161616",
|
||||
"icon-weak-base": "#343434",
|
||||
"icon-weak-hover": "#dbdbdb",
|
||||
"icon-weak-active": "#c7c7c7",
|
||||
"icon-weak-selected": "#8f8f8f",
|
||||
"icon-weak-disabled": "#ededed",
|
||||
"icon-weak-focus": "#8f8f8f",
|
||||
"icon-strong-base": "#ededed",
|
||||
"icon-strong-hover": "#F3F3F3",
|
||||
"icon-strong-active": "#EBEBEB",
|
||||
"icon-strong-selected": "#FCFCFC",
|
||||
"icon-strong-disabled": "var(--gray-dark-7)",
|
||||
"icon-strong-disabled": "#3e3e3e",
|
||||
"icon-strong-focus": "#FCFCFC",
|
||||
"icon-brand-base": "var(--white)",
|
||||
"icon-interactive-base": "var(--cobalt-dark-11)",
|
||||
|
|
@ -448,10 +443,10 @@
|
|||
"icon-info-base": "var(--lilac-dark-7)",
|
||||
"icon-info-hover": "var(--lilac-dark-8)",
|
||||
"icon-info-active": "var(--lilac-dark-11)",
|
||||
"icon-on-brand-base": "var(--gray-light-alpha-11)",
|
||||
"icon-on-brand-hover": "var(--gray-light-alpha-12)",
|
||||
"icon-on-brand-selected": "var(--gray-light-alpha-12)",
|
||||
"icon-on-interactive-base": "var(--gray-dark-12)",
|
||||
"icon-on-brand-base": "#0000008f",
|
||||
"icon-on-brand-hover": "#000000e8",
|
||||
"icon-on-brand-selected": "#000000e8",
|
||||
"icon-on-interactive-base": "#ededed",
|
||||
"icon-agent-plan-base": "var(--purple-dark-9)",
|
||||
"icon-agent-docs-base": "var(--amber-dark-9)",
|
||||
"icon-agent-ask-base": "var(--cyan-dark-9)",
|
||||
|
|
@ -507,14 +502,9 @@
|
|||
"markdown-image-text": "#56b6c2",
|
||||
"markdown-code-block": "#eeeeee",
|
||||
"border-color": "#ffffff",
|
||||
"border-weaker-base": "var(--gray-dark-alpha-3)",
|
||||
"border-weaker-hover": "var(--gray-dark-alpha-4)",
|
||||
"border-weaker-active": "var(--gray-dark-alpha-6)",
|
||||
"border-weaker-selected": "var(--cobalt-dark-alpha-3)",
|
||||
"border-weaker-disabled": "var(--gray-dark-alpha-2)",
|
||||
"border-weaker-focus": "var(--gray-dark-alpha-6)",
|
||||
"button-ghost-hover": "var(--gray-dark-alpha-2)",
|
||||
"button-ghost-hover2": "var(--gray-dark-alpha-3)",
|
||||
"border-weaker-base": "#1e1e1e",
|
||||
"button-ghost-hover": "#ffffff08",
|
||||
"button-ghost-hover2": "#ffffff0f",
|
||||
"avatar-background-pink": "#501b3f",
|
||||
"avatar-background-mint": "#033a34",
|
||||
"avatar-background-orange": "#5f2a06",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.19",
|
||||
"version": "1.2.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue