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
43 changes: 43 additions & 0 deletions .github/workflows/docs-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: docs-lint

# Pattern J: docs/specs-only changes get a light lint lane instead of full CI.
on:
pull_request:
paths:
- "specs/**"
- "docs/**"
- "**/*.md"
- "**/*.mdx"
- "!packages/guardrails/**"
- "!.github/workflows/**"
workflow_dispatch:

permissions:
contents: read

jobs:
docs-lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Lint markdown / specs presence
run: |
set -euo pipefail
mapfile -t files < <(git ls-files 'specs/**/*.md' 'docs/**/*.md' '*.md' ':!:packages/guardrails/**' ':!:**/node_modules/**' | head -200)
if [ "${#files[@]}" -eq 0 ]; then
echo "No docs files to lint"
exit 0
fi
empty=0
for f in "${files[@]}"; do
if [ ! -s "$f" ]; then
echo "::error::empty docs file: $f"
empty=1
fi
done
if [ "$empty" -ne 0 ]; then
exit 1
fi
echo "docs-lint ok (${#files[@]} files sampled)"
12 changes: 12 additions & 0 deletions .github/workflows/nix-eval.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,20 @@ name: nix-eval
on:
push:
branches: [dev]
# Pattern J: docs/specs-only PRs skip nix-eval; guardrails still triggers.
pull_request:
branches: [dev]
paths:
- "**"
- "!specs/**"
- "!docs/**"
- "!**/*.md"
- "!**/*.mdx"
- "packages/guardrails/**"
- ".github/workflows/**"
- "flake.nix"
- "flake.lock"
- "nix/**"
workflow_dispatch:

concurrency:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ on:
push:
branches:
- dev
# Pattern J: skip full unit/e2e for docs-only PRs; guardrails changes still match.
pull_request:
paths:
- "**"
- "!specs/**"
- "!docs/**"
- "!**/*.md"
- "!**/*.mdx"
- "packages/guardrails/**"
- ".github/workflows/**"
workflow_dispatch:

concurrency:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ name: typecheck
on:
push:
branches: [dev]
# Pattern J: docs/specs-only PRs skip typecheck; guardrails still triggers.
pull_request:
branches: [dev]
paths:
- "**"
- "!specs/**"
- "!docs/**"
- "!**/*.md"
- "!**/*.mdx"
- "packages/guardrails/**"
- ".github/workflows/**"
workflow_dispatch:

jobs:
Expand Down
60 changes: 60 additions & 0 deletions packages/app/e2e/regression/project-picker-recent-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"

const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)

// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"

// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)

async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}

test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)

await search.fill("foxtrot")

await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})

test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)

await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
8 changes: 6 additions & 2 deletions packages/app/src/components/dialog-select-directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ interface DialogSelectDirectoryProps {
server: ServerConnection.Any
}

const RECENT_PROJECT_LIMIT = 5

type Row = {
absolute: string
search: string
Expand Down Expand Up @@ -102,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.slice(0, 5)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
Expand All @@ -116,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
return uniqueRows([...recentProjects(), ...directoryRows])
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}

function resolve(absolute: string) {
Expand Down
89 changes: 88 additions & 1 deletion packages/app/src/context/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
bootstrapDirectory,
loadAgentsQuery,
loadCommands,
loadGlobalConfigQuery,
loadPathQuery,
loadProjectsQuery,
loadProvidersQuery,
Expand Down Expand Up @@ -76,6 +77,7 @@ function directoryState() {

describe("bootstrapDirectory", () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const legacyConfigReads: string[] = []
const mcpReads: string[] = []
const [store, setStore] = directoryState()

Expand All @@ -91,7 +93,12 @@ describe("bootstrapDirectory", () => {
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
config: {
get: async () => {
legacyConfigReads.push("directory")
return { data: {} }
},
},
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
Expand Down Expand Up @@ -134,8 +141,88 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))

expect(store.status).toBe("complete")
expect(legacyConfigReads).toEqual(["directory"])
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})

test("skips legacy config while refreshing a v2 directory", async () => {
const [store, setStore] = directoryState()

await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
config: {
get: async () => {
throw new Error("legacy directory config should not be called")
},
},
} as unknown as OpencodeClient,
api,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v2"),
})

expect(store.status).toBe("partial")

await new Promise((resolve) => setTimeout(resolve, 80))

expect(store.status).toBe("complete")
})
})

describe("config queries", () => {
test("skips legacy global config for v2 servers", async () => {
const sdk = {
global: {
config: {
get: async () => {
throw new Error("legacy global config should not be called")
},
},
},
} as unknown as OpencodeClient

const result = await new QueryClient().fetchQuery(
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v2")),
)

expect(result).toEqual({})
})

test("loads legacy global config for v1 servers", async () => {
const calls: string[] = []
const config = { shell: "zsh" } satisfies Config
const sdk = {
global: {
config: {
get: async () => {
calls.push("global")
return { data: config }
},
},
},
} as unknown as OpencodeClient

const result = await new QueryClient().fetchQuery(
loadGlobalConfigQuery(ServerScope.local, sdk, Promise.resolve("v1")),
)

expect(result).toEqual(config)
expect(calls).toEqual(["global"])
})
})

describe("query keys", () => {
Expand Down
14 changes: 10 additions & 4 deletions packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,13 @@ function showErrors(input: {
})
}

export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient, protocol?: Promise<ServerProtocol>) =>
queryOptions({
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
queryFn: async () => {
if ((await protocol) !== "v1") return {}
return retry(() => sdk.global.config.get().then((x) => x.data!))
},
})

type ProjectApi = {
Expand Down Expand Up @@ -149,7 +152,7 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
Expand Down Expand Up @@ -376,7 +379,10 @@ export async function bootstrapDirectory(input: {
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))
}),
() =>
retry(() =>
(async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/context/server-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ function makeQueryOptionsApi(
protocol: Promise<"v1" | "v2">,
) {
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK(), protocol),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
Expand Down
37 changes: 37 additions & 0 deletions packages/app/src/pages/session/timeline/rows-current.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,41 @@ describe("current session timeline rows", () => {
"thinking:msg_2",
])
})

test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
{
id: "msg_failed",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
error: { type: "ProviderError", message: "temporary failure" },
time: { created: 2, completed: 3 },
},
{
id: "msg_recovery",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "streaming again" }],
time: { created: 4 },
},
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))

const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"busy",
true,
normalized.messages.filter((message) => message.role === "user"),
)

expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
})
})
Loading
Loading