diff --git a/.gitignore b/.gitignore index 78a77f81982..ce3d19e778c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .env .idea .vscode +.codex *~ playground tmp diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 888c4b7e0fc..d85201c6981 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,6 +1,5 @@ { "$schema": "https://opencode.ai/config.json", - // "plugin": ["opencode-openai-codex-auth"], // "enterprise": { // "url": "https://enterprise.dev.opencode.ai", // }, @@ -9,12 +8,7 @@ "options": {}, }, }, - "mcp": { - "context7": { - "type": "remote", - "url": "https://mcp.context7.com/mcp", - }, - }, + "mcp": {}, "tools": { "github-triage": false, "github-pr-search": false, diff --git a/bun.lock b/bun.lock index d6bb26afe31..28b679c1be4 100644 --- a/bun.lock +++ b/bun.lock @@ -374,6 +374,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "ai-gateway-provider": "2.3.1", "bonjour-service": "1.3.0", "bun-pty": "0.4.4", "chokidar": "4.0.3", @@ -637,8 +638,16 @@ "@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="], + "@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="], + "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="], + "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="], + + "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="], + + "@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="], @@ -2041,6 +2050,8 @@ "ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="], + "ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="], + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -4351,6 +4362,8 @@ "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], + "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="], + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -4863,6 +4876,14 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 0f721d63eb2..25959fab522 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ -import { test as base, expect } from "@playwright/test" -import { seedProjects } from "./actions" +import { test as base, expect, type Page } from "@playwright/test" +import { cleanupTestProject, createTestProject, seedProjects } from "./actions" import { promptSelector } from "./selectors" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" @@ -8,6 +8,14 @@ export const settingsKey = "settings.v3" type TestFixtures = { sdk: ReturnType gotoSession: (sessionID?: string) => Promise + withProject: ( + callback: (project: { + directory: string + slug: string + gotoSession: (sessionID?: string) => Promise + }) => Promise, + options?: { extra?: string[] }, + ) => Promise } type WorkerFixtures = { @@ -33,17 +41,7 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { - await seedProjects(page, { directory }) - await page.addInitScript(() => { - localStorage.setItem( - "opencode.global.dat:model", - JSON.stringify({ - recent: [{ providerID: "kilo", modelID: "kilo/auto" }], // kilocode_change - user: [], - variant: {}, - }), - ) - }) + await seedStorage(page, { directory }) const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) @@ -51,6 +49,39 @@ export const test = base.extend({ } await use(gotoSession) }, + 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 gotoSession = async (sessionID?: string) => { + await page.goto(sessionPath(directory, sessionID)) + await expect(page.locator(promptSelector)).toBeVisible() + } + + try { + await gotoSession() + return await callback({ directory, slug, gotoSession }) + } finally { + await cleanupTestProject(directory) + } + }) + }, }) +async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) { + await seedProjects(page, input) + await page.addInitScript(() => { + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ + recent: [{ providerID: "kilo", modelID: "kilo/auto" }], // kilocode_change + user: [], + variant: {}, + }), + ) + }) +} + export { expect } diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts index 772c259517f..4a286fea75f 100644 --- a/packages/app/e2e/projects/project-edit.spec.ts +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -1,52 +1,53 @@ import { test, expect } from "../fixtures" import { openSidebar } from "../actions" -test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => { - await gotoSession() +test("dialog edit project updates name and startup script", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - await openSidebar(page) + await withProject(async () => { + 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 dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") + return dialog + } + + const name = `e2e project ${Date.now()}` + const startup = `echo e2e_${Date.now()}` + + const dialog = await open() + + const nameInput = dialog.getByLabel("Name") + await nameInput.fill(name) + + const startupInput = dialog.getByLabel("Workspace startup script") + await startupInput.fill(startup) + + await dialog.getByRole("button", { name: "Save" }).click() + await expect(dialog).toHaveCount(0) - 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 }) + await expect(header).toContainText(name) - 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 dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project") - return dialog - } - - const name = `e2e project ${Date.now()}` - const startup = `echo e2e_${Date.now()}` - - const dialog = await open() - - const nameInput = dialog.getByLabel("Name") - await nameInput.fill(name) - - const startupInput = dialog.getByLabel("Workspace startup script") - await startupInput.fill(startup) - - await dialog.getByRole("button", { name: "Save" }).click() - await expect(dialog).toHaveCount(0) - - const header = page.locator(".group\\/project").first() - await expect(header).toContainText(name) - - const reopened = await open() - await expect(reopened.getByLabel("Name")).toHaveValue(name) - await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) - await reopened.getByRole("button", { name: "Cancel" }).click() - await expect(reopened).toHaveCount(0) + const reopened = await open() + await expect(reopened.getByLabel("Name")).toHaveValue(name) + await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup) + await reopened.getByRole("button", { name: "Cancel" }).click() + await expect(reopened).toHaveCount(0) + }) }) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index bd323b90c6b..95768d21e9e 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -1,69 +1,73 @@ import { test, expect } from "../fixtures" -import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" +import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => { +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) - await seedProjects(page, { directory, extra: [other] }) try { - await gotoSession() + await withProject( + async () => { + await openSidebar(page) - await openSidebar(page) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.hover() - 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() - const close = page.locator(projectCloseHoverSelector(otherSlug)).first() - await expect(close).toBeVisible() - await close.click() - - await expect(otherButton).toHaveCount(0) + await expect(otherButton).toHaveCount(0) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } }) -test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => { +test("can close a project via project header more options menu", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherName = other.split("/").pop() ?? other const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) try { - await gotoSession() + await withProject( + async () => { + await openSidebar(page) - await openSidebar(page) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + const header = page + .locator(".group\\/project") + .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) + .first() + await expect(header).toContainText(otherName) - const header = page - .locator(".group\\/project") - .filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) - .first() - await expect(header).toContainText(otherName) + const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() + await expect(trigger).toHaveCount(1) + await trigger.focus() + await page.keyboard.press("Enter") - const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() - await expect(trigger).toHaveCount(1) - await trigger.focus() - await page.keyboard.press("Enter") + const menu = page.locator('[data-component="dropdown-menu-content"]').first() + await expect(menu).toBeVisible({ timeout: 10_000 }) - const menu = page.locator('[data-component="dropdown-menu-content"]').first() - await expect(menu).toBeVisible({ timeout: 10_000 }) - - await clickMenuItem(menu, /^Close$/i, { force: true }) - await expect(otherButton).toHaveCount(0) + await clickMenuItem(menu, /^Close$/i, { force: true }) + await expect(otherButton).toHaveCount(0) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 829ed8e57d6..a817412cde2 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -1,33 +1,34 @@ import { test, expect } from "../fixtures" -import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions" +import { defocus, createTestProject, cleanupTestProject } from "../actions" import { projectSwitchSelector } from "../selectors" import { dirSlug } from "../utils" -test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => { +test("can switch between projects from sidebar", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) const other = await createTestProject() const otherSlug = dirSlug(other) - await seedProjects(page, { directory, extra: [other] }) - try { - await gotoSession() + await withProject( + async ({ directory }) => { + await defocus(page) - await defocus(page) + const currentSlug = dirSlug(directory) + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() - const currentSlug = dirSlug(directory) - const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() - await expect(otherButton).toBeVisible() - await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) - await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() - const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() - await expect(currentButton).toBeVisible() - await currentButton.click() - - await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + }, + { extra: [other] }, + ) } finally { await cleanupTestProject(other) } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 80cd63aa2a4..41a28e3e380 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -10,33 +10,20 @@ import { cleanupTestProject, clickMenuItem, confirmDialog, - createTestProject, openSidebar, openWorkspaceMenu, - seedProjects, setWorkspacesEnabled, } from "../actions" -import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors" -import { dirSlug } from "../utils" +import { inlineInputSelector, workspaceItemSelector } from "../selectors" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } -async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise) { - const project = await createTestProject() - const rootSlug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - await gotoSession() +async function setupWorkspaceTest(page: Page, project: { slug: string }) { + const rootSlug = project.slug await openSidebar(page) - const target = page.locator(projectSwitchSelector(rootSlug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - - await openSidebar(page) await setWorkspacesEnabled(page, rootSlug, true) await page.getByRole("button", { name: "New workspace" }).first().click() @@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () ) .toBe(true) - return { project, rootSlug, slug, directory: dir } + return { rootSlug, slug, directory: dir } } -test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => { +test("can enable and disable workspaces from project menu", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const project = await createTestProject() - const slug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(slug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - + await withProject(async ({ slug }) => { await openSidebar(page) await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() @@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc await setWorkspacesEnabled(page, slug, false) await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can create a workspace", async ({ page, directory, gotoSession }) => { +test("can create a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const project = await createTestProject() - const slug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(slug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session`)) - + await withProject(async ({ slug }) => { await openSidebar(page) await setWorkspacesEnabled(page, slug, true) @@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => { await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await cleanupTestProject(workspaceDir) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can rename a workspace", async ({ page, directory, gotoSession }) => { +test("can rename a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { slug } = await setupWorkspaceTest(page, project) - try { const rename = `e2e workspace ${Date.now()}` const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Rename$/i, { force: true }) @@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => { await input.fill(rename) await input.press("Enter") await expect(item).toContainText(rename) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { +test("can reset a workspace", async ({ page, sdk, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { slug, directory: createdDir } = await setupWorkspaceTest(page, project) - try { const readme = path.join(createdDir, "README.md") const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) const original = await fs.readFile(readme, "utf8") @@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { .catch(() => false) }) .toBe(false) - } finally { - await cleanupTestProject(project) - } + }) }) -test("can delete a workspace", async ({ page, directory, gotoSession }) => { +test("can delete a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) - const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession) + await withProject(async (project) => { + const { rootSlug, slug } = await setupWorkspaceTest(page, project) - try { const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) @@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => { await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() - } finally { - await cleanupTestProject(project) - } + }) }) -test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => { +test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) + await withProject(async ({ slug: rootSlug }) => { + const workspaces = [] as { directory: string; slug: string }[] - const project = await createTestProject() - const rootSlug = dirSlug(project) - await seedProjects(page, { directory, extra: [project] }) + const listSlugs = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + return slugs + } - const workspaces = [] as { directory: string; slug: string }[] - - const listSlugs = async () => { - const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') - const slugs = await nodes.evaluateAll((els) => { - return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) - }) - return slugs - } - - const waitReady = async (slug: string) => { - await expect - .poll( - async () => { - const item = page.locator(workspaceItemSelector(slug)).first() - try { - await item.hover({ timeout: 500 }) - return true - } catch { - return false - } - }, - { timeout: 60_000 }, - ) - .toBe(true) - } - - const drag = async (from: string, to: string) => { - 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") - - await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) - await page.mouse.down() - await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) - await page.mouse.up() - } - - try { - await gotoSession() - await openSidebar(page) - - const target = page.locator(projectSwitchSelector(rootSlug)).first() - await expect(target).toBeVisible() - await target.click() - await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - - await openSidebar(page) - await setWorkspacesEnabled(page, rootSlug, true) - - for (const _ of [0, 1]) { - const prev = slugFromUrl(page.url()) - await page.getByRole("button", { name: "New workspace" }).first().click() + const waitReady = async (slug: string) => { await expect .poll( - () => { - const slug = slugFromUrl(page.url()) - return slug.length > 0 && slug !== rootSlug && slug !== prev + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } }, - { timeout: 45_000 }, + { timeout: 60_000 }, ) .toBe(true) + } - const slug = slugFromUrl(page.url()) - const dir = base64Decode(slug) - workspaces.push({ slug, directory: dir }) + const drag = async (from: string, to: string) => { + 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") + + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2) + await page.mouse.down() + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 }) + await page.mouse.up() + } + + try { await openSidebar(page) + + await setWorkspacesEnabled(page, rootSlug, true) + + 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 dir = base64Decode(slug) + workspaces.push({ slug, directory: dir }) + + await openSidebar(page) + } + + if (workspaces.length !== 2) throw new Error("Expected two created workspaces") + + const a = workspaces[0].slug + const b = workspaces[1].slug + + await waitReady(a) + await waitReady(b) + + const list = async () => { + const slugs = await listSlugs() + return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) + } + + await expect + .poll(async () => { + const slugs = await list() + return slugs.length === 2 + }) + .toBe(true) + + const before = await list() + const from = before[1] + const to = before[0] + if (!from || !to) throw new Error("Failed to resolve initial workspace order") + + await drag(from, to) + + await expect.poll(async () => await list()).toEqual([from, to]) + } finally { + await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) } - - if (workspaces.length !== 2) throw new Error("Expected two created workspaces") - - const a = workspaces[0].slug - const b = workspaces[1].slug - - await waitReady(a) - await waitReady(b) - - const list = async () => { - const slugs = await listSlugs() - return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2) - } - - await expect - .poll(async () => { - const slugs = await list() - return slugs.length === 2 - }) - .toBe(true) - - const before = await list() - const from = before[1] - const to = before[0] - if (!from || !to) throw new Error("Failed to resolve initial workspace order") - - await drag(from, to) - - await expect.poll(async () => await list()).toEqual([from, to]) - } finally { - await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory))) - await cleanupTestProject(project) - } + }) }) diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 57bf86b5a87..10819e69ffe 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const reuse = !process.env.CI -const win = process.platform === "win32" export default defineConfig({ testDir: "./e2e", @@ -15,8 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: !win, - workers: win ? 1 : undefined, + fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 4309a725e51..3f0a8fb0aae 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler { protected _rowEnd(row: number, isLastRow: boolean): void { let rowSeparator = "" - if (this._nullCellCount > 0) { + const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1) + const wrapped = !!nextLine?.isWrapped + + if (this._nullCellCount > 0 && wrapped) { this._currentRow += " ".repeat(this._nullCellCount) - this._nullCellCount = 0 } - if (!isLastRow) { - const nextLine = this._buffer.getLine(row + 1) + this._nullCellCount = 0 - if (!nextLine?.isWrapped) { - rowSeparator = "\r\n" - this._lastCursorRow = row + 1 - this._lastCursorCol = 0 - } + if (!isLastRow && !wrapped) { + rowSeparator = "\r\n" + this._lastCursorRow = row + 1 + this._lastCursorCol = 0 } this._allRows[this._rowIndex] = this._currentRow @@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler { const sgrSeq = this._diffStyle(cell, this._cursorStyle) - const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0 + const styleChanged = sgrSeq.length > 0 if (styleChanged) { if (this._nullCellCount > 0) { @@ -442,12 +442,24 @@ class StringSerializeHandler extends BaseSerializeHandler { } } - if (!excludeFinalCursorPosition) { - const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY - const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) - const cursorCol = this._buffer.cursorX + 1 - content += `\u001b[${cursorRow};${cursorCol}H` - } + if (excludeFinalCursorPosition) return content + + const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY + const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) + const cursorCol = this._buffer.cursorX + 1 + content += `\u001b[${cursorRow};${cursorCol}H` + + const line = this._buffer.getLine(absoluteCursorRow) + const cell = line?.getCell(this._buffer.cursorX) + const style = (() => { + if (!cell) return this._buffer.getNullCell() + if (cell.getWidth() !== 0) return cell + if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell + return cell + })() + + const sgrSeq = this._diffStyle(style, this._cursorStyle) + if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m` return content } diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 490753e6224..622daee7a3e 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
- +
- +
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 9c17da23b32..adb52404b45 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,17 +1,22 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" +import { base64Encode } from "@opencode-ai/util/encode" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useParams } from "@solidjs/router" -import { createMemo, createSignal, onCleanup, Show } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" -type EntryType = "command" | "file" +type EntryType = "command" | "file" | "session" type Entry = { id: string @@ -22,6 +27,9 @@ type Entry = { category: string option?: CommandOption path?: string + directory?: string + sessionID?: string + archived?: number } type DialogSelectFileMode = "all" | "files" @@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const file = useFile() const dialog = useDialog() const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) @@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil path, }) + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const sessionItem = (input: { + directory: string + id: string + title: string + description: string + archived?: number + }): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category: language.t("command.category.session"), + directory: input.directory, + sessionID: input.id, + archived: input.archived, + }) + const list = createMemo(() => allowed().map(commandItem)) const picks = createMemo(() => { @@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return out } + const sessionToken = { value: 0 } + let sessionInflight: Promise | undefined + let sessionAll: Entry[] | undefined + + const sessions = (text: string) => { + const query = text.trim() + if (!query) { + sessionToken.value += 1 + sessionInflight = undefined + sessionAll = undefined + return [] as Entry[] + } + + if (sessionAll) return sessionAll + if (sessionInflight) return sessionInflight + + const current = sessionToken.value + const dirs = workspaces() + if (dirs.length === 0) return [] as Entry[] + + sessionInflight = Promise.all( + dirs.map((directory) => { + const description = label(directory) + return globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? language.t("command.session.new"), + description, + directory, + archived: s.time?.archived, + })), + ) + .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) + }), + ) + .then((results) => { + if (sessionToken.value !== current) return [] as Entry[] + const seen = new Set() + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map(sessionItem) + sessionAll = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + sessionInflight = undefined + }) + + return sessionInflight + } + const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) @@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const files = await file.searchFiles(query) return files.map(fileItem) } - const files = await file.searchFiles(query) + + const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) const entries = files.map(fileItem) - return [...list(), ...entries] + return [...list(), ...nextSessions, ...entries] } const handleMove = (item: Entry | undefined) => { @@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return } + if (item.type === "session") { + if (!item.directory || !item.sessionID) return + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + return + } + if (!item.path) return open(item.path) } @@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil items={items} key={(item) => item.id} filterKeys={["title", "description", "category"]} - groupBy={(item) => item.category} + groupBy={grouped() ? (item) => item.category : () => ""} onMove={handleMove} onSelect={handleSelect} > {(item) => ( -
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
} > -
-
- {item.title} - - {item.description} + +
+
+ {item.title} + + {item.description} + +
+ + {formatKeybind(item.keybind ?? "")}
- - {formatKeybind(item.keybind ?? "")} - -
- + + +
+
+ +
+ + {item.title} + + + + {item.description} + + +
+
+
+
+ )} diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 1846f1aff24..94e6646b15b 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -321,7 +321,6 @@ export default function FileTree(props: { return ( = (props) => { const images = imageAttachments().slice() const mode = store.mode - if (text.trim().length === 0 && images.length === 0) { + if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) { if (working()) abort() return } @@ -1934,13 +1934,13 @@ export const PromptInput: Component = (props) => {