mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-07 07:04:04 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b7a5b1e7b | ||
|
|
28bb16ca2a | ||
|
|
8a95be492d | ||
|
|
c42c5a0cc6 | ||
|
|
b2c2478d9d | ||
|
|
1a9af8acb6 | ||
|
|
4c7fe60493 | ||
|
|
c108f304c6 | ||
|
|
2b8acfa0e2 | ||
|
|
b83282b940 | ||
|
|
c4fd677785 | ||
|
|
770cb66628 | ||
|
|
b0bc3d87f5 | ||
|
|
a2634337b8 | ||
|
|
7417c869fc | ||
|
|
091cf25de8 |
@@ -1,6 +1,6 @@
|
||||
Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
|
||||
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
|
||||
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
|
||||
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
|
||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,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) {
|
||||
@@ -608,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) {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -154,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(
|
||||
() => {
|
||||
@@ -166,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)
|
||||
@@ -178,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) => {
|
||||
@@ -191,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)
|
||||
@@ -1122,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)
|
||||
@@ -1812,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 ""
|
||||
@@ -1838,10 +1904,17 @@ 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}>
|
||||
{(p) => (
|
||||
@@ -1887,7 +1960,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
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.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
@@ -2006,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>
|
||||
@@ -2034,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">
|
||||
@@ -2087,28 +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>
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||
<SidebarPanel project={hoverProjectData()} />
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</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={{
|
||||
@@ -2124,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(),
|
||||
}}
|
||||
@@ -2157,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>
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
@@ -345,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"
|
||||
@@ -354,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")}
|
||||
@@ -363,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
|
||||
@@ -382,6 +384,7 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
@@ -390,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,7 +187,7 @@ 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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -62,13 +62,14 @@ function track(directory: string, next: Promise<Context>) {
|
||||
|
||||
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 })
|
||||
Log.Default.info("creating instance", { directory })
|
||||
existing = track(
|
||||
input.directory,
|
||||
directory,
|
||||
boot({
|
||||
directory: input.directory,
|
||||
directory,
|
||||
init: input.init,
|
||||
}),
|
||||
)
|
||||
@@ -103,11 +104,12 @@ export const Instance = {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
Log.Default.info("reloading instance", { directory: input.directory })
|
||||
await State.dispose(input.directory)
|
||||
cache.delete(input.directory)
|
||||
const next = track(input.directory, boot(input))
|
||||
emit(input.directory)
|
||||
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() {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,12 +27,14 @@ export const NotFoundError = NamedError.create(
|
||||
const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
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 = (() => {
|
||||
const name =
|
||||
Installation.CHANNEL !== "latest" && !Flag.OPENCODE_DISABLE_CHANNEL_DB
|
||||
? `opencode-${Installation.CHANNEL}.db`
|
||||
: "opencode.db"
|
||||
return path.join(Global.Path.data, name)
|
||||
return path.join(Global.Path.data, file(Installation.CHANNEL))
|
||||
})()
|
||||
|
||||
type Schema = typeof schema
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user