mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-09 16:14:07 +00:00
Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199011714e | ||
|
|
47d47367b8 | ||
|
|
8859836e5b | ||
|
|
8a3194e15b | ||
|
|
2f1cecbf64 | ||
|
|
b3777e2665 | ||
|
|
53331f3ea0 | ||
|
|
040283b301 | ||
|
|
cdf03ed933 | ||
|
|
0184c84f31 | ||
|
|
96639b34ea | ||
|
|
0c53772b93 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 | ||
|
|
2712244ad3 | ||
|
|
6388cbaf92 | ||
|
|
5cc61e1b53 | ||
|
|
0243be86a7 | ||
|
|
9154cd64e7 | ||
|
|
c71d1bde5e | ||
|
|
f27ef595f6 | ||
|
|
34328828ae | ||
|
|
18fb19da3b | ||
|
|
849e1ac543 | ||
|
|
656a8d8f55 | ||
|
|
e1dc03b7ba | ||
|
|
b976f339e8 | ||
|
|
7d7837e5b6 | ||
|
|
1db292f4df | ||
|
|
49a3a9fe36 | ||
|
|
e51ed460a6 | ||
|
|
49deb7207f | ||
|
|
91b9a03e27 | ||
|
|
d15c2ce349 | ||
|
|
5cc4bb4089 | ||
|
|
6e9e027886 | ||
|
|
f9a3d129a4 | ||
|
|
c53d1d3ad8 | ||
|
|
f386137fba | ||
|
|
c797b60069 | ||
|
|
a139e9297d | ||
|
|
050f99ec54 | ||
|
|
23ed652901 | ||
|
|
13a68f3de3 | ||
|
|
fdad35aaa7 | ||
|
|
a2ce4eb650 | ||
|
|
8fa04986cf | ||
|
|
a5710ed3e1 | ||
|
|
2efdc9df93 | ||
|
|
0c245886fe | ||
|
|
f03288b411 | ||
|
|
09388c98f3 | ||
|
|
ae25c1e7b7 | ||
|
|
0813c14cc6 | ||
|
|
a2b527ff29 | ||
|
|
b5151c421f | ||
|
|
e66fd079db | ||
|
|
207ebf4b8c | ||
|
|
12d862dbd3 | ||
|
|
981353793d | ||
|
|
426dcfa3b0 | ||
|
|
69cb49f7cc | ||
|
|
e30678a088 | ||
|
|
771b29a857 | ||
|
|
e6d1aae33a | ||
|
|
9dc8ac4734 | ||
|
|
fdd037ba20 | ||
|
|
0fdceb954c | ||
|
|
523f792b48 | ||
|
|
2230c3c401 | ||
|
|
1b494e5087 | ||
|
|
9c43893a0f | ||
|
|
6dfe19b445 | ||
|
|
fb7dd0661a | ||
|
|
3e19a8dcba | ||
|
|
a965a06259 | ||
|
|
0654f28c72 | ||
|
|
a32b76dee0 | ||
|
|
48cf609115 | ||
|
|
a52d640c8c | ||
|
|
a581493c13 | ||
|
|
e5b34076df | ||
|
|
0e642ed885 | ||
|
|
7af0546dc0 | ||
|
|
270f44f41d | ||
|
|
2db9d317f9 | ||
|
|
218869cf45 | ||
|
|
e99d7a4292 | ||
|
|
f0beb38f91 | ||
|
|
66fcab7b08 | ||
|
|
641e1781a2 | ||
|
|
490b95efe7 | ||
|
|
ba1edea0ab | ||
|
|
73c9b685a7 | ||
|
|
99d8aab0ac | ||
|
|
7dd6369952 | ||
|
|
06f60af1e9 | ||
|
|
66d0beba6f | ||
|
|
6b99dd50b6 | ||
|
|
c53c9d4e4e | ||
|
|
bbd0f3a252 | ||
|
|
b7e208b4f1 | ||
|
|
be9b4d1bcd | ||
|
|
5b5b791d75 | ||
|
|
a61f9b01e0 | ||
|
|
0b7a5b1e7b | ||
|
|
28bb16ca2a | ||
|
|
8a95be492d | ||
|
|
c42c5a0cc6 | ||
|
|
b2c2478d9d | ||
|
|
1a9af8acb6 | ||
|
|
4c7fe60493 | ||
|
|
95279abbbc | ||
|
|
1a2ddf9e0f | ||
|
|
c108f304c6 | ||
|
|
2b8acfa0e2 | ||
|
|
b83282b940 | ||
|
|
c4fd677785 | ||
|
|
770cb66628 | ||
|
|
b0bc3d87f5 | ||
|
|
a2634337b8 | ||
|
|
7417c869fc | ||
|
|
091cf25de8 | ||
|
|
f5dde52cc4 | ||
|
|
5df0c6e3c3 | ||
|
|
f807875a99 | ||
|
|
48158ce97d | ||
|
|
adc9536a16 | ||
|
|
c0eb929465 | ||
|
|
f8810780cc | ||
|
|
6a9d1eea69 | ||
|
|
add16af117 | ||
|
|
054f848307 | ||
|
|
3b2e3afebd | ||
|
|
f1c7d4cefb | ||
|
|
1454dd1dc1 | ||
|
|
5d68b1b148 | ||
|
|
68c435db79 | ||
|
|
5a7f03a384 | ||
|
|
4bcfb37567 | ||
|
|
9215af4465 | ||
|
|
a6265531d6 | ||
|
|
2499be3622 | ||
|
|
f6d989f836 | ||
|
|
fd365f9d1f | ||
|
|
2241568a72 | ||
|
|
2eb9f52fd1 | ||
|
|
eefe8a6d6d | ||
|
|
bd5f54887c | ||
|
|
fec8d5bcf1 | ||
|
|
e923047219 | ||
|
|
b19dc933a4 | ||
|
|
902268e0d1 | ||
|
|
6b00f4cb58 | ||
|
|
3392f89559 | ||
|
|
81037a4e30 | ||
|
|
807b8784e1 | ||
|
|
50188cdce3 | ||
|
|
2a098f68ed | ||
|
|
be7e4f5813 | ||
|
|
515687074e | ||
|
|
45adf54904 | ||
|
|
b384dac4b7 | ||
|
|
e993acec31 | ||
|
|
611e616010 | ||
|
|
b286c0ae3f | ||
|
|
81a61f8dbd | ||
|
|
752e449e38 | ||
|
|
a44f78c34a | ||
|
|
a5d727e7f9 | ||
|
|
7b5b665b4a | ||
|
|
b5515dd2f7 | ||
|
|
d16e5b98dc | ||
|
|
9dbf3a2042 | ||
|
|
5d419a0211 | ||
|
|
8b168981aa | ||
|
|
724dd665ec | ||
|
|
ab44597018 | ||
|
|
aec95c4d10 | ||
|
|
b2c82cb897 | ||
|
|
52b42258fa | ||
|
|
3026a005b6 | ||
|
|
a6f802d7fe | ||
|
|
9ef803be82 | ||
|
|
ce5c827a6e | ||
|
|
56decd79db | ||
|
|
fc258ea74f | ||
|
|
abd9e195ac | ||
|
|
9d78b69cd3 | ||
|
|
e31f00ad22 | ||
|
|
a90e8de050 | ||
|
|
eabf770053 | ||
|
|
86d7bdc542 | ||
|
|
d3ab78bba0 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d | ||
|
|
bb4d978684 |
38
.github/workflows/storybook.yml
vendored
Normal file
38
.github/workflows/storybook.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- ".github/workflows/storybook.yml"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
paths:
|
||||
- ".github/workflows/storybook.yml"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- "packages/storybook/**"
|
||||
- "packages/ui/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: storybook build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun --cwd packages/storybook build
|
||||
@@ -5,6 +5,11 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Use this tool to search GitHub pull requests by title and description.
|
||||
|
||||
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
|
||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||
- PR number and title
|
||||
- Author
|
||||
- State (open/closed/merged)
|
||||
|
||||
@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
|
||||
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
|
||||
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
|
||||
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
|
||||
"x86_64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-linux": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"aarch64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V",
|
||||
"x86_64-darwin": "sha256-TnrYykX8Mf/Ugtkix6V"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.29",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -100,6 +101,7 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
|
||||
@@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||
- `closeDialog(page, dialog)` - Close any dialog
|
||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||
- `withSession(sdk, title, callback)` - Create temp session
|
||||
- `withProject(...)` - Create temp project/workspace
|
||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||
- `clickListItem(container, filter)` - Click list item by key/text
|
||||
|
||||
**Selectors** (`selectors.ts`):
|
||||
@@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test"
|
||||
|
||||
### Error Handling
|
||||
|
||||
Tests should clean up after themselves:
|
||||
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||
|
||||
```typescript
|
||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
@@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||
})
|
||||
```
|
||||
|
||||
- Prefer `withSession(...)` for temp sessions
|
||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||
- Avoid calling `sdk.session.delete(...)` directly
|
||||
|
||||
### Timeouts
|
||||
|
||||
Default: 60s per test, 10s per assertion. Override when needed:
|
||||
@@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||
1. Choose appropriate folder or create new one
|
||||
2. Import from `../fixtures`
|
||||
3. Use helper functions from `../actions` and `../selectors`
|
||||
4. Clean up any created resources
|
||||
5. Use specific selectors (avoid CSS classes)
|
||||
6. Test one feature per test file
|
||||
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
||||
5. Clean up any created resources
|
||||
6. Use specific selectors (avoid CSS classes)
|
||||
7. Test one feature per test file
|
||||
|
||||
## Local Development
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { modKey, serverUrl } from "./utils"
|
||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
popoverBodySelector,
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
workspaceItemSelector,
|
||||
workspaceMenuTriggerSelector,
|
||||
} from "./selectors"
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page
|
||||
@@ -61,9 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||
}
|
||||
|
||||
export async function isSidebarClosed(page: Page) {
|
||||
const main = page.locator("main")
|
||||
const classes = (await main.getAttribute("class")) ?? ""
|
||||
return classes.includes("xl:border-l")
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toBeVisible()
|
||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||
}
|
||||
|
||||
export async function toggleSidebar(page: Page) {
|
||||
@@ -75,48 +74,34 @@ export async function openSidebar(page: Page) {
|
||||
if (!(await isSidebarClosed(page))) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
await button.click()
|
||||
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const opened = await expect(main)
|
||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
const opened = await expect(button)
|
||||
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
}
|
||||
|
||||
export async function closeSidebar(page: Page) {
|
||||
if (await isSidebarClosed(page)) return
|
||||
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const visible = await button
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
await button.click()
|
||||
|
||||
if (visible) await button.click()
|
||||
if (!visible) await toggleSidebar(page)
|
||||
|
||||
const main = page.locator("main")
|
||||
const closed = await expect(main)
|
||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
||||
const closed = await expect(button)
|
||||
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (closed) return
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(main).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page) {
|
||||
@@ -204,7 +189,7 @@ export async function createTestProject() {
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return root
|
||||
return resolveDirectory(root)
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
@@ -214,13 +199,40 @@ export async function cleanupTestProject(directory: string) {
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
let prev = ""
|
||||
let next = ""
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (skip.includes(slug)) return ""
|
||||
if (slug !== prev) {
|
||||
prev = slug
|
||||
next = ""
|
||||
return ""
|
||||
}
|
||||
next = slug
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
return next
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||
await expect(sessionEl).toBeVisible()
|
||||
await sessionEl.hover()
|
||||
return sessionEl
|
||||
@@ -321,6 +333,57 @@ export async function clickListItem(
|
||||
return item
|
||||
}
|
||||
|
||||
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||
const data = await sdk.session
|
||||
.status()
|
||||
.then((x) => x.data ?? {})
|
||||
.catch(() => undefined)
|
||||
return data?.[sessionID]
|
||||
}
|
||||
|
||||
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||
let prev = ""
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const info = await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!info) return true
|
||||
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||
if (next !== prev) {
|
||||
prev = next
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||
}
|
||||
|
||||
export async function cleanupSession(input: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
sdk?: ReturnType<typeof createSdk>
|
||||
}) {
|
||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||
if (current && current.type !== "idle") {
|
||||
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||
}
|
||||
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export async function withSession<T>(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
title: string,
|
||||
@@ -332,7 +395,7 @@ export async function withSession<T>(
|
||||
try {
|
||||
return await callback(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: session.id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,32 +633,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||
.first()
|
||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||
|
||||
const clicked = await trigger
|
||||
.click({ timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (clicked) {
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (opened) {
|
||||
await expect(close).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
const opened = await menu
|
||||
.waitFor({ state: "visible", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (opened) {
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
await expect(close).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
await trigger.click({ force: true })
|
||||
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
||||
await page.mouse.move(x, y)
|
||||
return menu
|
||||
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||
}
|
||||
|
||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||
@@ -608,11 +681,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
||||
|
||||
if (current === enabled) return
|
||||
|
||||
await openProjectMenu(page, projectSlug)
|
||||
const flip = async (timeout?: number) => {
|
||||
const menu = await openProjectMenu(page, projectSlug)
|
||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
return toggle.click({ force: true, timeout })
|
||||
}
|
||||
|
||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.click({ force: true })
|
||||
const flipped = await flip(1500)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!flipped) await flip()
|
||||
|
||||
const expected = enabled ? "New workspace" : "New session"
|
||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverName } from "../utils"
|
||||
import { serverNamePattern } 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()
|
||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||
})
|
||||
|
||||
test("server picker dialog opens from home", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
|
||||
const trigger = page.getByRole("button", { name: serverName })
|
||||
const trigger = page.getByRole("button", { name: serverNamePattern })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverName, serverUrl } from "../utils"
|
||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||
import { serverNamePattern, serverUrls } from "../utils"
|
||||
import { closeDialog, clickMenuItem } from "../actions"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||
await expect(row).toBeVisible()
|
||||
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
||||
|
||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click({ force: true })
|
||||
|
||||
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
await expect(menu).toBeVisible()
|
||||
await clickMenuItem(menu, /set as default/i)
|
||||
|
||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(async () =>
|
||||
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await ensurePopoverOpen()
|
||||
|
||||
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
|
||||
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
||||
await expect(serverRow).toBeVisible()
|
||||
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
||||
|
||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||
await expect(second).toBeVisible()
|
||||
await second.scrollIntoViewIfNeeded()
|
||||
await second.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
||||
|
||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||
await expect(third).toBeVisible()
|
||||
await third.scrollIntoViewIfNeeded()
|
||||
await third.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
|
||||
@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
|
||||
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const reviewPanel = page.locator("#review-panel")
|
||||
|
||||
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||
await expect(treeToggle).toBeVisible()
|
||||
if (await expanded(treeToggle)) await treeToggle.click()
|
||||
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
|
||||
await expect(reviewToggle).toBeVisible()
|
||||
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||
})
|
||||
|
||||
@@ -43,6 +43,13 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
|
||||
await tab.click()
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
await expect(viewer).toContainText("export default function FileTree")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
@@ -13,6 +13,8 @@ type TestFixtures = {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const directory = await createTestProject()
|
||||
const slug = dirSlug(directory)
|
||||
await seedStorage(page, { directory, extra: options?.extra })
|
||||
const root = await createTestProject()
|
||||
const slug = dirSlug(root)
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
return await callback({ directory, slug, gotoSession })
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
await cleanupTestProject(directory)
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async () => {
|
||||
await withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
|
||||
const open = async () => {
|
||||
const header = page.locator(".group\\/project").first()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
const menu = await openProjectMenu(page, slug)
|
||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -1,36 +1,8 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async () => {
|
||||
await openSidebar(page)
|
||||
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
@@ -53,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
})
|
||||
.poll(
|
||||
() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toMatch(/^(project|home)$/)
|
||||
|
||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
defocus,
|
||||
createTestProject,
|
||||
cleanupTestProject,
|
||||
openSidebar,
|
||||
setWorkspacesEnabled,
|
||||
sessionIDFromUrl,
|
||||
} from "../actions"
|
||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
||||
import { dirSlug, resolveDirectory } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||
await page.evaluate(
|
||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
||||
const key = "opencode.global.dat:layout"
|
||||
const raw = localStorage.getItem(key)
|
||||
const data = raw ? JSON.parse(raw) : {}
|
||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
||||
const current =
|
||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
||||
? sidebar.workspaces
|
||||
: {}
|
||||
const next = { ...current }
|
||||
|
||||
if (enabled) next[directory] = true
|
||||
if (!enabled) delete next[directory]
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
...data,
|
||||
sidebar: {
|
||||
...sidebar,
|
||||
workspaces: next,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, enabled },
|
||||
)
|
||||
}
|
||||
|
||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||
@@ -51,46 +72,39 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
let rootDir: string | undefined
|
||||
let workspaceDir: string | undefined
|
||||
let sessionID: string | undefined
|
||||
|
||||
try {
|
||||
await withProject(
|
||||
async ({ directory, slug }) => {
|
||||
rootDir = directory
|
||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||
await defocus(page)
|
||||
await workspaces(page, directory, true)
|
||||
await page.reload()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, slug, true)
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const next = slugFromUrl(page.url())
|
||||
if (!next) return ""
|
||||
if (next === slug) return ""
|
||||
return next
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
workspaceDir = base64Decode(workspaceSlug)
|
||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
||||
const raw = await waitSlug(page, [slug])
|
||||
const dir = base64Decode(raw)
|
||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||
const space = await resolveDirectory(dir)
|
||||
const next = dirSlug(space)
|
||||
trackDirectory(space)
|
||||
await openSidebar(page)
|
||||
|
||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
await expect(workspace).toBeVisible()
|
||||
await workspace.hover()
|
||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
|
||||
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
||||
await expect(newSession).toBeVisible()
|
||||
await newSession.click({ force: true })
|
||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||
await expect(btn).toBeVisible()
|
||||
await btn.click({ force: true })
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
||||
// against the resolved workspace slug.
|
||||
await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
@@ -103,9 +117,9 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
sessionID = created
|
||||
trackSession(created, space)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
await openSidebar(page)
|
||||
|
||||
@@ -124,20 +138,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
if (sessionID) {
|
||||
const id = sessionID
|
||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
||||
await Promise.all(
|
||||
dirs.map((directory) =>
|
||||
createSdk(directory)
|
||||
.session.delete({ sessionID: id })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (workspaceDir) {
|
||||
await cleanupTestProject(workspaceDir)
|
||||
}
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { base64Decode } from "@opencode-ai/util/encode"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||
await openSidebar(page)
|
||||
await expect
|
||||
@@ -31,20 +27,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||
await openSidebar(page)
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
if (slug === root) return ""
|
||||
if (seen.includes(slug)) return ""
|
||||
return slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.not.toBe("")
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const slug = await waitSlug(page, [root, ...seen])
|
||||
const directory = base64Decode(slug)
|
||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||
return { slug, directory }
|
||||
@@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
|
||||
await expect(button).toBeVisible()
|
||||
await button.click({ force: true })
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
||||
const next = await waitSlug(page)
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||
return next
|
||||
}
|
||||
|
||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||
await openWorkspaceNewSession(page, slug)
|
||||
const next = await openWorkspaceNewSession(page, slug)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
@@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||
await prompt.press("Enter")
|
||||
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
|
||||
const sessionID = sessionIDFromUrl(page.url())
|
||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return sessionID
|
||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||
return { sessionID, slug: next }
|
||||
}
|
||||
|
||||
async function sessionDirectory(directory: string, sessionID: string) {
|
||||
@@ -97,48 +81,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
|
||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
await withProject(async ({ directory, slug: root }) => {
|
||||
const workspaces = [] as { slug: string; directory: string }[]
|
||||
const sessions = [] as string[]
|
||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
|
||||
try {
|
||||
await openSidebar(page)
|
||||
await setWorkspacesEnabled(page, root, true)
|
||||
const first = await createWorkspace(page, root, [])
|
||||
trackDirectory(first.directory)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
|
||||
const first = await createWorkspace(page, root, [])
|
||||
workspaces.push(first)
|
||||
await waitWorkspaceReady(page, first.slug)
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
trackDirectory(second.directory)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
|
||||
const second = await createWorkspace(page, root, [first.slug])
|
||||
workspaces.push(second)
|
||||
await waitWorkspaceReady(page, second.slug)
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
trackSession(firstSession.sessionID, first.directory)
|
||||
|
||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||
sessions.push(firstSession)
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
trackSession(secondSession.sessionID, second.directory)
|
||||
|
||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||
sessions.push(secondSession)
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
trackSession(thirdSession.sessionID, first.directory)
|
||||
|
||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||
sessions.push(thirdSession)
|
||||
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
||||
} finally {
|
||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
||||
await Promise.all(
|
||||
sessions.map((sessionID) =>
|
||||
Promise.all(
|
||||
dirs.map((dir) =>
|
||||
createSdk(dir)
|
||||
.session.delete({ sessionID })
|
||||
.catch(() => undefined),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
||||
}
|
||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,14 +14,12 @@ import {
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
setWorkspacesEnabled,
|
||||
slugFromUrl,
|
||||
waitSlug,
|
||||
} from "../actions"
|
||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
import { createSdk, dirSlug } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
await openSidebar(page)
|
||||
@@ -29,17 +27,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
await setWorkspacesEnabled(page, rootSlug, true)
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const slug = await waitSlug(page, [rootSlug])
|
||||
const dir = base64Decode(slug)
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -91,18 +79,7 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const currentSlug = slugFromUrl(page.url())
|
||||
return currentSlug.length > 0 && currentSlug !== slug
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
const workspaceSlug = await waitSlug(page, [slug])
|
||||
const workspaceDir = base64Decode(workspaceSlug)
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -279,7 +256,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
await confirmDialog(page, /^Delete workspace$/i)
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -336,9 +313,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
||||
const src = page.locator(workspaceItemSelector(from)).first()
|
||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||
|
||||
await src.scrollIntoViewIfNeeded()
|
||||
await dst.scrollIntoViewIfNeeded()
|
||||
|
||||
const a = await src.boundingBox()
|
||||
const b = await dst.boundingBox()
|
||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||
@@ -357,17 +331,7 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
||||
for (const _ of [0, 1]) {
|
||||
const prev = slugFromUrl(page.url())
|
||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const slug = await waitSlug(page, [rootSlug, prev])
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
||||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
const value = `restore ${Date.now()}`
|
||||
|
||||
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||
}),
|
||||
)
|
||||
|
||||
await gotoSession(session.id)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(value)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
181
packages/app/e2e/prompt/prompt-history.spec.ts
Normal file
181
packages/app/e2e/prompt/prompt-history.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
if (!("type" in part) || part.type !== "tool") return false
|
||||
if (!("tool" in part) || part.tool !== "bash") return false
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
async function edge(page: Page, pos: "start" | "end") {
|
||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
const nodes: Text[] = []
|
||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||
nodes.push(node as Text)
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
const node = document.createTextNode("")
|
||||
el.appendChild(node)
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||
const range = document.createRange()
|
||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}, pos)
|
||||
}
|
||||
|
||||
async function wait(page: Page, value: string) {
|
||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||
}
|
||||
|
||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
const part = messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter(isBash)
|
||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||
|
||||
if (!part || part.state.status !== "completed") return
|
||||
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await edge(page, "start")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, draft)
|
||||
})
|
||||
})
|
||||
|
||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, first, firstToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, second, secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
})
|
||||
})
|
||||
62
packages/app/e2e/prompt/prompt-shell.spec.ts
Normal file
62
packages/app/e2e/prompt/prompt-shell.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
if (!("type" in part) || part.type !== "tool") return false
|
||||
if (!("tool" in part) || part.tool !== "bash") return false
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
const sdk = createSdk(directory)
|
||||
const prompt = page.locator(promptSelector)
|
||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||
|
||||
await gotoSession()
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
trackSession(id, directory)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||
const msg = list.findLast(
|
||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||
)
|
||||
if (!msg) return
|
||||
|
||||
const part = msg.parts
|
||||
.filter(isBash)
|
||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||
|
||||
if (!part || part.state.status !== "completed") return
|
||||
const output =
|
||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||
if (!output.includes("README.md")) return
|
||||
|
||||
return { cwd: directory, output }
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||
|
||||
await expect(prompt).toHaveText("")
|
||||
})
|
||||
})
|
||||
64
packages/app/e2e/prompt/prompt-slash-share.spec.ts
Normal file
64
packages/app/e2e/prompt/prompt-slash-share.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "e2e share seed" }],
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await seed(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl, withSession } from "../actions"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||
.toContain(token)
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
|
||||
@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||
export const projectSwitchSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||
|
||||
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
||||
|
||||
export const projectMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
@@ -26,7 +26,7 @@ async function withDockSession<T>(
|
||||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: session.id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -358,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ async function seedConversation(input: {
|
||||
.toBe(true)
|
||||
|
||||
if (!userMessageID) throw new Error("Expected a user message id")
|
||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(seeded.prompt).not.toContainText(token)
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage.first()).toBeVisible()
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(secondMessage).toHaveCount(1)
|
||||
|
||||
await second.prompt.click()
|
||||
await page.keyboard.press(`${modKey}+A`)
|
||||
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
||||
})
|
||||
.toBe(second.userMessageID)
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
||||
})
|
||||
.toBe(second.userMessageID)
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(secondMessage).toHaveCount(0)
|
||||
|
||||
await second.prompt.click()
|
||||
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
||||
})
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(firstMessage.first()).toBeVisible()
|
||||
await expect(secondMessage.first()).toBeVisible()
|
||||
await expect(firstMessage).toHaveCount(1)
|
||||
await expect(secondMessage).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
const main = page.locator("main")
|
||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
||||
|
||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
||||
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||
|
||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||
await page.waitForTimeout(100)
|
||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
||||
|
||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
||||
const finalClosed = finalClasses.includes("xl:border-l")
|
||||
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||
expect(finalClosed).toBe(initiallyClosed)
|
||||
})
|
||||
|
||||
|
||||
@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
const select = dialog.locator(settingsThemeSelector)
|
||||
await expect(select).toBeVisible()
|
||||
|
||||
const currentThemeId = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-theme")
|
||||
})
|
||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
||||
expect(firstTheme).toBeTruthy()
|
||||
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
||||
.map((x) => x.trim())
|
||||
.find((x) => x && x !== currentTheme)
|
||||
expect(nextTheme).toBeTruthy()
|
||||
|
||||
await items.nth(1).click()
|
||||
await items.filter({ hasText: nextTheme! }).first().click()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
})
|
||||
|
||||
expect(storedThemeId).not.toBeNull()
|
||||
expect(storedThemeId).not.toBe("oc-1")
|
||||
expect(storedThemeId).not.toBe(currentThemeId)
|
||||
|
||||
const dataTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute("data-theme")
|
||||
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||
expect(dataTheme).toBe(storedThemeId)
|
||||
})
|
||||
|
||||
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||
})
|
||||
|
||||
await gotoSession()
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
return localStorage.getItem("opencode-theme-id")
|
||||
})
|
||||
})
|
||||
.toBe("oc-2")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
return localStorage.getItem("opencode-theme-css-light")
|
||||
})
|
||||
})
|
||||
.toBeNull()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
return localStorage.getItem("opencode-theme-css-dark")
|
||||
})
|
||||
})
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
|
||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
@@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
await gotoSession(one.id)
|
||||
await closeSidebar(page)
|
||||
|
||||
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
||||
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
||||
|
||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||
await expect(project).toBeVisible()
|
||||
await project.hover()
|
||||
|
||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
await expect(oneItem).toBeVisible()
|
||||
await expect(twoItem).toBeVisible()
|
||||
|
||||
const item = await hoverSessionItem(page, one.id)
|
||||
await item
|
||||
@@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
||||
await expect(twoItem).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: one.id })
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { cleanupSession, openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
@@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
||||
|
||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(target).toBeVisible()
|
||||
await target.scrollIntoViewIfNeeded()
|
||||
await target.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
await cleanupSession({ sdk, sessionID: one.id })
|
||||
await cleanupSession({ sdk, sessionID: two.id })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await openSidebar(page)
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||
@@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
|
||||
await gotoSession(session1.id)
|
||||
|
||||
await openSidebar(page)
|
||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||
await toggleSidebar(page)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await gotoSession(session2.id)
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
await page.reload()
|
||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
const opened = await page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||
|
||||
141
packages/app/e2e/terminal/terminal-tabs.spec.ts
Normal file
141
packages/app/e2e/terminal/terminal-tabs.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { terminalSelector } from "../selectors"
|
||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||
|
||||
type State = {
|
||||
active?: string
|
||||
all: Array<{
|
||||
id: string
|
||||
title: string
|
||||
titleNumber: number
|
||||
buffer?: string
|
||||
}>
|
||||
}
|
||||
|
||||
async function open(page: Page) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
const visible = await terminal.isVisible().catch(() => false)
|
||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
}
|
||||
|
||||
async function run(page: Page, cmd: string) {
|
||||
const terminal = page.locator(terminalSelector)
|
||||
await expect(terminal).toBeVisible()
|
||||
await terminal.click()
|
||||
await page.keyboard.type(cmd)
|
||||
await page.keyboard.press("Enter")
|
||||
// powershell + windows just isnt that fast... we need to wait
|
||||
await page.waitForTimeout(3_000)
|
||||
}
|
||||
|
||||
async function store(page: Page, key: string) {
|
||||
return page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (raw) return JSON.parse(raw) as State
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const next = localStorage.key(i)
|
||||
if (!next?.endsWith(":workspace:terminal")) continue
|
||||
const value = localStorage.getItem(next)
|
||||
if (!value) continue
|
||||
return JSON.parse(value) as State
|
||||
}
|
||||
}, key)
|
||||
}
|
||||
|
||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await run(page, `echo ${one}`)
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
await run(page, `echo ${two}`)
|
||||
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
})
|
||||
|
||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
|
||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await second.hover()
|
||||
await page
|
||||
.getByRole("button", { name: /close terminal/i })
|
||||
.nth(1)
|
||||
.click({ force: true })
|
||||
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
await expect(tabs).toHaveCount(1)
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
return {
|
||||
count: state?.all.length ?? 0,
|
||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||
}
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toEqual({ count: 1, first: true })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
@@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||
export const serverName = `${serverHost}:${serverPort}`
|
||||
|
||||
const localHosts = ["127.0.0.1", "localhost"]
|
||||
|
||||
const serverLabels = (() => {
|
||||
const url = new URL(serverUrl)
|
||||
if (!localHosts.includes(url.hostname)) return [serverName]
|
||||
return localHosts.map((host) => `${host}:${url.port}`)
|
||||
})()
|
||||
|
||||
export const serverNames = [...new Set(serverLabels)]
|
||||
|
||||
export const serverUrls = serverNames.map((name) => `http://${name}`)
|
||||
|
||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
|
||||
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
@@ -14,6 +30,12 @@ export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string) {
|
||||
return createSdk(directory)
|
||||
.path.get()
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
const result = await sdk.path.get()
|
||||
@@ -33,3 +55,9 @@ export function dirPath(directory: string) {
|
||||
export function sessionPath(directory: string, sessionID?: string) {
|
||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||
}
|
||||
|
||||
export function workspacePersistKey(directory: string, key: string) {
|
||||
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(directory) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.23",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
;(function () {
|
||||
var themeId = localStorage.getItem("opencode-theme-id")
|
||||
if (!themeId) return
|
||||
var key = "opencode-theme-id"
|
||||
var themeId = localStorage.getItem(key) || "oc-2"
|
||||
|
||||
if (themeId === "oc-1") {
|
||||
themeId = "oc-2"
|
||||
localStorage.setItem(key, themeId)
|
||||
localStorage.removeItem("opencode-theme-css-light")
|
||||
localStorage.removeItem("opencode-theme-css-dark")
|
||||
}
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
@@ -9,9 +16,9 @@
|
||||
document.documentElement.dataset.theme = themeId
|
||||
document.documentElement.dataset.colorScheme = mode
|
||||
|
||||
if (themeId === "oc-1") return
|
||||
if (themeId === "oc-2") return
|
||||
|
||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
||||
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||
if (css) {
|
||||
var style = document.createElement("style")
|
||||
style.id = "oc-theme-preload"
|
||||
|
||||
@@ -6,10 +6,19 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const optimistic: Array<{
|
||||
message: {
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
|
||||
@@ -26,6 +35,7 @@ const clientFor = (directory: string) => {
|
||||
return { data: undefined }
|
||||
},
|
||||
prompt: async () => ({ data: undefined }),
|
||||
promptAsync: async () => ({ data: undefined }),
|
||||
command: async () => ({ data: undefined }),
|
||||
abort: async () => ({ data: undefined }),
|
||||
},
|
||||
@@ -40,7 +50,7 @@ beforeAll(async () => {
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useParams: () => params,
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||
@@ -62,7 +72,7 @@ beforeAll(async () => {
|
||||
useLocal: () => ({
|
||||
model: {
|
||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||
variant: { current: () => undefined },
|
||||
variant: { current: () => variant },
|
||||
},
|
||||
agent: {
|
||||
current: () => ({ name: "agent" }),
|
||||
@@ -118,7 +128,11 @@ beforeAll(async () => {
|
||||
data: { command: [] },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: () => undefined,
|
||||
add: (value: {
|
||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||
}) => {
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
@@ -155,9 +169,12 @@ beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
optimistic.length = 0
|
||||
params = {}
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
@@ -219,4 +236,39 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
|
||||
test("includes the selected variant on optimistic prompts", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
const submit = createPromptSubmit({
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(optimistic).toHaveLength(1)
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
variant: "high",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -316,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
model,
|
||||
variant,
|
||||
}
|
||||
|
||||
const addOptimisticMessage = () =>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages"
|
||||
|
||||
function user(id: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "user",
|
||||
sessionID: "session-1",
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
function assistant(id: string, parentID: string): Message {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
sessionID: "session-1",
|
||||
parentID,
|
||||
time: { created: 1 },
|
||||
} as unknown as Message
|
||||
}
|
||||
|
||||
describe("findAssistantMessages", () => {
|
||||
test("normal ordering: assistant after user in array → found via forward scan", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("clock skew: assistant before user in array → found via backward scan", () => {
|
||||
// When client clock is ahead, user ID sorts after assistant ID,
|
||||
// so assistant appears earlier in the ID-sorted message array
|
||||
const messages = [assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 1, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("no assistant messages → returns empty array", () => {
|
||||
const messages = [user("u1"), user("u2")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple assistant messages with matching parentID → all found", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("a1")
|
||||
expect(result[1].id).toBe("a2")
|
||||
})
|
||||
|
||||
test("does not return assistant messages with different parentID", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops forward scan at next user message", () => {
|
||||
const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")]
|
||||
const result = findAssistantMessages(messages, 0, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("stops backward scan at previous user message", () => {
|
||||
const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")]
|
||||
const result = findAssistantMessages(messages, 3, "u1")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe("a1")
|
||||
})
|
||||
|
||||
test("invalid index returns empty array", () => {
|
||||
const messages = [user("u1")]
|
||||
expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0)
|
||||
expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -303,7 +303,12 @@ export function SessionHeader() {
|
||||
})
|
||||
|
||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
const current = createMemo(
|
||||
() =>
|
||||
options().find((o) => o.id === prefs.app) ??
|
||||
options()[0] ??
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
|
||||
const selectApp = (app: OpenApp) => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
const CREATE_WORKTREE = "create"
|
||||
const ROOT_CLASS =
|
||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
||||
const ROOT_CLASS = "size-full flex flex-col"
|
||||
|
||||
interface NewSessionViewProps {
|
||||
worktree: string
|
||||
@@ -50,33 +50,43 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
|
||||
return (
|
||||
<div class={ROOT_CLASS}>
|
||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak select-text leading-5">
|
||||
{getDirectory(projectRoot())}
|
||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||
<div class="h-12 shrink-0" aria-hidden />
|
||||
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
||||
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<Mark class="w-10" />
|
||||
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-4 items-center">
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
{getDirectory(projectRoot())}
|
||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start justify-center gap-1.5 min-h-5">
|
||||
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
{label(current())}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
{(project) => (
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
{language.t("session.new.lastModified")}
|
||||
<span class="text-text-strong">
|
||||
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
||||
.setLocale(language.intl())
|
||||
.toRelative()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
{(project) => (
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak leading-5">
|
||||
{language.t("session.new.lastModified")}
|
||||
<span class="text-text-strong">
|
||||
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
||||
.setLocale(language.intl())
|
||||
.toRelative()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const currentTheme = theme.themes()[theme.themeId()]
|
||||
if (!currentTheme) return fallback
|
||||
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
|
||||
if (!variant?.seeds) return fallback
|
||||
if (!variant?.seeds && !variant?.palette) return fallback
|
||||
const resolved = resolveThemeVariant(variant, mode === "dark")
|
||||
const text = resolved["text-stronger"] ?? fallback.foreground
|
||||
const background = resolved["background-stronger"] ?? fallback.background
|
||||
|
||||
@@ -155,7 +155,7 @@ export function Titlebar() {
|
||||
|
||||
return (
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
@@ -266,10 +266,13 @@ export function Titlebar() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
|
||||
BETA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none">
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
@@ -189,6 +189,7 @@ function createGlobalSync() {
|
||||
})
|
||||
if (next.length !== store.session.length) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||
}
|
||||
children.unpin(directory)
|
||||
return
|
||||
@@ -220,6 +221,7 @@ function createGlobalSync() {
|
||||
}),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
|
||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||
({
|
||||
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("cleans caches for trimmed sessions on session.created", () => {
|
||||
const dropped = rootSession({ id: "ses_b" })
|
||||
const kept = rootSession({ id: "ses_a" })
|
||||
const message = userMessage("msg_1", dropped.id)
|
||||
const todos: string[] = []
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
session: [dropped],
|
||||
message: { [dropped.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
question: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: kept } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
setSessionTodo(sessionID, value) {
|
||||
if (value !== undefined) return
|
||||
todos.push(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||
expect(store.message[dropped.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.question[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
|
||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "ses_keep" })],
|
||||
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
|
||||
}),
|
||||
)
|
||||
|
||||
cleanupDroppedSessionCaches(store, setStore, store.session)
|
||||
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
const hasAny =
|
||||
store.message[sessionID] !== undefined ||
|
||||
store.session_diff[sessionID] !== undefined ||
|
||||
store.todo[sessionID] !== undefined ||
|
||||
store.permission[sessionID] !== undefined ||
|
||||
store.question[sessionID] !== undefined ||
|
||||
store.session_status[sessionID] !== undefined
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
if (!hasAny) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[sessionID]
|
||||
if (messages) {
|
||||
for (const message of messages) {
|
||||
const id = message?.id
|
||||
if (!id) continue
|
||||
delete draft.part[id]
|
||||
}
|
||||
}
|
||||
delete draft.message[sessionID]
|
||||
delete draft.session_diff[sessionID]
|
||||
delete draft.todo[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.question[sessionID]
|
||||
delete draft.session_status[sessionID]
|
||||
dropSessionCaches(draft, [sessionID])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: Session[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...Object.keys(store.message),
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.question),
|
||||
...Object.keys(store.session_status),
|
||||
...Object.values(store.part)
|
||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||
.filter((sessionID): sessionID is string => !!sessionID),
|
||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||
if (stale.length === 0) return
|
||||
for (const sessionID of stale) {
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
|
||||
102
packages/app/src/context/global-sync/session-cache.test.ts
Normal file
102
packages/app/src/context/global-sync/session-cache.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
}) as Message
|
||||
|
||||
const part = (id: string, sessionID: string, messageID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
}) as Part
|
||||
|
||||
describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dropSessionCaches clears message-backed parts", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[m.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
||||
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
||||
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen,
|
||||
keep: "ses_4",
|
||||
limit: 2,
|
||||
preserve: ["ses_1"],
|
||||
})
|
||||
|
||||
expect(stale).toEqual(["ses_2", "ses_3"])
|
||||
expect([...seen]).toEqual(["ses_1", "ses_4"])
|
||||
})
|
||||
})
|
||||
62
packages/app/src/context/global-sync/session-cache.ts
Normal file
62
packages/app/src/context/global-sync/session-cache.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
}
|
||||
|
||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
|
||||
if (stale.size === 0) return
|
||||
|
||||
for (const key of Object.keys(store.part)) {
|
||||
const parts = store.part[key]
|
||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||
delete store.part[key]
|
||||
}
|
||||
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
delete store.question[sessionID]
|
||||
}
|
||||
}
|
||||
|
||||
export function pickSessionCacheEvictions(input: {
|
||||
seen: Set<string>
|
||||
keep: string
|
||||
limit: number
|
||||
preserve?: Iterable<string>
|
||||
}) {
|
||||
const stale: string[] = []
|
||||
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
|
||||
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
|
||||
input.seen.add(input.keep)
|
||||
for (const id of input.seen) {
|
||||
if (input.seen.size - stale.length <= input.limit) break
|
||||
if (keep.has(id)) continue
|
||||
stale.push(id)
|
||||
}
|
||||
for (const id of stale) {
|
||||
input.seen.delete(id)
|
||||
}
|
||||
return stale
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const maxDirs = 30
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const seenFor = (directory: string) => {
|
||||
const existing = seen.get(directory)
|
||||
if (existing) {
|
||||
seen.delete(directory)
|
||||
seen.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.set(directory, created)
|
||||
while (seen.size > maxDirs) {
|
||||
const first = seen.keys().next().value
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
delete draft.limit[key]
|
||||
delete draft.complete[key]
|
||||
delete draft.loading[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
}),
|
||||
)
|
||||
clearMeta(directory, sessionIDs)
|
||||
}
|
||||
|
||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: seenFor(directory),
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
})
|
||||
evict(directory, setStore, stale)
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof sdk.client
|
||||
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
setMeta("loading", key, true)
|
||||
await fetchMessages(input)
|
||||
.then((next) => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
@@ -199,6 +262,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
const message: Message = {
|
||||
id: input.messageID,
|
||||
@@ -207,6 +271,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
}
|
||||
const [, setStore] = target()
|
||||
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
|
||||
@@ -222,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
@@ -256,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
}),
|
||||
)
|
||||
@@ -269,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
@@ -285,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
@@ -308,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
@@ -323,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
},
|
||||
evict(sessionID: string, directory = sdk.directory) {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
seenFor(directory).delete(sessionID)
|
||||
evict(directory, setStore, [sessionID])
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
@@ -38,6 +38,16 @@ type TerminalCacheEntry = {
|
||||
|
||||
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
||||
|
||||
const trimTerminal = (pty: LocalPTY) => {
|
||||
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
|
||||
return {
|
||||
...pty,
|
||||
buffer: undefined,
|
||||
cursor: undefined,
|
||||
scrollY: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
for (const cache of caches) {
|
||||
@@ -188,6 +198,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
console.error("Failed to update terminal", error)
|
||||
})
|
||||
},
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
if (index === -1) return
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
trimAll() {
|
||||
setStore("all", (all) => {
|
||||
const next = all.map(trimTerminal)
|
||||
if (next.every((pty, index) => pty === all[index])) return all
|
||||
return next
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
@@ -322,12 +344,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (next.dir === prev.dir && next.id) return
|
||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
|
||||
@@ -456,6 +456,7 @@ export const dict = {
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.new.title": "ابنِ أي شيء",
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||
|
||||
@@ -459,6 +459,7 @@ export const dict = {
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.new.title": "Crie qualquer coisa",
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
"session.new.worktree.create": "Criar novo worktree",
|
||||
|
||||
@@ -515,6 +515,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Sažmi",
|
||||
"session.todo.expand": "Proširi",
|
||||
|
||||
"session.new.title": "Napravi bilo šta",
|
||||
"session.new.worktree.main": "Glavna grana",
|
||||
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",
|
||||
"session.new.worktree.create": "Kreiraj novi worktree",
|
||||
|
||||
@@ -510,6 +510,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Udvid",
|
||||
|
||||
"session.new.title": "Byg hvad som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||
"session.new.worktree.create": "Opret nyt worktree",
|
||||
|
||||
@@ -467,6 +467,7 @@ export const dict = {
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.new.title": "Baue, was du willst",
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||
|
||||
@@ -511,11 +511,13 @@ export const dict = {
|
||||
"session.review.change.other": "Changes",
|
||||
"session.review.loadingChanges": "Loading changes...",
|
||||
"session.review.empty": "No changes in this session yet",
|
||||
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
||||
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
"session.files.empty": "No files",
|
||||
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
||||
|
||||
"session.messages.renderEarlier": "Render earlier messages",
|
||||
@@ -529,6 +531,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Collapse",
|
||||
"session.todo.expand": "Expand",
|
||||
|
||||
"session.new.title": "Build anything",
|
||||
"session.new.worktree.main": "Main branch",
|
||||
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
|
||||
"session.new.worktree.create": "Create new worktree",
|
||||
|
||||
@@ -516,6 +516,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Contraer",
|
||||
"session.todo.expand": "Expandir",
|
||||
|
||||
"session.new.title": "Construye lo que quieras",
|
||||
"session.new.worktree.main": "Rama principal",
|
||||
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",
|
||||
"session.new.worktree.create": "Crear nuevo árbol de trabajo",
|
||||
|
||||
@@ -463,6 +463,7 @@ export const dict = {
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.new.title": "Créez ce que vous voulez",
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
||||
|
||||
@@ -457,6 +457,7 @@ export const dict = {
|
||||
"session.todo.title": "ToDo",
|
||||
"session.todo.collapse": "折りたたむ",
|
||||
"session.todo.expand": "展開",
|
||||
"session.new.title": "何でも作る",
|
||||
"session.new.worktree.main": "メインブランチ",
|
||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||
"session.new.worktree.create": "新しいワークツリーを作成",
|
||||
|
||||
@@ -459,6 +459,7 @@ export const dict = {
|
||||
"session.todo.title": "할 일",
|
||||
"session.todo.collapse": "접기",
|
||||
"session.todo.expand": "펼치기",
|
||||
"session.new.title": "무엇이든 만들기",
|
||||
"session.new.worktree.main": "메인 브랜치",
|
||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||
"session.new.worktree.create": "새 작업 트리 생성",
|
||||
|
||||
@@ -516,6 +516,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Skjul",
|
||||
"session.todo.expand": "Utvid",
|
||||
|
||||
"session.new.title": "Bygg hva som helst",
|
||||
"session.new.worktree.main": "Hovedgren",
|
||||
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",
|
||||
"session.new.worktree.create": "Opprett nytt worktree",
|
||||
|
||||
@@ -458,6 +458,7 @@ export const dict = {
|
||||
"session.todo.title": "Zadania",
|
||||
"session.todo.collapse": "Zwiń",
|
||||
"session.todo.expand": "Rozwiń",
|
||||
"session.new.title": "Zbuduj cokolwiek",
|
||||
"session.new.worktree.main": "Główna gałąź",
|
||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
||||
|
||||
@@ -514,6 +514,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Свернуть",
|
||||
"session.todo.expand": "Развернуть",
|
||||
|
||||
"session.new.title": "Создавайте что угодно",
|
||||
"session.new.worktree.main": "Основная ветка",
|
||||
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",
|
||||
"session.new.worktree.create": "Создать новый worktree",
|
||||
|
||||
@@ -511,6 +511,7 @@ export const dict = {
|
||||
"session.todo.collapse": "ย่อ",
|
||||
"session.todo.expand": "ขยาย",
|
||||
|
||||
"session.new.title": "สร้างอะไรก็ได้",
|
||||
"session.new.worktree.main": "สาขาหลัก",
|
||||
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
|
||||
"session.new.worktree.create": "สร้าง worktree ใหม่",
|
||||
|
||||
@@ -523,6 +523,7 @@ export const dict = {
|
||||
"session.todo.collapse": "Daralt",
|
||||
"session.todo.expand": "Genişlet",
|
||||
|
||||
"session.new.title": "İstediğini yap",
|
||||
"session.new.worktree.main": "Ana dal",
|
||||
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
|
||||
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
|
||||
|
||||
@@ -510,6 +510,7 @@ export const dict = {
|
||||
"session.todo.title": "待办事项",
|
||||
"session.todo.collapse": "折叠",
|
||||
"session.todo.expand": "展开",
|
||||
"session.new.title": "构建任何东西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||
"session.new.worktree.create": "创建新的 worktree",
|
||||
|
||||
@@ -507,6 +507,7 @@ export const dict = {
|
||||
"session.todo.collapse": "折疊",
|
||||
"session.todo.expand": "展開",
|
||||
|
||||
"session.new.title": "建構任何東西",
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
|
||||
"session.new.worktree.create": "建立新的 worktree",
|
||||
|
||||
@@ -1 +1,29 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
|
||||
@layer components {
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem; /* gap-3 */
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@container getting-started (min-width: 17rem) {
|
||||
[data-component="getting-started-actions"] {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { SyncProvider, useSync } from "@/context/sync"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
directory={props.directory}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
|
||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
||||
>
|
||||
<LocalProvider>{props.children}</LocalProvider>
|
||||
</DataProvider>
|
||||
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
export default function Layout(props: ParentProps) {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ invalid: "" })
|
||||
const directory = createMemo(() => {
|
||||
return decode64(params.dir) ?? ""
|
||||
})
|
||||
const globalSDK = useGlobalSDK()
|
||||
const directory = createMemo(() => decode64(params.dir) ?? "")
|
||||
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.dir) return
|
||||
if (directory()) return
|
||||
if (store.invalid === params.dir) return
|
||||
setStore("invalid", params.dir)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
const raw = directory()
|
||||
if (!raw) {
|
||||
if (state.invalid === params.dir) return
|
||||
setState("invalid", params.dir)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("directory.error.invalidUrl"),
|
||||
})
|
||||
navigate("/", { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const current = params.dir
|
||||
globalSDK
|
||||
.createClient({
|
||||
directory: raw,
|
||||
throwOnError: true,
|
||||
})
|
||||
.path.get()
|
||||
.then((x) => {
|
||||
if (params.dir !== current) return
|
||||
const next = x.data?.directory ?? raw
|
||||
batch(() => {
|
||||
setState("invalid", "")
|
||||
setState("resolved", next)
|
||||
})
|
||||
if (next === raw) return
|
||||
const path = location.pathname.slice(current.length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
.catch(() => {
|
||||
if (params.dir !== current) return
|
||||
batch(() => {
|
||||
setState("invalid", "")
|
||||
setState("resolved", raw)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={directory()}>
|
||||
<SDKProvider directory={directory}>
|
||||
<SyncProvider>
|
||||
<DirectoryDataProvider directory={directory()}>{props.children}</DirectoryDataProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
<Show when={state.resolved}>
|
||||
{(resolved) => (
|
||||
<SDKProvider directory={resolved}>
|
||||
<SyncProvider>
|
||||
<DirectoryDataProvider directory={resolved()}>{props.children}</DirectoryDataProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
@@ -93,6 +94,7 @@ export default function Layout(props: ParentProps) {
|
||||
workspaceName: {} as Record<string, string>,
|
||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||
workspaceExpanded: {} as Record<string, boolean>,
|
||||
gettingStartedDismissed: false,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -154,6 +156,8 @@ export default function Layout(props: ParentProps) {
|
||||
const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
|
||||
const navLeave = { current: undefined as number | undefined }
|
||||
const [sortNow, setSortNow] = createSignal(Date.now())
|
||||
const [sizing, setSizing] = createSignal(false)
|
||||
let sizet: number | undefined
|
||||
let sortNowInterval: ReturnType<typeof setInterval> | undefined
|
||||
const sortNowTimeout = setTimeout(
|
||||
() => {
|
||||
@@ -166,7 +170,7 @@ export default function Layout(props: ParentProps) {
|
||||
const aim = createAim({
|
||||
enabled: () => !layout.sidebar.opened(),
|
||||
active: () => state.hoverProject,
|
||||
el: () => state.nav,
|
||||
el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
|
||||
onActivate: (directory) => {
|
||||
globalSync.child(directory)
|
||||
setState("hoverProject", directory)
|
||||
@@ -178,9 +182,23 @@ export default function Layout(props: ParentProps) {
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
clearTimeout(sortNowTimeout)
|
||||
if (sortNowInterval) clearInterval(sortNowInterval)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
if (peekt !== undefined) clearTimeout(peekt)
|
||||
aim.reset()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const stop = () => setSizing(false)
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
})
|
||||
})
|
||||
|
||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
|
||||
const setHoverProject = (value: string | undefined) => {
|
||||
@@ -191,12 +209,54 @@ export default function Layout(props: ParentProps) {
|
||||
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
||||
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
||||
|
||||
const disarm = () => {
|
||||
if (navLeave.current === undefined) return
|
||||
clearTimeout(navLeave.current)
|
||||
navLeave.current = undefined
|
||||
}
|
||||
|
||||
const arm = () => {
|
||||
if (layout.sidebar.opened()) return
|
||||
if (state.hoverProject === undefined) return
|
||||
disarm()
|
||||
navLeave.current = window.setTimeout(() => {
|
||||
navLeave.current = undefined
|
||||
setHoverProject(undefined)
|
||||
setState("hoverSession", undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const [peek, setPeek] = createSignal<LocalProject | undefined>(undefined)
|
||||
const [peeked, setPeeked] = createSignal(false)
|
||||
let peekt: number | undefined
|
||||
|
||||
const hoverProjectData = createMemo(() => {
|
||||
const id = state.hoverProject
|
||||
if (!id) return
|
||||
return layout.projects.list().find((project) => project.worktree === id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const p = hoverProjectData()
|
||||
if (p) {
|
||||
if (peekt !== undefined) {
|
||||
clearTimeout(peekt)
|
||||
peekt = undefined
|
||||
}
|
||||
setPeek(p)
|
||||
setPeeked(true)
|
||||
return
|
||||
}
|
||||
|
||||
setPeeked(false)
|
||||
if (peek() === undefined) return
|
||||
if (peekt !== undefined) clearTimeout(peekt)
|
||||
peekt = window.setTimeout(() => {
|
||||
peekt = undefined
|
||||
setPeek(undefined)
|
||||
}, 180)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.sidebar.opened()) return
|
||||
setHoverProject(undefined)
|
||||
@@ -364,6 +424,17 @@ export default function Layout(props: ParentProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
e.details?.type === "question.replied" ||
|
||||
e.details?.type === "question.rejected" ||
|
||||
e.details?.type === "permission.replied"
|
||||
) {
|
||||
const props = e.details.properties as { sessionID: string }
|
||||
const sessionKey = `${e.name}:${props.sessionID}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
const title =
|
||||
e.details.type === "permission.asked"
|
||||
@@ -598,25 +669,24 @@ export default function Layout(props: ParentProps) {
|
||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||
|
||||
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
||||
const prefetchedByDir = new Map<string, Map<string, true>>()
|
||||
const prefetchedByDir = new Map<string, Set<string>>()
|
||||
|
||||
const lruFor = (directory: string) => {
|
||||
const existing = prefetchedByDir.get(directory)
|
||||
if (existing) return existing
|
||||
const created = new Map<string, true>()
|
||||
const created = new Set<string>()
|
||||
prefetchedByDir.set(directory, created)
|
||||
return created
|
||||
}
|
||||
|
||||
const markPrefetched = (directory: string, sessionID: string) => {
|
||||
const lru = lruFor(directory)
|
||||
if (lru.has(sessionID)) lru.delete(sessionID)
|
||||
lru.set(sessionID, true)
|
||||
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
|
||||
const oldest = lru.keys().next().value as string | undefined
|
||||
if (!oldest) return
|
||||
lru.delete(oldest)
|
||||
}
|
||||
return pickSessionCacheEvictions({
|
||||
seen: lru,
|
||||
keep: sessionID,
|
||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -665,6 +735,7 @@ export default function Layout(props: ParentProps) {
|
||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!lruFor(directory).has(sessionID)) return
|
||||
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
@@ -728,7 +799,18 @@ export default function Layout(props: ParentProps) {
|
||||
const lru = lruFor(directory)
|
||||
const known = lru.has(session.id)
|
||||
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
||||
markPrefetched(directory, session.id)
|
||||
const stale = markPrefetched(directory, session.id)
|
||||
if (stale.length > 0) {
|
||||
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
for (const id of stale) {
|
||||
globalSync.todo.set(id, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (priority === "high") q.pending.unshift(session.id)
|
||||
if (priority !== "high") q.pending.push(session.id)
|
||||
@@ -1122,6 +1204,12 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const [data] = globalSync.child(target.directory, { bootstrap: false })
|
||||
if (data.session.some((item) => item.id === target.id)) {
|
||||
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
|
||||
return true
|
||||
}
|
||||
const resolved = await globalSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
@@ -1812,7 +1900,9 @@ export default function Layout(props: ParentProps) {
|
||||
setHoverSession,
|
||||
}
|
||||
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||
const projectName = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return ""
|
||||
@@ -1838,10 +1928,17 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
|
||||
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
|
||||
"border border-b-0 border-border-weak-base": !merged(),
|
||||
"border-l border-t border-border-weaker-base": merged(),
|
||||
"bg-background-base": merged() || hover(),
|
||||
"bg-background-stronger": !merged() && !hover(),
|
||||
"flex-1 min-w-0": panelProps.mobile,
|
||||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
style={{
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
}}
|
||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||
>
|
||||
<Show when={panelProps.project}>
|
||||
{(p) => (
|
||||
@@ -1887,7 +1984,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
@@ -2006,25 +2103,31 @@ export default function Layout(props: ParentProps) {
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
||||
class="shrink-0 px-3 py-3"
|
||||
classList={{
|
||||
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||
}}
|
||||
>
|
||||
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
||||
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||
<div class="p-3 flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("sidebar.gettingStarted.line1")}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("sidebar.gettingStarted.line2")}
|
||||
</div>
|
||||
</div>
|
||||
<div data-component="getting-started-actions">
|
||||
<Button size="large" icon="plus-small" onClick={connectProvider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
|
||||
Not yet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
|
||||
size="large"
|
||||
icon="plus"
|
||||
onClick={connectProvider}
|
||||
>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2034,33 +2137,27 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
<Titlebar />
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<div class="flex-1 min-h-0 relative overflow-x-hidden">
|
||||
<nav
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-desktop"
|
||||
classList={{
|
||||
"hidden xl:block": true,
|
||||
"relative shrink-0": true,
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (navLeave.current === undefined) return
|
||||
clearTimeout(navLeave.current)
|
||||
navLeave.current = undefined
|
||||
disarm()
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
aim.reset()
|
||||
if (!sidebarHovering()) return
|
||||
|
||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||
navLeave.current = window.setTimeout(() => {
|
||||
navLeave.current = undefined
|
||||
setHoverProject(undefined)
|
||||
setState("hoverSession", undefined)
|
||||
}, 300)
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<div class="@container w-full h-full contain-strict">
|
||||
@@ -2087,28 +2184,36 @@ export default function Layout(props: ParentProps) {
|
||||
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} />}
|
||||
{(project) => <SidebarPanel project={project} merged />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||
<SidebarPanel project={hoverProjectData()} />
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<div onPointerDown={() => setSizing(true)}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={(w) => {
|
||||
setSizing(true)
|
||||
if (sizet !== undefined) clearTimeout(sizet)
|
||||
sizet = window.setTimeout(() => setSizing(false), 120)
|
||||
layout.sidebar.resize(w)
|
||||
}}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.sidebar.width()}
|
||||
min={244}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||
collapseThreshold={244}
|
||||
onResize={layout.sidebar.resize}
|
||||
onCollapse={layout.sidebar.close}
|
||||
/>
|
||||
</Show>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||
style={{ left: "calc(4rem + 12px)" }}
|
||||
/>
|
||||
|
||||
<div class="xl:hidden">
|
||||
<div
|
||||
classList={{
|
||||
@@ -2124,7 +2229,7 @@ export default function Layout(props: ParentProps) {
|
||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||
data-component="sidebar-nav-mobile"
|
||||
classList={{
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||
"translate-x-0": layout.mobileSidebar.opened(),
|
||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||
}}
|
||||
@@ -2157,16 +2262,66 @@ export default function Layout(props: ParentProps) {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main
|
||||
<div
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
||||
"xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
|
||||
"absolute inset-0": true,
|
||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||
"z-20": true,
|
||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||
!sizing(),
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
<main
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
>
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||
"opacity-100 translate-x-0 pointer-events-auto": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2 pointer-events-none": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
onMouseMove={disarm}
|
||||
onMouseEnter={() => {
|
||||
disarm()
|
||||
aim.reset()
|
||||
}}
|
||||
onPointerDown={disarm}
|
||||
onMouseLeave={() => {
|
||||
arm()
|
||||
}}
|
||||
>
|
||||
<Show when={peek()} keyed>
|
||||
{(project) => <SidebarPanel project={project} merged={false} />}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
classList={{
|
||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||
"opacity-100 translate-x-0": peeked() && !layout.sidebar.opened(),
|
||||
"opacity-0 -translate-x-2": !peeked() || layout.sidebar.opened(),
|
||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||
"duration-180 ease-out": peeked() && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !peeked() || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
</div>
|
||||
<Toast.Region />
|
||||
</div>
|
||||
|
||||
@@ -163,7 +163,6 @@ const SessionHoverPreview = (props: {
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={props.trigger}
|
||||
mount={!props.mobile ? props.nav() : undefined}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
|
||||
>
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
@@ -93,6 +91,7 @@ const ProjectTile = (props: {
|
||||
modal={!props.sidebarHovering()}
|
||||
onOpenChange={(value) => {
|
||||
props.setMenu(value)
|
||||
props.setSuppressHover(value)
|
||||
if (value) props.setOpen(false)
|
||||
}}
|
||||
>
|
||||
@@ -109,6 +108,12 @@ const ProjectTile = (props: {
|
||||
!props.selected() && !props.active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
@@ -137,7 +142,7 @@ const ProjectTile = (props: {
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content>
|
||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||
@@ -194,21 +199,6 @@ const ProjectPreviewPanel = (props: {
|
||||
<div class="-m-3 p-2 flex flex-col w-72">
|
||||
<div class="px-4 pt-2 pb-1 flex items-center gap-2">
|
||||
<div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
|
||||
<Tooltip value={props.language.t("common.close")} placement="top" gutter={6}>
|
||||
<IconButton
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
data-action="project-close-hover"
|
||||
data-project={base64Encode(props.project.worktree)}
|
||||
aria-label={props.language.t("common.close")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.setOpen(false)
|
||||
props.ctx.closeProject(props.project.worktree)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="px-4 pb-2 text-12-medium text-text-weak">{props.language.t("sidebar.project.recentSessions")}</div>
|
||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
@@ -35,10 +35,22 @@ export const SidebarContent = (props: {
|
||||
}): JSX.Element => {
|
||||
const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
|
||||
const placement = () => (props.mobile ? "bottom" : "right")
|
||||
let panel: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = panel
|
||||
if (!el) return
|
||||
if (expanded()) {
|
||||
el.removeAttribute("inert")
|
||||
return
|
||||
}
|
||||
el.setAttribute("inert", "")
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full overflow-hidden">
|
||||
<div class="flex h-full w-full min-w-0 overflow-hidden">
|
||||
<div
|
||||
data-component="sidebar-rail"
|
||||
class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
|
||||
onMouseMove={props.aimMove}
|
||||
>
|
||||
@@ -100,7 +112,15 @@ export const SidebarContent = (props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={expanded()}>{props.renderPanel()}</Show>
|
||||
<div
|
||||
ref={(el) => {
|
||||
panel = el
|
||||
}}
|
||||
classList={{ "flex h-full min-h-0 min-w-0 overflow-hidden": true, "pointer-events-none": !expanded() }}
|
||||
aria-hidden={!expanded()}
|
||||
>
|
||||
{props.renderPanel()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ const WorkspaceActions = (props: {
|
||||
aria-label={props.language.t("common.moreOptions")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!props.pendingRename()) return
|
||||
@@ -249,7 +249,7 @@ const WorkspaceSessionList = (props: {
|
||||
loadMore: () => Promise<void>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => (
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<nav class="flex flex-col gap-1 px-3">
|
||||
<Show when={props.showNew()}>
|
||||
<NewSessionItem
|
||||
slug={props.slug()}
|
||||
@@ -490,7 +490,7 @@ export const LocalWorkspace = (props: {
|
||||
ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
|
||||
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
|
||||
>
|
||||
<nav class="flex flex-col gap-1 px-2">
|
||||
<nav class="flex flex-col gap-1 px-3">
|
||||
<Show when={loading()}>
|
||||
<SessionSkeleton />
|
||||
</Show>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import {
|
||||
onCleanup,
|
||||
@@ -20,27 +20,31 @@ import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
@@ -252,6 +256,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const globalSync = useGlobalSync()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
@@ -278,7 +283,9 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
git: false,
|
||||
pendingMessage: undefined as string | undefined,
|
||||
reviewSnap: false,
|
||||
scrollGesture: 0,
|
||||
scroll: {
|
||||
overflow: false,
|
||||
@@ -331,6 +338,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
@@ -416,15 +424,24 @@ export default function Page() {
|
||||
() => {
|
||||
const msg = lastUserMessage()
|
||||
if (!msg) return
|
||||
if (msg.agent) {
|
||||
local.agent.set(msg.agent)
|
||||
if (local.agent.current()?.model) return
|
||||
}
|
||||
if (msg.model) local.model.set(msg.model)
|
||||
syncSessionModel(local, msg)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (prev.id) sync.session.evict(prev.id, prev.dir)
|
||||
if (!next.id) resetSessionModel(local)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
@@ -444,6 +461,21 @@ export default function Page() {
|
||||
return key
|
||||
}, sessionKey())
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
if (prev === undefined || prev === open) return open
|
||||
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
setUi("reviewSnap", true)
|
||||
reviewFrame = requestAnimationFrame(() => {
|
||||
reviewFrame = undefined
|
||||
setUi("reviewSnap", false)
|
||||
})
|
||||
return open
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
@@ -490,10 +522,51 @@ export default function Page() {
|
||||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (!project || project.vcs) return "session.review.empty"
|
||||
return "session.review.noVcs"
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.empty"
|
||||
})
|
||||
|
||||
function upsert(next: Project) {
|
||||
const list = globalSync.data.project
|
||||
sync.set("project", next.id)
|
||||
const idx = list.findIndex((item) => item.id === next.id)
|
||||
if (idx >= 0) {
|
||||
globalSync.set(
|
||||
"project",
|
||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||
)
|
||||
return
|
||||
}
|
||||
const at = list.findIndex((item) => item.id > next.id)
|
||||
if (at >= 0) {
|
||||
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
return
|
||||
}
|
||||
globalSync.set("project", [...list, next])
|
||||
}
|
||||
|
||||
function initGit() {
|
||||
if (ui.git) return
|
||||
setUi("git", true)
|
||||
void sdk.client.project
|
||||
.initGit()
|
||||
.then((x) => {
|
||||
if (!x.data) return
|
||||
upsert(x.data)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setUi("git", false)
|
||||
})
|
||||
}
|
||||
|
||||
let inputRef!: HTMLDivElement
|
||||
let promptDock: HTMLDivElement | undefined
|
||||
let dockHeight = 0
|
||||
@@ -727,23 +800,28 @@ export default function Page() {
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
valueClass="text-14-medium"
|
||||
/>
|
||||
)
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
valueClass="text-14-medium"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyTurn = () => (
|
||||
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -809,9 +887,23 @@ export default function Page() {
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : reviewEmptyKey() === "session.review.noVcs" ? (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||
<div
|
||||
class="text-14-regular text-text-base max-w-md"
|
||||
style={{ "line-height": "var(--line-height-normal)" }}
|
||||
>
|
||||
Track, review, and undo changes in this project
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
@@ -844,7 +936,7 @@ export default function Page() {
|
||||
diffStyle: layout.review.diffStyle(),
|
||||
onDiffStyleChange: layout.review.setDiffStyle,
|
||||
loadingClass: "px-6 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -968,23 +1060,6 @@ export default function Page() {
|
||||
tabs().setActive(next)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => layout.fileTree.opened(),
|
||||
(opened, prev) => {
|
||||
if (prev === undefined) return
|
||||
if (!isDesktop()) return
|
||||
|
||||
if (opened) {
|
||||
const active = tabs().active()
|
||||
const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
|
||||
layout.fileTree.setTab(tab)
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
@@ -1174,6 +1249,7 @@ export default function Page() {
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
scrollSpy.destroy()
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
})
|
||||
|
||||
@@ -1193,9 +1269,9 @@ export default function Page() {
|
||||
{/* Session panel */}
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||
"flex-1": true,
|
||||
"md:flex-none": desktopSidePanelOpen(),
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!size.active() && !ui.reviewSnap,
|
||||
}}
|
||||
style={{
|
||||
width: sessionPanelWidth(),
|
||||
@@ -1215,7 +1291,7 @@ export default function Page() {
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
@@ -1291,17 +1367,28 @@ export default function Page() {
|
||||
/>
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.session.width()}
|
||||
min={450}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
|
||||
onResize={layout.session.resize}
|
||||
/>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={layout.session.width()}
|
||||
min={450}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
|
||||
onResize={(width) => {
|
||||
size.touch()
|
||||
layout.session.resize(width)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
|
||||
<SessionSidePanel
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TerminalPanel />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -25,6 +26,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
@@ -35,6 +37,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const picked = createMemo(() => store.answers[store.tab]?.length ?? 0)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -43,6 +46,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
|
||||
const fold = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
const next = value.trim()
|
||||
@@ -257,9 +262,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
header={
|
||||
<>
|
||||
<div
|
||||
data-action="session-question-toggle"
|
||||
class="flex flex-1 min-w-0 items-center gap-2 cursor-default select-none"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{ margin: "0 -10px", padding: "0 0 0 10px" }}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-progress">
|
||||
<div data-slot="question-progress" class="ml-auto mr-1">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
@@ -271,13 +288,38 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
||||
}
|
||||
disabled={store.sending}
|
||||
onClick={() => jump(i())}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
jump(i())
|
||||
}}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<IconButton
|
||||
data-action="session-question-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
fold()
|
||||
}}
|
||||
aria-label={store.collapsed ? language.t("session.todo.expand") : language.t("session.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
@@ -297,56 +339,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text">{question()?.question}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
class="cursor-default"
|
||||
classList={{
|
||||
"mb-6": store.collapsed && picked() === 0,
|
||||
}}
|
||||
role={store.collapsed ? "button" : undefined}
|
||||
tabIndex={store.collapsed ? 0 : undefined}
|
||||
onClick={fold}
|
||||
onKeyDown={(event) => {
|
||||
if (!store.collapsed) return
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
fold()
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={store.collapsed && picked() > 0}>
|
||||
<div data-slot="question-hint" class="cursor-default mb-6">
|
||||
{picked()} answer{picked() === 1 ? "" : "s"} selected
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<div data-slot="question-answers" hidden={store.collapsed} aria-hidden={store.collapsed}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={picked()}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={() => selectOption(i())}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={store.sending}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
@@ -365,80 +472,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (store.sending) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
customToggle()
|
||||
}}
|
||||
>
|
||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
<textarea
|
||||
ref={(el) =>
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
el.style.height = "0px"
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}, 0)
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
data-slot="question-custom-input"
|
||||
placeholder={language.t("ui.question.custom.placeholder")}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={store.sending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
e.currentTarget.style.height = "0px"
|
||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { batch } from "solid-js"
|
||||
import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export const focusTerminalById = (id: string) => {
|
||||
const wrapper = document.getElementById(`terminal-wrapper-${id}`)
|
||||
@@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
|
||||
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
|
||||
return toIndex
|
||||
}
|
||||
|
||||
export const createSizing = () => {
|
||||
const [state, setState] = createStore({ active: false })
|
||||
let t: number | undefined
|
||||
|
||||
const stop = () => {
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t)
|
||||
t = undefined
|
||||
}
|
||||
setState("active", false)
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t)
|
||||
t = undefined
|
||||
}
|
||||
setState("active", true)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("pointerup", stop)
|
||||
window.addEventListener("pointercancel", stop)
|
||||
window.addEventListener("blur", stop)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pointerup", stop)
|
||||
window.removeEventListener("pointercancel", stop)
|
||||
window.removeEventListener("blur", stop)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (t !== undefined) clearTimeout(t)
|
||||
})
|
||||
|
||||
return {
|
||||
active: () => state.active,
|
||||
start,
|
||||
touch() {
|
||||
start()
|
||||
t = window.setTimeout(stop, 120)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type Sizing = ReturnType<typeof createSizing>
|
||||
|
||||
export const createPresence = (open: Accessor<boolean>, wait = 200) => {
|
||||
const [state, setState] = createStore({
|
||||
show: open(),
|
||||
open: open(),
|
||||
})
|
||||
let frame: number | undefined
|
||||
let t: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
if (t !== undefined) {
|
||||
clearTimeout(t)
|
||||
t = undefined
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(open, (next) => {
|
||||
clear()
|
||||
|
||||
if (next) {
|
||||
if (state.show) {
|
||||
setState("open", true)
|
||||
return
|
||||
}
|
||||
|
||||
setState({ show: true, open: false })
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
setState("open", true)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.show) return
|
||||
setState("open", false)
|
||||
t = window.setTimeout(() => {
|
||||
t = undefined
|
||||
setState("show", false)
|
||||
}, wait)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
return {
|
||||
show: () => state.show,
|
||||
open: () => state.open,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
@@ -235,6 +236,40 @@ export function MessageTimeline(props: {
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
open: false,
|
||||
show: false,
|
||||
fade: false,
|
||||
})
|
||||
|
||||
let f: number | undefined
|
||||
const clear = () => {
|
||||
if (f !== undefined) window.clearTimeout(f)
|
||||
f = undefined
|
||||
}
|
||||
|
||||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
setSlot({ open: true, show: true, fade: false })
|
||||
return
|
||||
}
|
||||
if (prev) {
|
||||
setSlot({ open: false, show: true, fade: true })
|
||||
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
|
||||
return
|
||||
}
|
||||
setSlot({ open: false, show: false, fade: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
@@ -573,43 +608,64 @@ export function MessageTimeline(props: {
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
<Show when={slot.show}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
|
||||
158
packages/app/src/pages/session/session-model-helpers.test.ts
Normal file
158
packages/app/src/pages/session/session-model-helpers.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
|
||||
|
||||
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
|
||||
({
|
||||
id: "msg",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: input?.agent ?? "build",
|
||||
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
variant: input?.variant,
|
||||
}) as UserMessage
|
||||
|
||||
describe("syncSessionModel", () => {
|
||||
test("restores the last message model and variant", () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
syncSessionModel(
|
||||
{
|
||||
agent: {
|
||||
current() {
|
||||
return undefined
|
||||
},
|
||||
set(value) {
|
||||
calls.push(["agent", value])
|
||||
},
|
||||
},
|
||||
model: {
|
||||
set(value) {
|
||||
calls.push(["model", value])
|
||||
},
|
||||
current() {
|
||||
return { id: "claude-sonnet-4", provider: { id: "anthropic" } }
|
||||
},
|
||||
variant: {
|
||||
set(value) {
|
||||
calls.push(["variant", value])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
message({ variant: "high" }),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
["agent", "build"],
|
||||
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||
["variant", "high"],
|
||||
])
|
||||
})
|
||||
|
||||
test("skips variant when the model falls back", () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
syncSessionModel(
|
||||
{
|
||||
agent: {
|
||||
current() {
|
||||
return undefined
|
||||
},
|
||||
set(value) {
|
||||
calls.push(["agent", value])
|
||||
},
|
||||
},
|
||||
model: {
|
||||
set(value) {
|
||||
calls.push(["model", value])
|
||||
},
|
||||
current() {
|
||||
return { id: "gpt-5", provider: { id: "openai" } }
|
||||
},
|
||||
variant: {
|
||||
set(value) {
|
||||
calls.push(["variant", value])
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
message({ variant: "high" }),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
["agent", "build"],
|
||||
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetSessionModel", () => {
|
||||
test("restores the current agent defaults", () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
resetSessionModel({
|
||||
agent: {
|
||||
current() {
|
||||
return {
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
variant: "high",
|
||||
}
|
||||
},
|
||||
set() {},
|
||||
},
|
||||
model: {
|
||||
set(value) {
|
||||
calls.push(["model", value])
|
||||
},
|
||||
current() {
|
||||
return undefined
|
||||
},
|
||||
variant: {
|
||||
set(value) {
|
||||
calls.push(["variant", value])
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||
["variant", "high"],
|
||||
])
|
||||
})
|
||||
|
||||
test("clears the variant when the agent has none", () => {
|
||||
const calls: unknown[] = []
|
||||
|
||||
resetSessionModel({
|
||||
agent: {
|
||||
current() {
|
||||
return {
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||
}
|
||||
},
|
||||
set() {},
|
||||
},
|
||||
model: {
|
||||
set(value) {
|
||||
calls.push(["model", value])
|
||||
},
|
||||
current() {
|
||||
return undefined
|
||||
},
|
||||
variant: {
|
||||
set(value) {
|
||||
calls.push(["variant", value])
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
["model", { providerID: "anthropic", modelID: "claude-sonnet-4" }],
|
||||
["variant", undefined],
|
||||
])
|
||||
})
|
||||
})
|
||||
48
packages/app/src/pages/session/session-model-helpers.ts
Normal file
48
packages/app/src/pages/session/session-model-helpers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { batch } from "solid-js"
|
||||
|
||||
type Local = {
|
||||
agent: {
|
||||
current():
|
||||
| {
|
||||
model?: UserMessage["model"]
|
||||
variant?: string
|
||||
}
|
||||
| undefined
|
||||
set(name: string | undefined): void
|
||||
}
|
||||
model: {
|
||||
set(model: UserMessage["model"] | undefined): void
|
||||
current():
|
||||
| {
|
||||
id: string
|
||||
provider: { id: string }
|
||||
}
|
||||
| undefined
|
||||
variant: {
|
||||
set(value: string | undefined): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const resetSessionModel = (local: Local) => {
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return
|
||||
batch(() => {
|
||||
local.model.set(agent.model)
|
||||
local.model.variant.set(agent.variant)
|
||||
})
|
||||
}
|
||||
|
||||
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
||||
batch(() => {
|
||||
local.agent.set(msg.agent)
|
||||
local.model.set(msg.model)
|
||||
})
|
||||
|
||||
const model = local.model.current()
|
||||
if (!model) return
|
||||
if (model.provider.id !== msg.model.providerID) return
|
||||
if (model.id !== msg.model.modelID) return
|
||||
local.model.variant.set(msg.variant)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
|
||||
import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
import { StickyAddButton } from "@/pages/session/review-tab"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
@@ -31,6 +31,8 @@ export function SessionSidePanel(props: {
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewSnap: boolean
|
||||
size: Sizing
|
||||
}) {
|
||||
const params = useParams()
|
||||
const layout = useLayout()
|
||||
@@ -46,8 +48,15 @@ export function SessionSidePanel(props: {
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
|
||||
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
|
||||
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!open()) return "0px"
|
||||
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
|
||||
return `${layout.fileTree.width()}px`
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
@@ -60,6 +69,12 @@ export function SessionSidePanel(props: {
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
@@ -87,6 +102,21 @@ export function SessionSidePanel(props: {
|
||||
return out
|
||||
})
|
||||
|
||||
const empty = (msg: string) => (
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="h-6 shrink-0" aria-hidden />
|
||||
<div class="flex-1 pb-64 flex items-center justify-center text-center">
|
||||
<div class="text-12-regular text-text-weak">{msg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const nofiles = createMemo(() => {
|
||||
const state = file.tree.state("")
|
||||
if (!state?.loaded) return false
|
||||
return file.tree.children("").length === 0
|
||||
})
|
||||
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
@@ -145,17 +175,8 @@ export function SessionSidePanel(props: {
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
activeDraggable: undefined as string | undefined,
|
||||
fileTreeScrolled: false,
|
||||
})
|
||||
|
||||
let changesEl: HTMLDivElement | undefined
|
||||
let allEl: HTMLDivElement | undefined
|
||||
|
||||
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
|
||||
const next = (el?.scrollTop ?? 0) > 0
|
||||
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
@@ -176,11 +197,6 @@ export function SessionSidePanel(props: {
|
||||
setStore("activeDraggable", undefined)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.fileTree.opened()) return
|
||||
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
@@ -203,149 +219,172 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={open()}>
|
||||
<Show when={isDesktop()}>
|
||||
<aside
|
||||
id="review-panel"
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
class="relative min-w-0 h-full border-l border-border-weak-base flex"
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"flex-1": reviewOpen(),
|
||||
"shrink-0": !reviewOpen(),
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
}}
|
||||
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={reviewOpen()}>
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||
</SortableProvider>
|
||||
<StickyAddButton>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</StickyAddButton>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<div class="size-full flex border-l border-border-weaker-base">
|
||||
<div
|
||||
aria-hidden={!reviewOpen()}
|
||||
inert={!reviewOpen()}
|
||||
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"pointer-events-none": !reviewOpen(),
|
||||
}}
|
||||
>
|
||||
<div class="size-full min-w-0 h-full bg-background-base">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs value={activeTab()} onChange={openTab}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List
|
||||
ref={(el: HTMLDivElement) => {
|
||||
const stop = createFileTabListSync({ el, contextOpen })
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage variant="indicator" />
|
||||
<div>{language.t("session.tab.context")}</div>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<SortableProvider ids={openedTabs()}>
|
||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
||||
</SortableProvider>
|
||||
<StickyAddButton>
|
||||
<TooltipKeybind
|
||||
title={language.t("command.file.open")}
|
||||
keybind={command.keybind("file.open")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() =>
|
||||
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</StickyAddButton>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={reviewTab()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "context"}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "empty"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<Show when={contextOpen()}>
|
||||
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "context"}>
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<SessionContextTab />
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Show when={activeFileTab()} keyed>
|
||||
{(tab) => <FileTabContent tab={tab} />}
|
||||
</Show>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(tab) => {
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={layout.fileTree.opened()}>
|
||||
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
|
||||
<div
|
||||
id="file-tree-panel"
|
||||
aria-hidden={!fileOpen()}
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
>
|
||||
<div
|
||||
class="h-full flex flex-col overflow-hidden group/filetree"
|
||||
classList={{ "border-l border-border-weak-base": reviewOpen() }}
|
||||
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
@@ -354,7 +393,7 @@ export function SessionSidePanel(props: {
|
||||
class="h-full"
|
||||
data-scope="filetree"
|
||||
>
|
||||
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
@@ -363,12 +402,7 @@ export function SessionSidePanel(props: {
|
||||
{language.t("session.files.all")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content
|
||||
value="changes"
|
||||
ref={(el: HTMLDivElement) => (changesEl = el)}
|
||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
||||
class="bg-background-stronger px-3 py-0"
|
||||
>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
@@ -382,6 +416,7 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
@@ -391,39 +426,47 @@ export function SessionSidePanel(props: {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
||||
{language.t("session.review.noChanges")}
|
||||
</div>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
value="all"
|
||||
ref={(el: HTMLDivElement) => (allEl = el)}
|
||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
||||
class="bg-background-stronger px-3 py-0"
|
||||
>
|
||||
<FileTree
|
||||
path=""
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||
<Match when={true}>
|
||||
<FileTree
|
||||
path=""
|
||||
class="pt-3"
|
||||
modified={diffFiles()}
|
||||
kinds={kinds()}
|
||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={layout.fileTree.resize}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
<Show when={fileOpen()}>
|
||||
<div onPointerDown={() => props.size.start()}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
edge="start"
|
||||
size={layout.fileTree.width()}
|
||||
min={200}
|
||||
max={480}
|
||||
collapseThreshold={160}
|
||||
onResize={(width) => {
|
||||
props.size.touch()
|
||||
layout.fileTree.resize(width)
|
||||
}}
|
||||
onCollapse={layout.fileTree.close}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
|
||||
export function TerminalPanel() {
|
||||
@@ -33,8 +33,11 @@ export function TerminalPanel() {
|
||||
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const open = createMemo(() => isDesktop() && opened())
|
||||
const panel = createPresence(open)
|
||||
const size = createSizing()
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
@@ -67,7 +70,7 @@ export function TerminalPanel() {
|
||||
on(
|
||||
() => terminal.active(),
|
||||
(activeId) => {
|
||||
if (!activeId || !open()) return
|
||||
if (!activeId || !panel.open()) return
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
@@ -76,6 +79,14 @@ export function TerminalPanel() {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (panel.open()) return
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const dir = params.dir
|
||||
if (!dir) return
|
||||
@@ -133,120 +144,143 @@ export function TerminalPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={open()}>
|
||||
<Show when={panel.show()}>
|
||||
<div
|
||||
ref={root}
|
||||
id="terminal-panel"
|
||||
role="region"
|
||||
aria-label={language.t("terminal.title")}
|
||||
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
|
||||
style={{ height: `${height()}px` }}
|
||||
aria-hidden={!panel.open()}
|
||||
inert={!panel.open()}
|
||||
class="relative w-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"opacity-100": panel.open(),
|
||||
"opacity-0 pointer-events-none": !panel.open(),
|
||||
"transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!size.active(),
|
||||
}}
|
||||
style={{ height: panel.open() ? `${height()}px` : "0px" }}
|
||||
>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={layout.terminal.resize}
|
||||
onCollapse={close}
|
||||
/>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
<div class="size-full flex flex-col border-t border-border-weak-base">
|
||||
<div onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
direction="vertical"
|
||||
size={height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
/>
|
||||
</div>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">
|
||||
{language.t("terminal.loading")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="alt"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||
<DragDropProvider
|
||||
onDragStart={handleTerminalDragStart}
|
||||
onDragEnd={handleTerminalDragEnd}
|
||||
onDragOver={handleTerminalDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant="alt"
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class="!h-auto !flex-none"
|
||||
>
|
||||
<Tabs.List class="h-10 border-b border-border-weaker-base">
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
onConnect={() => terminal.trim(id)}
|
||||
onCleanup={terminal.update}
|
||||
onConnectError={() => terminal.clone(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
46
packages/app/src/theme-preload.test.ts
Normal file
46
packages/app/src/theme-preload.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
|
||||
|
||||
const run = () => Function(src)()
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = ""
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.removeAttribute("data-color-scheme")
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: () =>
|
||||
({
|
||||
matches: false,
|
||||
}) as MediaQueryList,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe("theme preload", () => {
|
||||
test("migrates legacy oc-1 to oc-2 before mount", () => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("oc-2")
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
||||
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
|
||||
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
|
||||
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
|
||||
expect(document.getElementById("oc-theme-preload")).toBeNull()
|
||||
})
|
||||
|
||||
test("keeps cached css for non-default themes", () => {
|
||||
localStorage.setItem("opencode-theme-id", "nightowl")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("nightowl")
|
||||
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
|
||||
})
|
||||
})
|
||||
@@ -104,4 +104,12 @@ describe("persist localStorage resilience", () => {
|
||||
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test("workspace storage sanitizes Windows filename characters", () => {
|
||||
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
|
||||
|
||||
expect(result).toStartWith("opencode.workspace.")
|
||||
expect(result.endsWith(".dat")).toBeTrue()
|
||||
expect(/[:\\/]/.test(result)).toBeFalse()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,7 +204,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
|
||||
}
|
||||
|
||||
function workspaceStorage(dir: string) {
|
||||
const head = dir.slice(0, 12) || "workspace"
|
||||
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(dir) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat`
|
||||
}
|
||||
@@ -300,6 +300,7 @@ export const PersistTesting = {
|
||||
localStorageDirect,
|
||||
localStorageWithPrefix,
|
||||
normalize,
|
||||
workspaceStorage,
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.23",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,8 +9,8 @@ export const config = {
|
||||
github: {
|
||||
repoUrl: "https://github.com/anomalyco/opencode",
|
||||
starsFormatted: {
|
||||
compact: "100K",
|
||||
full: "100,000",
|
||||
compact: "120K",
|
||||
full: "120,000",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,8 +22,8 @@ export const config = {
|
||||
|
||||
// Static stats (used on landing page)
|
||||
stats: {
|
||||
contributors: "700",
|
||||
commits: "9,000",
|
||||
monthlyUsers: "2.5M",
|
||||
contributors: "800",
|
||||
commits: "10,000",
|
||||
monthlyUsers: "5M",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -480,7 +480,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(محذوف)",
|
||||
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
||||
"workspace.cost.subscriptionShort": "اشتراك",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "مفاتيح API",
|
||||
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
||||
|
||||
@@ -488,7 +488,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(excluído)",
|
||||
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
|
||||
"workspace.cost.subscriptionShort": "ass",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chaves de API",
|
||||
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
|
||||
|
||||
@@ -484,7 +484,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøgler",
|
||||
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
|
||||
|
||||
@@ -487,7 +487,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(gelöscht)",
|
||||
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
|
||||
"workspace.cost.subscriptionShort": "Abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
|
||||
|
||||
@@ -480,7 +480,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(deleted)",
|
||||
"workspace.cost.empty": "No usage data available for the selected period.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
|
||||
|
||||
@@ -489,7 +489,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminado)",
|
||||
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Claves API",
|
||||
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
|
||||
|
||||
@@ -490,7 +490,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(supprimé)",
|
||||
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
|
||||
"workspace.cost.subscriptionShort": "abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Clés API",
|
||||
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
|
||||
|
||||
@@ -487,7 +487,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminato)",
|
||||
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chiavi API",
|
||||
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
||||
|
||||
@@ -486,7 +486,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(削除済み)",
|
||||
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
|
||||
"workspace.cost.subscriptionShort": "サブ",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "APIキー",
|
||||
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
|
||||
|
||||
@@ -480,7 +480,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(삭제됨)",
|
||||
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
|
||||
"workspace.cost.subscriptionShort": "구독",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 키",
|
||||
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
|
||||
|
||||
@@ -485,7 +485,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøkler",
|
||||
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
||||
|
||||
@@ -486,7 +486,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(usunięte)",
|
||||
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Klucze API",
|
||||
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user