Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,15 @@ jobs:

- name: Install ripgrep
if: runner.os == 'Windows'
run: choco install ripgrep --version 15.1.0 --yes --no-progress
shell: pwsh
run: |
$archive = Join-Path $env:RUNNER_TEMP "ripgrep.zip"
Invoke-WebRequest "https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip" -OutFile $archive
if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne "124510b94b6baa3380d051fdf4650eaa80a302c876d611e9dba0b2e18d87493a") {
throw "Ripgrep checksum mismatch"
}
Expand-Archive $archive -DestinationPath "$env:RUNNER_TEMP/ripgrep"
"$env:RUNNER_TEMP/ripgrep/ripgrep-15.1.0-x86_64-pc-windows-msvc" >> $env:GITHUB_PATH

- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
Expand All @@ -69,8 +77,8 @@ jobs:
turbo-${{ runner.os }}-

- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || '2' }}
timeout-minutes: ${{ github.repository != 'anomalyco/opencode' && runner.os == 'Windows' && 60 || 20 }}
run: GITHUB_ACTIONS=false bun turbo test --log-order=stream --concurrency=${{ github.repository == 'anomalyco/opencode' && '10' || (runner.os == 'Windows' && '1' || '2') }}
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}

Expand Down
437 changes: 0 additions & 437 deletions docs/fork/web-audit.md

This file was deleted.

163 changes: 85 additions & 78 deletions packages/app/e2e/regression/session-context-usage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,89 +33,96 @@ const messages = [0, 1].flatMap<typeof SessionMessage.Message.Encoded>((index) =
])
Schema.decodeUnknownSync(Schema.Array(SessionMessage.Message))(messages, { onExcessProperty: "error" })

for (const mode of ["recorded", "rollback", "live"] as const) {
test(`context circle and detail agree with ${mode} usage`, async ({ page }) => {
const reverted = mode === "rollback"
let settled = mode !== "live"
const transport = await installSseTransport<TimelineEvent>(page, {
server: `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
path: `/api/session/${sessionID}/event`,
})
await mockOpenCodeServer(page, {
directory,
project: { id: "proj_context_usage", worktree: directory, time: { created: 1, updated: 1 }, sandboxes: [] },
provider: {
all: [
for (const newLayoutDesigns of [false, true]) {
for (const mode of ["recorded", "rollback", "live"] as const) {
test(`context circle and detail agree with ${mode} usage (${newLayoutDesigns ? "v2" : "legacy"})`, async ({
page,
}) => {
await page.addInitScript((newLayoutDesigns) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns } }))
}, newLayoutDesigns)
const reverted = mode === "rollback"
let settled = mode !== "live"
const transport = await installSseTransport<TimelineEvent>(page, {
server: `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
path: `/api/session/${sessionID}/event`,
})
await mockOpenCodeServer(page, {
directory,
project: { id: "proj_context_usage", worktree: directory, time: { created: 1, updated: 1 }, sandboxes: [] },
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 100_000 } } },
},
],
connected: ["opencode"],
default: model,
},
sessions: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 100_000 } } },
id: sessionID,
directory,
title: "Context usage regression",
cost: 2.5,
time: { created: 1, updated: 5 },
...(reverted ? { revert: { messageID: "msg_3_user", inclusive: true } } : {}),
},
],
connected: ["opencode"],
default: model,
},
sessions: [
{
id: sessionID,
directory,
title: "Context usage regression",
cost: 2.5,
time: { created: 1, updated: 5 },
...(reverted ? { revert: { messageID: "msg_3_user", inclusive: true } } : {}),
},
],
currentPageMessages: () => ({
items: messages
.map((message) =>
!settled && message.type === "assistant" && message.id === "msg_4_assistant"
? {
...message,
time: { created: 4 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
: message,
currentPageMessages: () => ({
items: messages
.map((message) =>
!settled && message.type === "assistant" && message.id === "msg_4_assistant"
? {
...message,
time: { created: 4 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
: message,
)
.toReversed(),
throughSeq: 0,
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Context usage regression")
const usage = page.getByRole("button", { name: "View context usage", exact: true }).first()
const percentage = reverted ? 25 : 50
const expectPercentage = (value: number) =>
expect
.poll(async () =>
usage.locator('circle[data-slot$="-progress"]').evaluate((circle) => {
const total = Number(circle.getAttribute("stroke-dasharray"))
return Math.round(100 * (1 - Number(circle.getAttribute("stroke-dashoffset")) / total))
}),
)
.toReversed(),
throughSeq: 0,
}),
})
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, "Context usage regression")
const usage = page.getByRole("button", { name: "View context usage", exact: true }).first()
const percentage = reverted ? 25 : 50
const expectPercentage = (value: number) =>
expect
.poll(async () =>
usage.locator('circle[data-slot$="-progress"]').evaluate((circle) => {
const total = Number(circle.getAttribute("stroke-dasharray"))
return Math.round(100 * (1 - Number(circle.getAttribute("stroke-dashoffset")) / total))
.toBe(value)
await expectPercentage(mode === "live" ? 25 : percentage)
if (mode === "live") {
settled = true
await transport.send(
event("session.next.step.ended", {
sessionID,
timestamp: 10,
assistantMessageID: "msg_4_assistant",
finish: "stop",
cost: 1.25,
tokens: { input: 40_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } },
}),
)
.toBe(value)
await expectPercentage(mode === "live" ? 25 : percentage)
if (mode === "live") {
settled = true
await transport.send(
event("session.next.step.ended", {
sessionID,
timestamp: 10,
assistantMessageID: "msg_4_assistant",
finish: "stop",
cost: 1.25,
tokens: { input: 40_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } },
}),
await expectPercentage(50)
}
await usage.hover()
await expect(page.getByRole("tooltip")).toContainText(`${percentage}%`)
await expect(page.getByRole("tooltip")).toContainText((percentage * 1_000).toLocaleString("en-US"))
await usage.click()
await expect(page.getByText("Total Tokens", { exact: true }).locator("..")).toContainText(
(percentage * 1_000).toLocaleString("en-US"),
)
await expectPercentage(50)
}
await usage.hover()
await expect(page.getByRole("tooltip")).toContainText(`${percentage}%`)
await expect(page.getByRole("tooltip")).toContainText((percentage * 1_000).toLocaleString("en-US"))
await usage.click()
await expect(page.getByText("Total Tokens", { exact: true }).locator("..")).toContainText(
(percentage * 1_000).toLocaleString("en-US"),
)
await expect(page.getByText("Usage", { exact: true }).last().locator("..")).toContainText(`${percentage}%`)
})
await expect(page.getByText("Usage", { exact: true }).last().locator("..")).toContainText(`${percentage}%`)
})
}
}
4 changes: 4 additions & 0 deletions packages/app/e2e/regression/session-rollback-queue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,15 @@ test("command undo and redo use the current pending input and replacement draft"
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, session.title)
await expect(page.getByText("second user prompt", { exact: true })).toHaveCount(1)
await page.clock.install()
const select = async (command: string) => {
await page.keyboard.press("Control+p")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox").fill(command)
await dialog.getByText(command, { exact: true }).click()
// Dialog portals disappear before the 100ms close timer releases the command lock.
await page.clock.runFor(150)
await expect(page.locator("[data-dialog-layer]")).toHaveCount(0)
}
const input = page.locator('[data-component="prompt-input"]')
await select("Undo")
Expand Down
17 changes: 1 addition & 16 deletions packages/app/e2e/smoke/session-timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentPageMessages, fixture } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import { expectAtBottom, scrollToBottom } from "../utils/scroll"

const forbiddenText = ["Load details", "Show earlier steps"]
Expand Down Expand Up @@ -383,9 +383,6 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)

await selectHomeProject(page, fixture.project.name)
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
Expand Down Expand Up @@ -763,18 +760,6 @@ function expectCompleteScroll(
expect(expectedPartIDs.length).toBe(331)
}

async function selectHomeProject(page: Page, projectName: string) {
await page.goto("/")
const row = page
.locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") })
.first()
await expectAppVisible(row)
await row.click()
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
await expect(page).toHaveURL(/\/$/)
}

async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
await expectSessionTitle(page, expectedTitle)
Expand Down
21 changes: 18 additions & 3 deletions packages/app/e2e/user-story/model-selection-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"

test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
let connectedGo = false
let delayCatalogue = false
const catalogue = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const connections: Array<{ integrationID: string; body: unknown }> = []

await mockOpenCodeServer(page, {
Expand Down Expand Up @@ -61,8 +66,12 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
})
await page.route(
(url) => url.pathname === "/api/integration",
(route) =>
route.fulfill({
async (route) => {
if (delayCatalogue) {
requested.resolve()
await catalogue.promise
}
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({
location: { directory },
Expand All @@ -71,7 +80,8 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
],
}),
}),
})
},
)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
Expand All @@ -91,8 +101,12 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
await modelControl.click()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")

delayCatalogue = true
await page.locator('[data-provider-id="opencode-go"]').click()
await requested.promise
expect(errors).toEqual([])
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
catalogue.resolve()
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { key: "mock-go-api-key" } }])
Expand All @@ -104,4 +118,5 @@ test("creates a session in a new project, connects OpenCode Go, and selects its
await goModel.click()

await expect(modelControl).toContainText("Go Model 1")
expect(errors).toEqual([])
})
4 changes: 3 additions & 1 deletion packages/app/src/components/dialog-connect-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,9 @@ function ProviderConnection(props: {
})

const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
() =>
providers.all().get(props.provider) ??
serverSync().data.provider.all.get(props.provider) ?? { id: props.provider, name: props.provider },
)
const fallback = createMemo<ConnectMethod[]>(() => [
{
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/file-tree-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
normalizeFileTreeV2Path,
type FileTreeV2Node,
} from "@/components/file-tree-v2-model"
import { virtualScrollElement } from "@/components/virtual-scroll-element"
import { createVirtualScrollElement } from "@/components/virtual-scroll-element"

export type { Kind } from "@/components/file-tree"

Expand Down Expand Up @@ -142,12 +142,13 @@ export default function FileTreeV2(props: {
return flattenFileTreeV2(model()!, expanded)
})
const [root, setRoot] = createSignal<HTMLDivElement>()
const scrollElement = createVirtualScrollElement(root)
const [focused, setFocused] = createSignal<string>()
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return rows().length
},
getScrollElement: () => virtualScrollElement(root()),
getScrollElement: scrollElement,
initialRect: { width: 0, height: 600 },
estimateSize: () => 28,
gap: 2,
Expand Down
12 changes: 7 additions & 5 deletions packages/app/src/components/session-context-usage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
import { Match, Show, Switch, batch, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
Expand Down Expand Up @@ -41,10 +41,12 @@ function openSessionContext(args: {
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context")
args.tabs.setActive("context")
batch(() => {
args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button")
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
void args.tabs.open("context")
args.tabs.setActive("context")
})
}

export function SessionContextUsage(props: SessionContextUsageProps) {
Expand Down
Loading
Loading