Merge branch 'dev' into desktop-poilsh-styles-ui-ux
commit
457c246f10
|
|
@ -18,6 +18,54 @@ jobs:
|
|||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install --with-deps
|
||||
|
||||
- name: Seed opencode data
|
||||
working-directory: packages/opencode
|
||||
run: bun script/seed-e2e.ts
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
||||
|
||||
- name: Run opencode server
|
||||
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_CLIENT: "app"
|
||||
|
||||
- name: Wait for opencode server
|
||||
run: |
|
||||
for i in {1..60}; do
|
||||
curl -fsS "http://localhost:4096/global/health" > /dev/null && exit 0
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: run
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
|
|
@ -26,3 +74,20 @@ jobs:
|
|||
bun turbo test
|
||||
env:
|
||||
CI: true
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
PLAYWRIGHT_SERVER_HOST: "localhost"
|
||||
PLAYWRIGHT_SERVER_PORT: "4096"
|
||||
VITE_OPENCODE_SERVER_HOST: "localhost"
|
||||
VITE_OPENCODE_SERVER_PORT: "4096"
|
||||
OPENCODE_CLIENT: "app"
|
||||
timeout-minutes: 30
|
||||
|
|
|
|||
10
bun.lock
10
bun.lock
|
|
@ -56,6 +56,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/bun": "catalog:",
|
||||
|
|
@ -502,6 +503,7 @@
|
|||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.0.2",
|
||||
"@playwright/test": "1.51.0",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
|
|
@ -1355,6 +1357,8 @@
|
|||
|
||||
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.5", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
|
@ -3291,6 +3295,10 @@
|
|||
|
||||
"planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="],
|
||||
|
||||
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
|
@ -4427,6 +4435,8 @@
|
|||
|
||||
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768302833,
|
||||
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
|
||||
"lastModified": 1768569498,
|
||||
"narHash": "sha256-bB6Nt99Cj8Nu5nIUq0GLmpiErIT5KFshMQJGMZwgqUo=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "61db79b0c6b838d9894923920b612048e1201926",
|
||||
"rev": "be5afa0fcb31f0a96bf9ecba05a516c66fcd8114",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-D1VXuKJagfq3mxh8Xs8naHoYNJUJzAM9JLJqpHcItDk=",
|
||||
"aarch64-linux": "sha256-9wXcg50Sv56Wb2x5NWe15olNGE/uMiDkmGRmqPoeW1U=",
|
||||
"aarch64-darwin": "sha256-i5eTTjsNAARwcw69sd6wuse2BKTUi/Vfgo4M28l+RoY=",
|
||||
"x86_64-darwin": "sha256-oFtQnIzgTS2zcjkhBTnXxYqr20KXdA2I+b908piLs+c="
|
||||
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
|
||||
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
|
||||
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
|
||||
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"@playwright/test": "1.51.0",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/playwright-report
|
||||
|
|
|
|||
|
|
@ -29,6 +29,21 @@ It correctly bundles Solid in production mode and optimizes the build for the be
|
|||
The build is minified and the filenames include the hashes.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
## E2E Testing
|
||||
|
||||
The Playwright runner expects the app already running at `http://localhost:3000`.
|
||||
|
||||
```bash
|
||||
bun add -D @playwright/test
|
||||
bunx playwright install
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
Environment options:
|
||||
|
||||
- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
|
||||
- `PLAYWRIGHT_PORT` (default: `3000`)
|
||||
|
||||
## Deployment
|
||||
|
||||
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { promptSelector } from "./utils"
|
||||
|
||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke context ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "seed context",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await gotoSession(sessionID)
|
||||
|
||||
const contextButton = page
|
||||
.locator('[data-component="button"]')
|
||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||
.first()
|
||||
|
||||
await expect(contextButton).toBeVisible()
|
||||
await contextButton.click()
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { modKey } from "./utils"
|
||||
|
||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill("package.json")
|
||||
|
||||
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { test as base, expect } from "@playwright/test"
|
||||
import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils"
|
||||
|
||||
type TestFixtures = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
directory: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
directory: [
|
||||
async ({}, use) => {
|
||||
const directory = await getWorktree()
|
||||
await use(directory)
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
slug: [
|
||||
async ({ directory }, use) => {
|
||||
await use(dirSlug(directory))
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
sdk: async ({ directory }, use) => {
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
}
|
||||
await use(gotoSession)
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { serverName } from "./utils"
|
||||
|
||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
|
||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
|
||||
})
|
||||
|
||||
test("server picker dialog opens from home", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
|
||||
const trigger = page.getByRole("button", { name: serverName })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { dirPath, promptSelector } from "./utils"
|
||||
|
||||
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
||||
await page.goto(dirPath(directory))
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { modKey } from "./utils"
|
||||
|
||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.keyboard.press(`${modKey}+P`)
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { promptSelector } from "./utils"
|
||||
|
||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await gotoSession(sessionID)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("hello from e2e")
|
||||
await expect(prompt).toContainText("hello from e2e")
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { modKey } from "./utils"
|
||||
|
||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const main = page.locator("main")
|
||||
const closedClass = /xl:border-l/
|
||||
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
|
||||
|
||||
if (isClosed) {
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(closedClass)
|
||||
}
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).toHaveClass(closedClass)
|
||||
|
||||
await page.keyboard.press(`${modKey}+B`)
|
||||
await expect(main).not.toHaveClass(closedClass)
|
||||
})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { test, expect } from "./fixtures"
|
||||
import { terminalSelector, terminalToggleKey } from "./utils"
|
||||
|
||||
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const initiallyOpen = await terminal.isVisible()
|
||||
if (initiallyOpen) {
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toHaveCount(0)
|
||||
}
|
||||
|
||||
await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
|
||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||
export const serverName = `${serverHost}:${serverPort}`
|
||||
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export const promptSelector = '[data-component="prompt-input"]'
|
||||
export const terminalSelector = '[data-component="terminal"]'
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
const result = await sdk.path.get()
|
||||
const data = result.data
|
||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
||||
return data.worktree
|
||||
}
|
||||
|
||||
export function dirSlug(directory: string) {
|
||||
return base64Encode(directory)
|
||||
}
|
||||
|
||||
export function dirPath(directory: string) {
|
||||
return `/${dirSlug(directory)}`
|
||||
}
|
||||
|
||||
export function sessionPath(directory: string, sessionID?: string) {
|
||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||
}
|
||||
|
|
@ -12,11 +12,16 @@
|
|||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
"serve": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/bun": "catalog:",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${port}`
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./e2e/test-results",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
reuseExistingServer: reuse,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -32,12 +32,14 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
})
|
||||
|
||||
const [dragOver, setDragOver] = createSignal(false)
|
||||
const [iconHover, setIconHover] = createSignal(false)
|
||||
|
||||
function handleFileSelect(file: File) {
|
||||
if (!isValidImageFile(file)) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setStore("iconUrl", e.target?.result as string)
|
||||
setIconHover(false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
|
@ -84,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog title="Edit project">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
|
|
@ -99,17 +101,24 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">Icon</label>
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="relative">
|
||||
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
|
||||
<div
|
||||
class="size-12 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
|
||||
class="size-16 rounded-md overflow-hidden border border-dashed transition-colors cursor-pointer"
|
||||
classList={{
|
||||
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
|
||||
"border-border-base hover:border-border-strong": !dragOver(),
|
||||
"overflow-hidden": !!store.iconUrl,
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => document.getElementById("icon-upload")?.click()}
|
||||
onClick={() => {
|
||||
if (store.iconUrl && iconHover()) {
|
||||
clearIcon()
|
||||
} else {
|
||||
document.getElementById("icon-upload")?.click()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProjectAvatar
|
||||
name={store.name || defaultName()}
|
||||
|
|
@ -119,20 +128,48 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
<Show when={store.iconUrl}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
|
||||
onClick={clearIcon}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-icon-base" />
|
||||
</button>
|
||||
</Show>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "64px",
|
||||
height: "64px",
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
"border-radius": "6px",
|
||||
"z-index": 10,
|
||||
"pointer-events": "none",
|
||||
opacity: iconHover() && !store.iconUrl ? 1 : 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
}}
|
||||
>
|
||||
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "64px",
|
||||
height: "64px",
|
||||
background: "rgba(0,0,0,0.6)",
|
||||
"border-radius": "6px",
|
||||
"z-index": 10,
|
||||
"pointer-events": "none",
|
||||
opacity: iconHover() && store.iconUrl ? 1 : 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size="large" class="text-icon-invert-base" />
|
||||
</div>
|
||||
</div>
|
||||
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
|
||||
<span>Click or drag an image</span>
|
||||
<span>Recommended: 128x128px</span>
|
||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||
<span>Recommended size 128x128px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -140,20 +177,25 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||
<Show when={!store.iconUrl}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-12-medium text-text-weak">Color</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-1.5">
|
||||
<For each={AVATAR_COLOR_KEYS}>
|
||||
{(color) => (
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-8 rounded-md transition-all"
|
||||
classList={{
|
||||
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
|
||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||
store.color === color,
|
||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||
store.color !== color,
|
||||
}}
|
||||
style={{ background: getAvatarColors(color).background }}
|
||||
onClick={() => setStore("color", color)}
|
||||
>
|
||||
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
|
||||
<Avatar
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(color)}
|
||||
class="size-full rounded"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -1056,7 +1056,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||
|
||||
let session = info()
|
||||
if (!session && isNewSession) {
|
||||
session = await client.session.create().then((x) => x.data ?? undefined)
|
||||
session = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: "Failed to create session",
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
if (!session) return
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ type TerminalCacheEntry = {
|
|||
dispose: VoidFunction
|
||||
}
|
||||
|
||||
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
||||
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, session?: string) {
|
||||
const legacy = session ? [`${dir}/terminal/${session}.v1`, `${dir}/terminal.v1`] : [`${dir}/terminal.v1`]
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.scoped(dir, id, "terminal", [legacy]),
|
||||
Persist.workspace(dir, "terminal", legacy),
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
|
|
@ -43,17 +43,28 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
|
|||
all: createMemo(() => Object.values(store.all)),
|
||||
active: createMemo(() => store.active),
|
||||
new() {
|
||||
const parse = (title: string) => {
|
||||
const match = title.match(/^Terminal (\d+)$/)
|
||||
if (!match) return
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value) || value <= 0) return
|
||||
return value
|
||||
}
|
||||
|
||||
const existingTitleNumbers = new Set(
|
||||
store.all.map((pty) => {
|
||||
const match = pty.titleNumber
|
||||
return match
|
||||
store.all.flatMap((pty) => {
|
||||
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
||||
if (direct !== undefined) return [direct]
|
||||
const parsed = parse(pty.title)
|
||||
if (parsed === undefined) return []
|
||||
return [parsed]
|
||||
}),
|
||||
)
|
||||
|
||||
let nextNumber = 1
|
||||
while (existingTitleNumbers.has(nextNumber)) {
|
||||
nextNumber++
|
||||
}
|
||||
const nextNumber =
|
||||
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
|
||||
(number) => !existingTitleNumbers.has(number),
|
||||
) ?? 1
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: `Terminal ${nextNumber}` })
|
||||
|
|
@ -166,8 +177,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||
}
|
||||
}
|
||||
|
||||
const load = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}:${id ?? WORKSPACE_KEY}`
|
||||
const load = (dir: string, session?: string) => {
|
||||
const key = `${dir}:${WORKSPACE_KEY}`
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
|
|
@ -176,7 +187,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createTerminalSession(sdk, dir, id),
|
||||
value: createTerminalSession(sdk, dir, session),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
|
|
@ -185,18 +196,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||
return entry.value
|
||||
}
|
||||
|
||||
const session = createMemo(() => load(params.dir!, params.id))
|
||||
const workspace = createMemo(() => load(params.dir!, params.id))
|
||||
|
||||
return {
|
||||
ready: () => session().ready(),
|
||||
all: () => session().all(),
|
||||
active: () => session().active(),
|
||||
new: () => session().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
||||
clone: (id: string) => session().clone(id),
|
||||
open: (id: string) => session().open(id),
|
||||
close: (id: string) => session().close(id),
|
||||
move: (id: string, to: number) => session().move(id, to),
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,13 +27,14 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import {
|
||||
|
|
@ -964,10 +965,23 @@ export default function Layout(props: ParentProps) {
|
|||
if (!current) return
|
||||
if (directory === current.worktree) return
|
||||
|
||||
const reset = globalSDK.client.worktree
|
||||
const progress = showToast({
|
||||
persistent: true,
|
||||
title: "Resetting workspace",
|
||||
description: "This may take a minute.",
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
|
||||
const sessions = await globalSDK.client.session
|
||||
.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
dismiss()
|
||||
showToast({
|
||||
title: "Failed to reset workspace",
|
||||
description: errorMessage(err),
|
||||
|
|
@ -975,21 +989,16 @@ export default function Layout(props: ParentProps) {
|
|||
return false
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session`
|
||||
navigate(href)
|
||||
layout.mobileSidebar.hide()
|
||||
if (!result) {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const sessions = await globalSDK.client.session
|
||||
.list({ directory })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const archivedAt = Date.now()
|
||||
await Promise.all(
|
||||
sessions.map((session) =>
|
||||
const archivedAt = Date.now()
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((session) => session.time.archived === undefined)
|
||||
.map((session) =>
|
||||
globalSDK.client.session
|
||||
.update({
|
||||
sessionID: session.id,
|
||||
|
|
@ -998,11 +1007,14 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
})()
|
||||
)
|
||||
|
||||
const result = await reset
|
||||
if (!result) return
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
dismiss()
|
||||
|
||||
const href = `/${base64Encode(directory)}/session`
|
||||
navigate(href)
|
||||
layout.mobileSidebar.hide()
|
||||
|
||||
showToast({
|
||||
title: "Workspace reset",
|
||||
|
|
@ -1296,7 +1308,13 @@ export default function Layout(props: ParentProps) {
|
|||
)
|
||||
}
|
||||
|
||||
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
|
||||
const SessionItem = (props: {
|
||||
session: Session
|
||||
slug: string
|
||||
mobile?: boolean
|
||||
dense?: boolean
|
||||
popover?: boolean
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
|
|
@ -1330,63 +1348,100 @@ export default function Layout(props: ParentProps) {
|
|||
return agent?.color
|
||||
})
|
||||
|
||||
const hoverMessages = createMemo(() =>
|
||||
sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
|
||||
)
|
||||
const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
|
||||
const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened())
|
||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||
const isActive = createMemo(() => props.session.id === params.id)
|
||||
|
||||
const messageLabel = (message: Message) => {
|
||||
const parts = sessionStore.part[message.id] ?? []
|
||||
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
|
||||
return text?.text
|
||||
}
|
||||
|
||||
const item = (
|
||||
<A
|
||||
href={`${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onMouseEnter={() => prefetchSession(props.session, "high")}
|
||||
onFocus={() => prefetchSession(props.session, "high")}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={notifications().length > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<InlineEditor
|
||||
id={`session:${props.session.id}`}
|
||||
value={() => props.session.title}
|
||||
onSave={(next) => renameSession(props.session, next)}
|
||||
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<DiffChanges changes={summary()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<A
|
||||
href={`${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
onMouseEnter={() => prefetchSession(props.session, "high")}
|
||||
onFocus={() => prefetchSession(props.session, "high")}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={notifications().length > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Tooltip
|
||||
placement="top-start"
|
||||
value={props.session.title}
|
||||
gutter={0}
|
||||
openDelay={3000}
|
||||
class="grow-1 min-w-0"
|
||||
>
|
||||
<InlineEditor
|
||||
id={`session:${props.session.id}`}
|
||||
value={() => props.session.title}
|
||||
onSave={(next) => renameSession(props.session, next)}
|
||||
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
|
||||
stopPropagation
|
||||
/>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<DiffChanges changes={summary()} />
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
|
||||
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
|
||||
<MessageNav
|
||||
messages={hoverMessages() ?? []}
|
||||
current={undefined}
|
||||
getLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive()) {
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
return
|
||||
}
|
||||
window.location.hash = `message-${message.id}`
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
||||
}}
|
||||
size="normal"
|
||||
class="w-60"
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
</HoverCard>
|
||||
</Show>
|
||||
<div
|
||||
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
|
||||
>
|
||||
|
|
@ -1689,6 +1744,7 @@ export default function Layout(props: ParentProps) {
|
|||
slug={base64Encode(props.project.worktree)}
|
||||
dense
|
||||
mobile={props.mobile}
|
||||
popover={false}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -1705,7 +1761,13 @@ export default function Layout(props: ParentProps) {
|
|||
</div>
|
||||
<For each={sessions(directory)}>
|
||||
{(session) => (
|
||||
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
|
||||
<SessionItem
|
||||
session={session}
|
||||
slug={base64Encode(directory)}
|
||||
dense
|
||||
mobile={props.mobile}
|
||||
popover={false}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
|
@ -1858,7 +1920,7 @@ export default function Layout(props: ParentProps) {
|
|||
</DragDropProvider>
|
||||
</div>
|
||||
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
|
||||
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
|
||||
</Tooltip>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
|||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
|
|
@ -788,17 +788,14 @@ export default function Page() {
|
|||
.filter((tab) => tab !== "context"),
|
||||
)
|
||||
|
||||
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
|
||||
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
|
||||
const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review")
|
||||
|
||||
const showTabs = createMemo(
|
||||
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
|
||||
)
|
||||
const showTabs = createMemo(() => view().reviewPanel.opened())
|
||||
|
||||
const activeTab = createMemo(() => {
|
||||
const active = tabs().active()
|
||||
if (active) return active
|
||||
if (reviewTab()) return "review"
|
||||
if (hasReview()) return "review"
|
||||
|
||||
const first = openedTabs()[0]
|
||||
if (first) return first
|
||||
|
|
@ -1096,8 +1093,8 @@ export default function Page() {
|
|||
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
|
||||
{/* Mobile tab bar - only shown on mobile when there are diffs */}
|
||||
<Show when={!isDesktop() && hasReview()}>
|
||||
{/* Mobile tab bar - only shown on mobile when user opened review */}
|
||||
<Show when={!isDesktop() && view().reviewPanel.opened()}>
|
||||
<Tabs class="h-auto">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger
|
||||
|
|
@ -1114,7 +1111,10 @@ export default function Page() {
|
|||
classes={{ button: "w-full" }}
|
||||
onClick={() => setStore("mobileTab", "review")}
|
||||
>
|
||||
{reviewCount()} Files Changed
|
||||
<Switch>
|
||||
<Match when={hasReview()}>{reviewCount()} Files Changed</Match>
|
||||
<Match when={true}>Review</Match>
|
||||
</Switch>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
|
@ -1139,41 +1139,40 @@ export default function Page() {
|
|||
when={!mobileReview()}
|
||||
fallback={
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
classes={{
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+24px)]",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
classes={{
|
||||
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Mark class="w-6 opacity-40" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<Show when={isDesktop()}>
|
||||
<div class="absolute inset-0 pointer-events-none z-50">
|
||||
<SessionMessageRail
|
||||
messages={visibleUserMessages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={scrollToMessage}
|
||||
wide={!showTabs()}
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
ref={setScrollRef}
|
||||
onScroll={(e) => {
|
||||
|
|
@ -1182,11 +1181,29 @@ export default function Page() {
|
|||
}}
|
||||
onClick={autoScroll.handleInteraction}
|
||||
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-y snap-mandatory"
|
||||
style={{ "--session-title-height": info()?.title ? "40px" : "0px" }}
|
||||
>
|
||||
<Show when={info()?.title}>
|
||||
<div
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
"w-full": true,
|
||||
"px-4 md:px-6": true,
|
||||
"md:max-w-200 md:mx-auto": !showTabs(),
|
||||
}}
|
||||
>
|
||||
<div class="h-10 flex items-center">
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto": !showTabs(),
|
||||
"mt-0.5": !showTabs(),
|
||||
"mt-0": showTabs(),
|
||||
}}
|
||||
|
|
@ -1236,6 +1253,7 @@ export default function Page() {
|
|||
data-message-id={message.id}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full snap-start": true,
|
||||
"md:max-w-200": !showTabs(),
|
||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform !== "desktop",
|
||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||
|
|
@ -1252,15 +1270,8 @@ export default function Page() {
|
|||
}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content:
|
||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||
container:
|
||||
"px-4 md:px-6 " +
|
||||
(!showTabs()
|
||||
? "md:max-w-200 md:mx-auto"
|
||||
: visibleUserMessages().length > 1
|
||||
? "md:pr-6 md:pl-18"
|
||||
: ""),
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1350,7 +1361,7 @@ export default function Page() {
|
|||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={true}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-3">
|
||||
<Show when={diffs()}>
|
||||
|
|
@ -1403,26 +1414,36 @@ export default function Page() {
|
|||
</div>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={true}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onViewFile={(path) => {
|
||||
const value = file.tab(path)
|
||||
tabs().open(value)
|
||||
file.load(path)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Mark class="w-6 opacity-40" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds } from "./date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
export function getWeekBounds(date: Date) {
|
||||
const dayOfWeek = date.getUTCDay()
|
||||
const offset = (date.getUTCDay() + 6) % 7
|
||||
const start = new Date(date)
|
||||
start.setUTCDate(date.getUTCDate() - dayOfWeek + 1)
|
||||
start.setUTCDate(date.getUTCDate() - offset)
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(start.getUTCDate() + 7)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { iife } from "@opencode-ai/util/iife"
|
|||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { DateTime } from "luxon"
|
||||
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
|
||||
import { createStore } from "solid-js/store"
|
||||
import z from "zod"
|
||||
import NotFound from "../[...404]"
|
||||
|
|
@ -296,13 +295,13 @@ export default function () {
|
|||
{(message) => (
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
sessionTitle={info().title}
|
||||
messageID={message.id}
|
||||
stepsExpanded={store.expandedSteps[message.id] ?? false}
|
||||
onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content:
|
||||
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
|
|
@ -353,26 +352,16 @@ export default function () {
|
|||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
|
||||
"mx-auto max-w-200": !wide(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex justify-start items-start min-w-0": true,
|
||||
"max-w-200 mx-auto px-6": wide(),
|
||||
"pr-6 pl-18": !wide() && messages().length > 1,
|
||||
"px-6": !wide() && messages().length === 1,
|
||||
"w-full flex justify-start items-start min-w-0 px-6": true,
|
||||
}}
|
||||
>
|
||||
{title()}
|
||||
</div>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<SessionMessageRail
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
wide={wide()}
|
||||
/>
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
|
|
@ -386,13 +375,7 @@ export default function () {
|
|||
classes={{
|
||||
root: "grow",
|
||||
content: "flex flex-col justify-between",
|
||||
container:
|
||||
"w-full pb-20 " +
|
||||
(wide()
|
||||
? "max-w-200 mx-auto px-6"
|
||||
: messages().length > 1
|
||||
? "pr-6 pl-18"
|
||||
: "px-6"),
|
||||
container: "w-full pb-20 px-6",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
|
||||
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
||||
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
||||
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
|
||||
const parts = model.split("/")
|
||||
const providerID = parts[0] ?? "opencode"
|
||||
const modelID = parts[1] ?? "gpt-5-nano"
|
||||
const now = Date.now()
|
||||
|
||||
const seed = async () => {
|
||||
const { Instance } = await import("../src/project/instance")
|
||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||
const { Session } = await import("../src/session")
|
||||
const { Identifier } = await import("../src/id/id")
|
||||
const { Project } = await import("../src/project/project")
|
||||
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title })
|
||||
const messageID = Identifier.descending("message")
|
||||
const partID = Identifier.descending("part")
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await seed()
|
||||
|
|
@ -226,7 +226,7 @@ export const rlang: Info = {
|
|||
}
|
||||
|
||||
export const uvformat: Info = {
|
||||
name: "uv format",
|
||||
name: "uv",
|
||||
command: ["uv", "format", "--", "$FILE"],
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
|
|
@ -337,23 +337,6 @@ export const rustfmt: Info = {
|
|||
command: ["rustfmt", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
if (!Bun.which("rustfmt")) return false
|
||||
const configs = ["rustfmt.toml", ".rustfmt.toml"]
|
||||
for (const config of configs) {
|
||||
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
|
||||
if (found.length > 0) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
export const cargofmt: Info = {
|
||||
name: "cargofmt",
|
||||
command: ["cargo", "fmt", "--", "$FILE"],
|
||||
extensions: [".rs"],
|
||||
async enabled() {
|
||||
if (!Bun.which("cargo")) return false
|
||||
const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree)
|
||||
return found.length > 0
|
||||
return Bun.which("rustfmt") !== null
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,11 +123,8 @@ export namespace ProviderTransform {
|
|||
return result
|
||||
}
|
||||
|
||||
if (
|
||||
model.capabilities.interleaved &&
|
||||
typeof model.capabilities.interleaved === "object" &&
|
||||
model.capabilities.interleaved.field === "reasoning_content"
|
||||
) {
|
||||
if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
|
||||
const field = model.capabilities.interleaved.field
|
||||
return msgs.map((msg) => {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
|
||||
|
|
@ -136,7 +133,7 @@ export namespace ProviderTransform {
|
|||
// Filter out reasoning parts from content
|
||||
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
|
||||
|
||||
// Include reasoning_content directly on the message for all assistant messages
|
||||
// Include reasoning_content | reasoning_details directly on the message for all assistant messages
|
||||
if (reasoningText) {
|
||||
return {
|
||||
...msg,
|
||||
|
|
@ -145,7 +142,7 @@ export namespace ProviderTransform {
|
|||
...msg.providerOptions,
|
||||
openaiCompatible: {
|
||||
...(msg.providerOptions as any)?.openaiCompatible,
|
||||
reasoning_content: reasoningText,
|
||||
[field]: reasoningText,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ export namespace ShareNext {
|
|||
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
}
|
||||
|
||||
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
|
||||
|
||||
export async function init() {
|
||||
if (disabled) return
|
||||
Bus.subscribe(Session.Event.Updated, async (evt) => {
|
||||
await sync(evt.properties.info.id, [
|
||||
{
|
||||
|
|
@ -63,6 +66,7 @@ export namespace ShareNext {
|
|||
}
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
if (disabled) return { id: "", url: "", secret: "" }
|
||||
log.info("creating share", { sessionID })
|
||||
const result = await fetch(`${await url()}/api/share`, {
|
||||
method: "POST",
|
||||
|
|
@ -110,6 +114,7 @@ export namespace ShareNext {
|
|||
|
||||
const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
|
||||
async function sync(sessionID: string, data: Data[]) {
|
||||
if (disabled) return
|
||||
const existing = queue.get(sessionID)
|
||||
if (existing) {
|
||||
for (const item of data) {
|
||||
|
|
@ -145,6 +150,7 @@ export namespace ShareNext {
|
|||
}
|
||||
|
||||
export async function remove(sessionID: string) {
|
||||
if (disabled) return
|
||||
log.info("removing share", { sessionID })
|
||||
const share = await get(sessionID)
|
||||
if (!share) return
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export namespace Share {
|
|||
const pending = new Map<string, any>()
|
||||
|
||||
export async function sync(key: string, content: any) {
|
||||
if (disabled) return
|
||||
const [root, ...splits] = key.split("/")
|
||||
if (root !== "session") return
|
||||
const [sub, sessionID] = splits
|
||||
|
|
@ -69,7 +70,10 @@ export namespace Share {
|
|||
process.env["OPENCODE_API"] ??
|
||||
(Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
|
||||
|
||||
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
|
||||
|
||||
export async function create(sessionID: string) {
|
||||
if (disabled) return { url: "", secret: "" }
|
||||
return fetch(`${URL}/share_create`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionID: sessionID }),
|
||||
|
|
@ -79,6 +83,7 @@ export namespace Share {
|
|||
}
|
||||
|
||||
export async function remove(sessionID: string, secret: string) {
|
||||
if (disabled) return {}
|
||||
return fetch(`${URL}/share_delete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sessionID, secret }),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
|
||||
*** Begin Patch
|
||||
[ one or more file sections ]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
[data-slot="hover-card-trigger"] {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="hover-card-content"] {
|
||||
|
|
@ -8,6 +10,7 @@
|
|||
max-width: 320px;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
pointer-events: auto;
|
||||
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ const icons = {
|
|||
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
|
||||
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
|
||||
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,25 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
|||
mouseActive: false,
|
||||
})
|
||||
|
||||
const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const nodeRect = node.getBoundingClientRect()
|
||||
const top = nodeRect.top - containerRect.top + container.scrollTop
|
||||
const bottom = top + nodeRect.height
|
||||
const viewTop = container.scrollTop
|
||||
const viewBottom = viewTop + container.clientHeight
|
||||
const target =
|
||||
block === "center"
|
||||
? top - container.clientHeight / 2 + nodeRect.height / 2
|
||||
: top < viewTop
|
||||
? top
|
||||
: bottom > viewBottom
|
||||
? bottom - container.clientHeight
|
||||
: viewTop
|
||||
const max = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
container.scrollTop = Math.max(0, Math.min(target, max))
|
||||
}
|
||||
|
||||
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
|
||||
|
||||
const searchProps = () => (typeof props.search === "object" ? props.search : {})
|
||||
|
|
@ -66,24 +85,31 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
|||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!scrollRef()) return
|
||||
const scroll = scrollRef()
|
||||
if (!scroll) return
|
||||
if (!props.current) return
|
||||
const key = props.key(props.current)
|
||||
requestAnimationFrame(() => {
|
||||
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`)
|
||||
element?.scrollIntoView({ block: "center" })
|
||||
const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
scrollIntoView(scroll, element, "center")
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const all = flat()
|
||||
if (store.mouseActive || all.length === 0) return
|
||||
const scroll = scrollRef()
|
||||
if (!scroll) return
|
||||
if (active() === props.key(all[0])) {
|
||||
scrollRef()?.scrollTo(0, 0)
|
||||
scroll.scrollTo(0, 0)
|
||||
return
|
||||
}
|
||||
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`)
|
||||
element?.scrollIntoView({ block: "center" })
|
||||
const key = active()
|
||||
if (!key) return
|
||||
const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
scrollIntoView(scroll, element, "center")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -213,6 +239,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
|||
setActive(props.key(item))
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!store.mouseActive) return
|
||||
setActive(null)
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@
|
|||
0 1px 2px 0 rgba(19, 16, 16, 0.06),
|
||||
0 1px 3px 0 rgba(19, 16, 16, 0.08);
|
||||
}
|
||||
|
||||
&[data-size="compact"] {
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-nav-item-button"] {
|
||||
|
|
|
|||
|
|
@ -96,8 +96,16 @@ const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: H
|
|||
}
|
||||
}
|
||||
|
||||
export const MessageNav = (props: MessageNavProps) => {
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
|
||||
export function MessageNav(
|
||||
props: ComponentProps<"nav"> & {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
size: "normal" | "compact"
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
getLabel?: (message: UserMessage) => string | undefined
|
||||
},
|
||||
) {
|
||||
const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
|
||||
let navRef: HTMLElement | undefined
|
||||
let listRef: HTMLUListElement | undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { checksum } from "@opencode-ai/util/encode"
|
|||
import { Tooltip } from "./tooltip"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
|
||||
interface Diagnostic {
|
||||
range: {
|
||||
|
|
@ -297,6 +298,23 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
|
|||
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
|
||||
const dialog = useDialog()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [canExpand, setCanExpand] = createSignal(false)
|
||||
let textRef: HTMLDivElement | undefined
|
||||
|
||||
const updateCanExpand = () => {
|
||||
const el = textRef
|
||||
if (!el) return
|
||||
if (expanded()) return
|
||||
setCanExpand(el.scrollHeight > el.clientHeight + 2)
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => textRef,
|
||||
() => {
|
||||
updateCanExpand()
|
||||
},
|
||||
)
|
||||
|
||||
const textPart = createMemo(
|
||||
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
|
||||
|
|
@ -304,6 +322,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||
|
||||
const text = createMemo(() => textPart()?.text || "")
|
||||
|
||||
createEffect(() => {
|
||||
text()
|
||||
updateCanExpand()
|
||||
})
|
||||
|
||||
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
|
||||
|
||||
const attachments = createMemo(() =>
|
||||
|
|
@ -335,7 +358,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message">
|
||||
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
|
||||
<Show when={attachments().length > 0}>
|
||||
<div data-slot="user-message-attachments">
|
||||
<For each={attachments()}>
|
||||
|
|
@ -365,8 +388,16 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
|||
</div>
|
||||
</Show>
|
||||
<Show when={text()}>
|
||||
<div data-slot="user-message-text">
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)}>
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
<button
|
||||
data-slot="user-message-expand"
|
||||
type="button"
|
||||
aria-label={expanded() ? "Collapse message" : "Expand message"}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
|
||||
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
[data-slot="session-message-rail-anchor"] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
height: -webkit-fill-available;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="session-message-rail-portal"] {
|
||||
z-index: 100;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
[data-component="session-message-rail"] {
|
||||
display: contents;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-component="session-message-rail"][data-wide] {
|
||||
margin-top: 0.125rem;
|
||||
left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
[data-component="session-message-rail"]:not([data-wide]) {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { ComponentProps, Show, splitProps, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { MessageNav } from "./message-nav"
|
||||
import "./session-message-rail.css"
|
||||
import { Portal } from "solid-js/web"
|
||||
|
||||
export interface SessionMessageRailProps extends ComponentProps<"div"> {
|
||||
messages: UserMessage[]
|
||||
current?: UserMessage
|
||||
wide?: boolean
|
||||
onMessageSelect: (message: UserMessage) => void
|
||||
}
|
||||
|
||||
export function SessionMessageRail(props: SessionMessageRailProps) {
|
||||
const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"])
|
||||
let anchorRef: HTMLDivElement | undefined
|
||||
const [position, setPosition] = createSignal({ top: 0, left: 0, height: 0 })
|
||||
|
||||
const updatePosition = () => {
|
||||
if (anchorRef) {
|
||||
const rect = anchorRef.getBoundingClientRect()
|
||||
setPosition({ top: rect.top, left: rect.left, height: rect.height })
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updatePosition()
|
||||
window.addEventListener("scroll", updatePosition, true)
|
||||
window.addEventListener("resize", updatePosition)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("scroll", updatePosition, true)
|
||||
window.removeEventListener("resize", updatePosition)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={(local.messages?.length ?? 0) > 1}>
|
||||
<div
|
||||
{...others}
|
||||
data-component="session-message-rail"
|
||||
data-wide={local.wide ? "" : undefined}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
>
|
||||
<div ref={(el) => (anchorRef = el)} data-slot="session-message-rail-anchor" />
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
data-slot="session-message-rail-portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: `${position().top}px`,
|
||||
left: `${position().left}px`,
|
||||
height: `${position().height}px`,
|
||||
}}
|
||||
>
|
||||
<MessageNav
|
||||
messages={local.messages}
|
||||
current={local.current}
|
||||
onMessageSelect={local.onMessageSelect}
|
||||
size={local.wide ? "normal" : "compact"}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
[data-component="session-turn"] {
|
||||
--session-turn-sticky-height: 0px;
|
||||
--sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 12px);
|
||||
/* flex: 1; */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
|
@ -29,23 +31,6 @@
|
|||
gap: 28px;
|
||||
overflow-anchor: none;
|
||||
|
||||
[data-slot="session-turn-user-badges"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-left: 16px;
|
||||
background: linear-gradient(to right, transparent, var(--background-stronger) 12px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover [data-slot="session-turn-user-badges"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-badge"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -61,23 +46,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-sticky-title"] {
|
||||
[data-slot="session-turn-attachments"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-sticky"] {
|
||||
width: calc(100% + 9px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: var(--session-title-height, 0px);
|
||||
z-index: 20;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: 21;
|
||||
margin-left: -9px;
|
||||
padding-left: 9px;
|
||||
padding-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-response-trigger"] {
|
||||
position: sticky;
|
||||
top: 32px;
|
||||
background-color: var(--background-stronger);
|
||||
z-index: 20;
|
||||
width: calc(100% + 9px);
|
||||
margin-left: -9px;
|
||||
padding-left: 9px;
|
||||
padding-bottom: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-header"] {
|
||||
|
|
@ -88,10 +89,72 @@
|
|||
}
|
||||
|
||||
[data-slot="session-turn-message-content"] {
|
||||
margin-top: -18px;
|
||||
margin-top: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-component="user-message"] [data-slot="user-message-text"] {
|
||||
max-height: var(--user-message-collapsed-height, 64px);
|
||||
}
|
||||
|
||||
[data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
[data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
|
||||
padding-right: 36px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-component="user-message"][data-can-expand="true"]
|
||||
[data-slot="user-message-text"]
|
||||
[data-slot="user-message-expand"],
|
||||
[data-component="user-message"][data-expanded="true"]
|
||||
[data-slot="user-message-text"]
|
||||
[data-slot="user-message-expand"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-weak);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="user-message"][data-expanded="true"]
|
||||
[data-slot="user-message-text"]
|
||||
[data-slot="user-message-expand"]
|
||||
[data-slot="icon-svg"] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
[data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
|
||||
background: var(--surface-raised-base);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-user-badges"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-title"] {
|
||||
width: 100%;
|
||||
font-size: var(--font-size-large);
|
||||
|
|
@ -276,10 +339,9 @@
|
|||
}
|
||||
|
||||
[data-component="sticky-accordion-header"] {
|
||||
top: var(--sticky-header-height, 40px);
|
||||
|
||||
top: var(--sticky-header-height, 0px);
|
||||
&[data-expanded]::before {
|
||||
top: calc(-1 * var(--sticky-header-height, 40px));
|
||||
top: calc(-1 * var(--sticky-header-height, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
AssistantMessage,
|
||||
FilePart,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
type PermissionRequest,
|
||||
|
|
@ -13,17 +14,13 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|||
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Typewriter } from "./typewriter"
|
||||
import { Message, Part } from "./message-part"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { ProviderIcon } from "./provider-icon"
|
||||
import type { IconName } from "./provider-icons/types"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Card } from "./card"
|
||||
|
|
@ -33,6 +30,7 @@ import { Spinner } from "./spinner"
|
|||
import { createStore } from "solid-js/store"
|
||||
import { DateTime, DurationUnit, Interval } from "luxon"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
|
||||
function computeStatusFromPart(part: PartType | undefined): string | undefined {
|
||||
if (!part) return undefined
|
||||
|
|
@ -79,6 +77,12 @@ function same<T>(a: readonly T[], b: readonly T[]) {
|
|||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
function isAttachment(part: PartType | undefined) {
|
||||
if (part?.type !== "file") return false
|
||||
const mime = (part as FilePart).mime ?? ""
|
||||
return mime.startsWith("image/") || mime === "application/pdf"
|
||||
}
|
||||
|
||||
function AssistantMessageItem(props: {
|
||||
message: AssistantMessage
|
||||
responsePartId: string | undefined
|
||||
|
|
@ -119,6 +123,7 @@ function AssistantMessageItem(props: {
|
|||
export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
messageID: string
|
||||
lastUserMessageID?: string
|
||||
stepsExpanded?: boolean
|
||||
|
|
@ -136,6 +141,7 @@ export function SessionTurn(
|
|||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyFiles: FilePart[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyPermissions: PermissionRequest[] = []
|
||||
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
|
||||
|
|
@ -183,6 +189,19 @@ export function SessionTurn(
|
|||
return data.store.part[msg.id] ?? emptyParts
|
||||
})
|
||||
|
||||
const attachmentParts = createMemo(() => {
|
||||
const msgParts = parts()
|
||||
if (msgParts.length === 0) return emptyFiles
|
||||
return msgParts.filter((part) => isAttachment(part)) as FilePart[]
|
||||
})
|
||||
|
||||
const stickyParts = createMemo(() => {
|
||||
const msgParts = parts()
|
||||
if (msgParts.length === 0) return emptyParts
|
||||
if (attachmentParts().length === 0) return msgParts
|
||||
return msgParts.filter((part) => !isAttachment(part))
|
||||
})
|
||||
|
||||
const assistantMessages = createMemo(
|
||||
() => {
|
||||
const msg = message()
|
||||
|
|
@ -330,10 +349,19 @@ export function SessionTurn(
|
|||
|
||||
const response = createMemo(() => lastTextPart()?.text)
|
||||
const responsePartId = createMemo(() => lastTextPart()?.id)
|
||||
const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
|
||||
const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0)
|
||||
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
||||
|
||||
const [responseCopied, setResponseCopied] = createSignal(false)
|
||||
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
|
||||
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
const updateStickyHeight = (height: number) => {
|
||||
const root = rootRef()
|
||||
if (!root) return
|
||||
const next = Math.ceil(height)
|
||||
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
|
||||
}
|
||||
const handleCopyResponse = async () => {
|
||||
const content = response()
|
||||
if (!content) return
|
||||
|
|
@ -364,13 +392,28 @@ export function SessionTurn(
|
|||
onUserInteracted: props.onUserInteracted,
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => stickyRef(),
|
||||
({ height }) => {
|
||||
updateStickyHeight(height)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const root = rootRef()
|
||||
if (!root) return
|
||||
const sticky = stickyRef()
|
||||
if (!sticky) {
|
||||
root.style.setProperty("--session-turn-sticky-height", "0px")
|
||||
return
|
||||
}
|
||||
updateStickyHeight(sticky.getBoundingClientRect().height)
|
||||
})
|
||||
|
||||
const diffInit = 20
|
||||
const diffBatch = 20
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
stickyTitleRef: undefined as HTMLDivElement | undefined,
|
||||
stickyTriggerRef: undefined as HTMLDivElement | undefined,
|
||||
stickyHeaderHeight: 0,
|
||||
retrySeconds: 0,
|
||||
diffsOpen: [] as string[],
|
||||
diffLimit: diffInit,
|
||||
|
|
@ -404,22 +447,6 @@ export function SessionTurn(
|
|||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => store.stickyTitleRef,
|
||||
({ height }) => {
|
||||
const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
|
||||
setStore("stickyHeaderHeight", height + triggerHeight + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createResizeObserver(
|
||||
() => store.stickyTriggerRef,
|
||||
({ height }) => {
|
||||
const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
|
||||
setStore("stickyHeaderHeight", titleHeight + height + 8)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setStore("duration", duration())
|
||||
|
|
@ -460,7 +487,7 @@ export function SessionTurn(
|
|||
})
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div data-component="session-turn" class={props.classes?.root} ref={setRootRef}>
|
||||
<div
|
||||
ref={autoScroll.scrollRef}
|
||||
onScroll={autoScroll.handleScroll}
|
||||
|
|
@ -475,86 +502,63 @@ export function SessionTurn(
|
|||
data-message={msg().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={isShellMode()}>
|
||||
<Part part={shellModePart()!} message={msg()} defaultOpen />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{/* Title (sticky) */}
|
||||
<div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
|
||||
<div data-slot="session-turn-message-header">
|
||||
<div data-slot="session-turn-message-title">
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<h1>{msg().summary?.title}</h1>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div data-slot="session-turn-user-badges">
|
||||
<Show when={(msg() as UserMessage).agent}>
|
||||
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
|
||||
</Show>
|
||||
<Show when={(msg() as UserMessage).model?.modelID}>
|
||||
<span data-slot="session-turn-badge" class="inline-flex items-center gap-1">
|
||||
<ProviderIcon
|
||||
id={(msg() as UserMessage).model!.providerID as IconName}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{(msg() as UserMessage).model?.modelID}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant || "default"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* User Message */}
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={msg()} parts={parts()} />
|
||||
</div>
|
||||
{/* Trigger (sticky) */}
|
||||
<Show when={working() || hasSteps()}>
|
||||
<div ref={(el) => setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-expandable={assistantMessages().length > 0}
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={props.onStepsExpandedToggle ?? (() => {})}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={retry()}>
|
||||
<span data-slot="session-turn-retry-message">
|
||||
{(() => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
|
||||
})()}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-seconds">
|
||||
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
|
||||
</Match>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
|
||||
<Match when={props.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!props.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Show>
|
||||
</Button>
|
||||
<Show when={attachmentParts().length > 0}>
|
||||
<div data-slot="session-turn-attachments">
|
||||
<Message message={msg()} parts={attachmentParts()} />
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="session-turn-sticky" ref={setStickyRef}>
|
||||
{/* User Message */}
|
||||
<div data-slot="session-turn-message-content">
|
||||
<Message message={msg()} parts={stickyParts()} />
|
||||
</div>
|
||||
|
||||
{/* Trigger (sticky) */}
|
||||
<Show when={working() || hasSteps()}>
|
||||
<div data-slot="session-turn-response-trigger">
|
||||
<Button
|
||||
data-expandable={assistantMessages().length > 0}
|
||||
data-slot="session-turn-collapsible-trigger-content"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={props.onStepsExpandedToggle ?? (() => {})}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={retry()}>
|
||||
<span data-slot="session-turn-retry-message">
|
||||
{(() => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
|
||||
})()}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-seconds">
|
||||
· retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
|
||||
</Match>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
|
||||
<Match when={props.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!props.stepsExpanded}>Show steps</Match>
|
||||
</Switch>
|
||||
<span>·</span>
|
||||
<span>{store.duration}</span>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
|
|
@ -612,7 +616,7 @@ export function SessionTurn(
|
|||
setStore("diffsOpen", value)
|
||||
}}
|
||||
>
|
||||
<For each={(msg().summary?.diffs ?? []).slice(0, store.diffLimit)}>
|
||||
<For each={(data.store.session_diff?.[props.sessionID] ?? []).slice(0, store.diffLimit)}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
|
|
@ -658,13 +662,13 @@ export function SessionTurn(
|
|||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
<Show when={(msg().summary?.diffs?.length ?? 0) > store.diffLimit}>
|
||||
<Show when={(data.store.session_diff?.[props.sessionID]?.length ?? 0) > store.diffLimit}>
|
||||
<Button
|
||||
data-slot="session-turn-accordion-more"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const total = msg().summary?.diffs?.length ?? 0
|
||||
const total = data.store.session_diff?.[props.sessionID]?.length ?? 0
|
||||
setStore("diffLimit", (limit) => {
|
||||
const next = limit + diffBatch
|
||||
if (next > total) return total
|
||||
|
|
@ -672,7 +676,8 @@ export function SessionTurn(
|
|||
})
|
||||
}}
|
||||
>
|
||||
Show more changes ({(msg().summary?.diffs?.length ?? 0) - store.diffLimit})
|
||||
Show more changes (
|
||||
{(data.store.session_diff?.[props.sessionID]?.length ?? 0) - store.diffLimit})
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
background: var(--input-base);
|
||||
|
||||
&:focus-within {
|
||||
border-color: transparent;
|
||||
/* border/shadow-xs/select */
|
||||
box-shadow:
|
||||
0 0 0 3px var(--border-weak-selected),
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ To use OpenCode as an ACP agent in [CodeCompanion.nvim](https://github.com/olimo
|
|||
|
||||
```lua
|
||||
require("codecompanion").setup({
|
||||
strategies = {
|
||||
interactions = {
|
||||
chat = {
|
||||
adapter = {
|
||||
name = "opencode",
|
||||
|
|
@ -138,7 +138,7 @@ require("codecompanion").setup({
|
|||
|
||||
This config sets up CodeCompanion to use OpenCode as the ACP agent for chat.
|
||||
|
||||
If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/configuration/adapters#environment-variables-setting-an-api-key) in the CodeCompanion.nvim documentation for full details.
|
||||
If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key) in the CodeCompanion.nvim documentation for full details.
|
||||
|
||||
## Support
|
||||
|
||||
|
|
|
|||
|
|
@ -654,21 +654,77 @@ GitLab Duo provides AI-powered agentic chat with native tool calling capabilitie
|
|||
- **duo-chat-sonnet-4-5** - Balanced performance for most workflows
|
||||
- **duo-chat-opus-4-5** - Most capable for complex analysis
|
||||
|
||||
:::note
|
||||
You can also specify 'GITLAB_TOKEN' environment variable if you don't want
|
||||
to store token in opencode auth storage.
|
||||
:::
|
||||
|
||||
##### Self-Hosted GitLab
|
||||
|
||||
:::note[compliance note]
|
||||
OpenCode uses a small model for some AI tasks like generating the session title.
|
||||
It is configured to use gpt-5-nano by default, hosted by Zen. To lock OpenCode
|
||||
to only use your own GitLab-hosted instance, add the following to your
|
||||
`opencode.json` file. It is also recommended to disable session sharing.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"small_model": "gitlab/duo-chat-haiku-4-5",
|
||||
"share": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
For self-hosted GitLab instances:
|
||||
|
||||
```bash
|
||||
GITLAB_INSTANCE_URL=https://gitlab.company.com GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx opencode
|
||||
export GITLAB_INSTANCE_URL=https://gitlab.company.com
|
||||
export GITLAB_TOKEN=glpat-...
|
||||
```
|
||||
|
||||
If your instance runs a custom AI Gateway:
|
||||
|
||||
```bash
|
||||
GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com
|
||||
```
|
||||
|
||||
Or add to your bash profile:
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export GITLAB_INSTANCE_URL=https://gitlab.company.com
|
||||
export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
||||
export GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com
|
||||
export GITLAB_TOKEN=glpat-...
|
||||
```
|
||||
|
||||
:::note
|
||||
Your GitLab administrator must enable the following:
|
||||
|
||||
1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for the user, group, or instance
|
||||
2. Feature flags (via Rails console):
|
||||
- `agent_platform_claude_code`
|
||||
- `third_party_agents_enabled`
|
||||
:::
|
||||
|
||||
##### OAuth for Self-Hosted instances
|
||||
|
||||
In order to make Oauth working for your self-hosted instance, you need to create
|
||||
a new application (Settings → Applications) with the
|
||||
callback URL `http://127.0.0.1:8080/callback` and following scopes:
|
||||
|
||||
- api (Access the API on your behalf)
|
||||
- read_user (Read your personal information)
|
||||
- read_repository (Allows read-only access to the repository)
|
||||
|
||||
Then expose application ID as environment variable:
|
||||
|
||||
```bash
|
||||
export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
|
||||
```
|
||||
|
||||
More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) homepage.
|
||||
|
||||
##### Configuration
|
||||
|
||||
Customize through `opencode.json`:
|
||||
|
|
@ -690,7 +746,7 @@ Customize through `opencode.json`:
|
|||
}
|
||||
```
|
||||
|
||||
##### GitLab API Tools (Optional)
|
||||
##### GitLab API Tools (Optional, but highly recommended)
|
||||
|
||||
To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@
|
|||
"opencode#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue