diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts new file mode 100644 index 00000000000..3da16d3171f --- /dev/null +++ b/packages/app/e2e/actions.ts @@ -0,0 +1,160 @@ +import { expect, type Locator, type Page } from "@playwright/test" +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" + +export async function defocus(page: Page) { + await page.mouse.click(5, 5) +} + +export async function openPalette(page: Page) { + await defocus(page) + await page.keyboard.press(`${modKey}+P`) + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect(dialog.getByRole("textbox").first()).toBeVisible() + return dialog +} + +export async function closeDialog(page: Page, dialog: Locator) { + await page.keyboard.press("Escape") + const closed = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (closed) return + + await page.keyboard.press("Escape") + const closedSecond = await dialog + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) + + if (closedSecond) return + + await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) + await expect(dialog).toHaveCount(0) +} + +export async function isSidebarClosed(page: Page) { + const main = page.locator("main") + const classes = (await main.getAttribute("class")) ?? "" + return classes.includes("xl:border-l") +} + +export async function toggleSidebar(page: Page) { + await defocus(page) + await page.keyboard.press(`${modKey}+B`) +} + +export async function openSidebar(page: Page) { + if (!(await isSidebarClosed(page))) return + await toggleSidebar(page) + await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) +} + +export async function closeSidebar(page: Page) { + if (await isSidebarClosed(page)) return + await toggleSidebar(page) + await expect(page.locator("main")).toHaveClass(/xl:border-l/) +} + +export async function openSettings(page: Page) { + await defocus(page) + + const dialog = page.getByRole("dialog") + await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) + + const opened = await dialog + .waitFor({ state: "visible", timeout: 3000 }) + .then(() => true) + .catch(() => false) + + if (opened) return dialog + + await page.getByRole("button", { name: "Settings" }).first().click() + await expect(dialog).toBeVisible() + return dialog +} + +export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) { + await page.addInitScript( + (args: { directory: string; serverUrl: string; extra: string[] }) => { + const key = "opencode.global.dat:server" + const raw = localStorage.getItem(key) + const parsed = (() => { + if (!raw) return undefined + try { + return JSON.parse(raw) as unknown + } catch { + return undefined + } + })() + + const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} + const list = Array.isArray(store.list) ? store.list : [] + const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} + const projects = store.projects && typeof store.projects === "object" ? store.projects : {} + const nextProjects = { ...(projects as Record) } + + const add = (origin: string, directory: string) => { + const current = nextProjects[origin] + const items = Array.isArray(current) ? current : [] + const existing = items.filter( + (p): p is { worktree: string; expanded?: boolean } => + !!p && + typeof p === "object" && + "worktree" in p && + typeof (p as { worktree?: unknown }).worktree === "string", + ) + + if (existing.some((p) => p.worktree === directory)) return + nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing] + } + + const directories = [args.directory, ...args.extra] + for (const directory of directories) { + add("local", directory) + add(args.serverUrl, directory) + } + + localStorage.setItem( + key, + JSON.stringify({ + list, + projects: nextProjects, + lastProject, + }), + ) + }, + { directory: input.directory, serverUrl, extra: input.extra ?? [] }, + ) +} + +export async function createTestProject() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) + + await fs.writeFile(path.join(root, "README.md"), "# e2e\n") + + execSync("git init", { cwd: root, stdio: "ignore" }) + execSync("git add -A", { cwd: root, stdio: "ignore" }) + execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { + cwd: root, + stdio: "ignore", + }) + + return root +} + +export async function cleanupTestProject(directory: string) { + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) +} + +export function sessionIDFromUrl(url: string) { + const match = /\/session\/([^/?#]+)/.exec(url) + return match?.[1] +} diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/app/home.spec.ts similarity index 88% rename from packages/app/e2e/home.spec.ts rename to packages/app/e2e/app/home.spec.ts index c6fb0e3b074..f21dc40ec21 100644 --- a/packages/app/e2e/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { serverName } from "./utils" +import { test, expect } from "../fixtures" +import { serverName } from "../utils" test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/app/navigation.spec.ts similarity index 66% rename from packages/app/e2e/navigation.spec.ts rename to packages/app/e2e/app/navigation.spec.ts index 76923af6ede..328c950df36 100644 --- a/packages/app/e2e/navigation.spec.ts +++ b/packages/app/e2e/app/navigation.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { dirPath, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { dirPath } from "../utils" test("project route redirects to /session", async ({ page, directory, slug }) => { await page.goto(dirPath(directory)) diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts new file mode 100644 index 00000000000..3ccfd7a9250 --- /dev/null +++ b/packages/app/e2e/app/palette.spec.ts @@ -0,0 +1,11 @@ +import { test, expect } from "../fixtures" +import { openPalette } from "../actions" + +test("search palette opens and closes", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openPalette(page) + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) +}) diff --git a/packages/app/e2e/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts similarity index 95% rename from packages/app/e2e/server-default.spec.ts rename to packages/app/e2e/app/server-default.spec.ts index b6b16f0bccf..6f44ded1a28 100644 --- a/packages/app/e2e/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { serverName, serverUrl } from "./utils" +import { test, expect } from "../fixtures" +import { serverName, serverUrl } from "../utils" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/app/session.spec.ts similarity index 87% rename from packages/app/e2e/session.spec.ts rename to packages/app/e2e/app/session.spec.ts index 19e25a42131..d35af7ef778 100644 --- a/packages/app/e2e/session.spec.ts +++ b/packages/app/e2e/app/session.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke ${Date.now()}` diff --git a/packages/app/e2e/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts similarity index 83% rename from packages/app/e2e/titlebar-history.spec.ts rename to packages/app/e2e/app/titlebar-history.spec.ts index d4aa605e6dd..c7ff6566c14 100644 --- a/packages/app/e2e/titlebar-history.spec.ts +++ b/packages/app/e2e/app/titlebar-history.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" +import { promptSelector } from "../selectors" test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => { await page.setViewportSize({ width: 1400, height: 800 }) @@ -14,12 +15,7 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd try { await gotoSession(one.id) - const main = page.locator("main") - const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") - if (collapsed) { - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(/xl:border-l/) - } + await openSidebar(page) const link = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(link).toBeVisible() diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts similarity index 73% rename from packages/app/e2e/file-open.spec.ts rename to packages/app/e2e/files/file-open.spec.ts index fb7104b6b05..dea35d25baa 100644 --- a/packages/app/e2e/file-open.spec.ts +++ b/packages/app/e2e/files/file-open.spec.ts @@ -1,13 +1,10 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" +import { test, expect } from "../fixtures" +import { openPalette } from "../actions" test("can open a file tab from the search palette", async ({ page, gotoSession }) => { await gotoSession() - await page.keyboard.press(`${modKey}+P`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() + const dialog = await openPalette(page) const input = dialog.getByRole("textbox").first() await input.fill("package.json") diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts similarity index 96% rename from packages/app/e2e/file-tree.spec.ts rename to packages/app/e2e/files/file-tree.spec.ts index 0b04eb2468f..844da1b329b 100644 --- a/packages/app/e2e/file-tree.spec.ts +++ b/packages/app/e2e/files/file-tree.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures" +import { test, expect } from "../fixtures" test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts similarity index 81% rename from packages/app/e2e/file-viewer.spec.ts rename to packages/app/e2e/files/file-viewer.spec.ts index 1e0f8a6f23a..3dc0dead2d8 100644 --- a/packages/app/e2e/file-viewer.spec.ts +++ b/packages/app/e2e/files/file-viewer.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" +import { test, expect } from "../fixtures" +import { openPalette } from "../actions" test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { await gotoSession() @@ -7,10 +7,7 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession } const sep = process.platform === "win32" ? "\\" : "/" const file = ["packages", "app", "package.json"].join(sep) - await page.keyboard.press(`${modKey}+P`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() + const dialog = await openPalette(page) const input = dialog.getByRole("textbox").first() await input.fill(file) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index c5315ff194d..0c3150609b3 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,7 @@ import { test as base, expect } from "@playwright/test" -import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils" +import { seedProjects } from "./actions" +import { promptSelector } from "./selectors" +import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" type TestFixtures = { sdk: ReturnType @@ -29,54 +31,17 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { - await page.addInitScript( - (input: { directory: string; serverUrl: string }) => { - const key = "opencode.global.dat:server" - const raw = localStorage.getItem(key) - const parsed = (() => { - if (!raw) return undefined - try { - return JSON.parse(raw) as unknown - } catch { - return undefined - } - })() - - const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} - const list = Array.isArray(store.list) ? store.list : [] - const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} - const projects = store.projects && typeof store.projects === "object" ? store.projects : {} - const nextProjects = { ...(projects as Record) } - - const add = (origin: string) => { - const current = nextProjects[origin] - const items = Array.isArray(current) ? current : [] - const existing = items.filter( - (p): p is { worktree: string; expanded?: boolean } => - !!p && - typeof p === "object" && - "worktree" in p && - typeof (p as { worktree?: unknown }).worktree === "string", - ) - - if (existing.some((p) => p.worktree === input.directory)) return - nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing] - } - - add("local") - add(input.serverUrl) - - localStorage.setItem( - key, - JSON.stringify({ - list, - projects: nextProjects, - lastProject, - }), - ) - }, - { directory, serverUrl }, - ) + await seedProjects(page, { directory }) + await page.addInitScript(() => { + localStorage.setItem( + "opencode.global.dat:model", + JSON.stringify({ + recent: [{ providerID: "opencode", modelID: "big-pickle" }], + user: [], + variant: {}, + }), + ) + }) const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) diff --git a/packages/app/e2e/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts similarity index 94% rename from packages/app/e2e/model-picker.spec.ts rename to packages/app/e2e/models/model-picker.spec.ts index 9e64b3dfb0a..df95e04d222 100644 --- a/packages/app/e2e/model-picker.spec.ts +++ b/packages/app/e2e/models/model-picker.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts similarity index 66% rename from packages/app/e2e/models-visibility.spec.ts rename to packages/app/e2e/models/models-visibility.spec.ts index 680ba96a318..36f14596dd3 100644 --- a/packages/app/e2e/models-visibility.spec.ts +++ b/packages/app/e2e/models/models-visibility.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { closeDialog, openSettings } from "../actions" test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { await gotoSession() @@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi await page.keyboard.press("Escape") await expect(picker).toHaveCount(0) - const settings = page.getByRole("dialog") - - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - const opened = await settings - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (!opened) { - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(settings).toBeVisible() - } + const settings = await openSettings(page) await settings.getByRole("tab", { name: "Models" }).click() const search = settings.getByPlaceholder("Search models") @@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi await toggle.locator('[data-slot="switch-control"]').click() await expect(input).toHaveAttribute("aria-checked", "false") - await page.keyboard.press("Escape") - const closed = await settings - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (!closed) { - await page.keyboard.press("Escape") - const closedSecond = await settings - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (!closedSecond) { - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(settings).toHaveCount(0) - } - } + await closeDialog(page, settings) await page.locator(promptSelector).click() await page.keyboard.type("/model") diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts deleted file mode 100644 index 617c55ac167..00000000000 --- a/packages/app/e2e/palette.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" - -test("search palette opens and closes", async ({ page, gotoSession }) => { - await gotoSession() - - await page.keyboard.press(`${modKey}+P`) - - const dialog = page.getByRole("dialog") - await expect(dialog).toBeVisible() - await expect(dialog.getByRole("textbox").first()).toBeVisible() - - await page.keyboard.press("Escape") - await expect(dialog).toHaveCount(0) -}) diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts new file mode 100644 index 00000000000..22d053f3d92 --- /dev/null +++ b/packages/app/e2e/projects/project-edit.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" + +test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => { + await gotoSession() + await page.setViewportSize({ width: 1400, height: 800 }) + + 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 }) + + await page.getByRole("menuitem", { name: "Edit" }).click() + + 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) +}) diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts new file mode 100644 index 00000000000..c3618740dd9 --- /dev/null +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from "../fixtures" +import { createTestProject, seedProjects, cleanupTestProject, openSidebar } 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 }) => { + 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 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) + } finally { + await cleanupTestProject(other) + } +}) + +test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherName = other.split("/").pop() + const otherSlug = dirSlug(other) + await seedProjects(page, { directory, extra: [other] }) + + try { + await gotoSession() + + await openSidebar(page) + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + + 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 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 close = page + .locator(projectCloseMenuSelector(otherSlug)) + .or(page.getByRole("menuitem", { name: "Close" })) + .or( + page + .locator('[data-component="dropdown-menu-content"] [data-slot="dropdown-menu-item"]') + .filter({ hasText: "Close" }), + ) + .first() + await expect(close).toBeVisible({ timeout: 10_000 }) + await close.click({ force: true }) + await expect(otherButton).toHaveCount(0) + } finally { + await cleanupTestProject(other) + } +}) diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts new file mode 100644 index 00000000000..829ed8e57d6 --- /dev/null +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "../fixtures" +import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions" +import { projectSwitchSelector } from "../selectors" +import { dirSlug } from "../utils" + +test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => { + 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 defocus(page) + + 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`)) + + const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() + await expect(currentButton).toBeVisible() + await currentButton.click() + + await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`)) + } finally { + await cleanupTestProject(other) + } +}) diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts similarity index 93% rename from packages/app/e2e/context.spec.ts rename to packages/app/e2e/prompt/context.spec.ts index beabd2eb7dd..9e8f998f27f 100644 --- a/packages/app/e2e/context.spec.ts +++ b/packages/app/e2e/prompt/context.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke context ${Date.now()}` diff --git a/packages/app/e2e/prompt-mention.spec.ts b/packages/app/e2e/prompt/prompt-mention.spec.ts similarity index 90% rename from packages/app/e2e/prompt-mention.spec.ts rename to packages/app/e2e/prompt/prompt-mention.spec.ts index 113b8465f71..5cc9f6e6850 100644 --- a/packages/app/e2e/prompt-mention.spec.ts +++ b/packages/app/e2e/prompt/prompt-mention.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/prompt-slash-open.spec.ts b/packages/app/e2e/prompt/prompt-slash-open.spec.ts similarity index 86% rename from packages/app/e2e/prompt-slash-open.spec.ts rename to packages/app/e2e/prompt/prompt-slash-open.spec.ts index 3c29d405c18..b4a93099d9d 100644 --- a/packages/app/e2e/prompt-slash-open.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-open.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts similarity index 89% rename from packages/app/e2e/prompt.spec.ts rename to packages/app/e2e/prompt/prompt.spec.ts index 3e5892ce8d5..33f8d7ebc3a 100644 --- a/packages/app/e2e/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -1,10 +1,6 @@ -import { test, expect } from "./fixtures" -import { promptSelector } from "./utils" - -function sessionIDFromUrl(url: string) { - const match = /\/session\/([^/?#]+)/.exec(url) - return match?.[1] -} +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { sessionIDFromUrl } from "../actions" test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { test.setTimeout(120_000) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts new file mode 100644 index 00000000000..9179a6fd570 --- /dev/null +++ b/packages/app/e2e/selectors.ts @@ -0,0 +1,17 @@ +export const promptSelector = '[data-component="prompt-input"]' +export const terminalSelector = '[data-component="terminal"]' + +export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' +export const settingsLanguageSelectSelector = '[data-action="settings-language"]' + +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}"]` + +export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` diff --git a/packages/app/e2e/settings.spec.ts b/packages/app/e2e/settings.spec.ts deleted file mode 100644 index 09dc942cc92..00000000000 --- a/packages/app/e2e/settings.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" - -test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = page.getByRole("dialog") - - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - - const opened = await dialog - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (!opened) { - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(dialog).toBeVisible() - } - - await dialog.getByRole("tab", { name: "Shortcuts" }).click() - await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible() - await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible() - - await page.keyboard.press("Escape") - - const closed = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closed) return - - await page.keyboard.press("Escape") - const closedSecond = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - - if (closedSecond) return - - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(dialog).toHaveCount(0) -}) diff --git a/packages/app/e2e/settings/settings-language.spec.ts b/packages/app/e2e/settings/settings-language.spec.ts new file mode 100644 index 00000000000..b326a7d81ca --- /dev/null +++ b/packages/app/e2e/settings/settings-language.spec.ts @@ -0,0 +1,28 @@ +import { test, expect } from "../fixtures" +import { settingsLanguageSelectSelector } from "../selectors" +import { openSettings } from "../actions" + +test("smoke changing language updates settings labels", async ({ page, gotoSession }) => { + await page.addInitScript(() => { + localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" })) + }) + + await gotoSession() + + const dialog = await openSettings(page) + + const heading = dialog.getByRole("heading", { level: 2 }) + await expect(heading).toHaveText("General") + + const select = dialog.locator(settingsLanguageSelectSelector) + await expect(select).toBeVisible() + await select.locator('[data-slot="select-select-trigger"]').click() + + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click() + + await expect(heading).toHaveText("Allgemein") + + await select.locator('[data-slot="select-select-trigger"]').click() + await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click() + await expect(heading).toHaveText("General") +}) diff --git a/packages/app/e2e/settings-providers.spec.ts b/packages/app/e2e/settings/settings-providers.spec.ts similarity index 50% rename from packages/app/e2e/settings-providers.spec.ts rename to packages/app/e2e/settings/settings-providers.spec.ts index 326a9fad1d2..4b3b178cc59 100644 --- a/packages/app/e2e/settings-providers.spec.ts +++ b/packages/app/e2e/settings/settings-providers.spec.ts @@ -1,22 +1,11 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { closeDialog, openSettings } from "../actions" test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => { await gotoSession() - const dialog = page.getByRole("dialog") - - await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) - - const opened = await dialog - .waitFor({ state: "visible", timeout: 3000 }) - .then(() => true) - .catch(() => false) - - if (!opened) { - await page.getByRole("button", { name: "Settings" }).first().click() - await expect(dialog).toBeVisible() - } + const dialog = await openSettings(page) await dialog.getByRole("tab", { name: "Providers" }).click() await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible() @@ -37,20 +26,5 @@ test("smoke providers settings opens provider selector", async ({ page, gotoSess const stillOpen = await dialog.isVisible().catch(() => false) if (!stillOpen) return - await page.keyboard.press("Escape") - const closed = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (closed) return - - await page.keyboard.press("Escape") - const closedSecond = await dialog - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false) - if (closedSecond) return - - await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } }) - await expect(dialog).toHaveCount(0) + await closeDialog(page, dialog) }) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts new file mode 100644 index 00000000000..55b76707636 --- /dev/null +++ b/packages/app/e2e/settings/settings.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from "../fixtures" +import { closeDialog, openSettings } from "../actions" + +test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + + await dialog.getByRole("tab", { name: "Shortcuts" }).click() + await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible() + await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible() + + await closeDialog(page, dialog) +}) diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts deleted file mode 100644 index 925590f5106..00000000000 --- a/packages/app/e2e/sidebar.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test, expect } from "./fixtures" -import { modKey } from "./utils" - -test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { - await gotoSession() - - const main = page.locator("main") - const closedClass = /xl:border-l/ - const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l")) - - if (isClosed) { - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(closedClass) - } - - await page.keyboard.press(`${modKey}+B`) - await expect(main).toHaveClass(closedClass) - - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(closedClass) -}) diff --git a/packages/app/e2e/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts similarity index 52% rename from packages/app/e2e/sidebar-session-links.spec.ts rename to packages/app/e2e/sidebar/sidebar-session-links.spec.ts index fab64736e26..1c0f4fa71dd 100644 --- a/packages/app/e2e/sidebar-session-links.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts @@ -1,33 +1,8 @@ -import { test, expect } from "./fixtures" -import { modKey, promptSelector } from "./utils" +import { test, expect } from "../fixtures" +import { openSidebar } from "../actions" +import { promptSelector } from "../selectors" -type Locator = { - first: () => Locator - getAttribute: (name: string) => Promise - scrollIntoViewIfNeeded: () => Promise - click: () => Promise -} - -type Page = { - locator: (selector: string) => Locator - keyboard: { - press: (key: string) => Promise - } -} - -type Fixtures = { - page: Page - slug: string - sdk: { - session: { - create: (input: { title: string }) => Promise<{ data?: { id?: string } }> - delete: (input: { sessionID: string }) => Promise - } - } - gotoSession: (sessionID?: string) => Promise -} - -test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => { +test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => { const stamp = Date.now() const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data) @@ -39,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl try { await gotoSession(one.id) - const main = page.locator("main") - const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") - if (collapsed) { - await page.keyboard.press(`${modKey}+B`) - await expect(main).not.toHaveClass(/xl:border-l/) - } + await openSidebar(page) const target = page.locator(`[data-session-id="${two.id}"] a`).first() await expect(target).toBeVisible() diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts new file mode 100644 index 00000000000..6239a04bd79 --- /dev/null +++ b/packages/app/e2e/sidebar/sidebar.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from "../fixtures" +import { openSidebar, toggleSidebar } from "../actions" + +test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => { + await gotoSession() + + await openSidebar(page) + + await toggleSidebar(page) + await expect(page.locator("main")).toHaveClass(/xl:border-l/) + + await toggleSidebar(page) + await expect(page.locator("main")).not.toHaveClass(/xl:border-l/) +}) diff --git a/packages/app/e2e/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts similarity index 83% rename from packages/app/e2e/terminal-init.spec.ts rename to packages/app/e2e/terminal/terminal-init.spec.ts index cfde2d01930..87934b66e38 100644 --- a/packages/app/e2e/terminal-init.spec.ts +++ b/packages/app/e2e/terminal/terminal-init.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { promptSelector, terminalSelector, terminalToggleKey } from "./utils" +import { test, expect } from "../fixtures" +import { promptSelector, terminalSelector } from "../selectors" +import { terminalToggleKey } from "../utils" test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal/terminal.spec.ts similarity index 74% rename from packages/app/e2e/terminal.spec.ts rename to packages/app/e2e/terminal/terminal.spec.ts index fc558b63259..ef88aa34e52 100644 --- a/packages/app/e2e/terminal.spec.ts +++ b/packages/app/e2e/terminal/terminal.spec.ts @@ -1,5 +1,6 @@ -import { test, expect } from "./fixtures" -import { terminalSelector, terminalToggleKey } from "./utils" +import { test, expect } from "../fixtures" +import { terminalSelector } from "../selectors" +import { terminalToggleKey } from "../utils" test("terminal panel can be toggled", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/thinking-level.spec.ts b/packages/app/e2e/thinking-level.spec.ts index 564ef3c1f3c..92200933e5d 100644 --- a/packages/app/e2e/thinking-level.spec.ts +++ b/packages/app/e2e/thinking-level.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures" -import { modelVariantCycleSelector } from "./utils" +import { modelVariantCycleSelector } from "./selectors" test("smoke model variant cycle updates label", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 76438a03cc8..18e88ddc9c7 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "types": ["node"] + "types": ["node", "bun"] }, "include": ["./**/*.ts"] } diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 3de488bd9c2..ec6cdf83023 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -10,10 +10,6 @@ export const serverName = `${serverHost}:${serverPort}` export const modKey = process.platform === "darwin" ? "Meta" : "Control" export const terminalToggleKey = "Control+Backquote" -export const promptSelector = '[data-component="prompt-input"]' -export const terminalSelector = '[data-component="terminal"]' -export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' - export function createSdk(directory?: string) { return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) } diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4f0dcc3ee65..4d96c6c5f66 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,7 +90,7 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element + children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T triggerProps?: ComponentProps }) { @@ -182,12 +182,13 @@ export function ModelSelectorPopover(props: { as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {props.children} + {typeof props.children === "function" ? props.children(store.open) : props.children} setStore("content", el)} - class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" onEscapeKeyDown={(event) => { setStore("dismiss", "escape") setStore("open", false) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c1d417eb08..6e614eb1d7e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -32,7 +32,9 @@ import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { MorphChevron } from "@opencode-ai/ui/morph-chevron" import { Button } from "@opencode-ai/ui/button" +import { CycleLabel } from "@opencode-ai/ui/cycle-label" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import type { IconName } from "@opencode-ai/ui/icons/provider" @@ -42,6 +44,7 @@ import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" @@ -1252,7 +1255,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .shell({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, command: text, @@ -1275,7 +1278,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session.id, + sessionID: session?.id || "", command: commandName, arguments: args.join(" "), agent, @@ -1431,13 +1434,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session.id, + sessionID: session?.id || "", messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session.id, + sessionID: session?.id || "", role: "user", time: { created: Date.now() }, agent, @@ -1448,9 +1451,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id || ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1466,9 +1469,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id || ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1485,7 +1488,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1498,7 +1501,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id || ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1519,15 +1522,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "busy" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1544,7 +1547,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session.id, { abort: controller, cleanup }) + pending.set(session?.id || "", { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1572,7 +1575,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session.id) + pending.delete(session?.id || "") if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1582,7 +1585,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session.id, + sessionID: session?.id || "", agent, model, messageID, @@ -1592,9 +1595,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session.id) - if (sessionDirectory === projectDirectory) { - sync.set("session_status", session.id, { type: "idle" }) + pending.delete(session?.id || "") + if (sessionDirectory === projectDirectory && session?.id) { + sync.set("session_status", session?.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1616,6 +1619,28 @@ export const PromptInput: Component = (props) => { }) } + const currrentModelVariant = createMemo(() => { + const modelVariant = local.model.variant.current() ?? "" + return modelVariant === "xhigh" + ? "xHigh" + : modelVariant.length > 0 + ? modelVariant[0].toUpperCase() + modelVariant.slice(1) + : "Default" + }) + + const reasoningPercentage = createMemo(() => { + const variants = local.model.variant.list() + const current = local.model.variant.current() + const totalEntries = variants.length + 1 + + if (totalEntries <= 2 || current === "Default") { + return 0 + } + + const currentIndex = current ? variants.indexOf(current) + 1 : 0 + return ((currentIndex + 1) / totalEntries) * 100 + }, [local.model.variant]) + return (
@@ -1668,7 +1693,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1729,9 +1754,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1770,7 +1795,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1787,7 +1812,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) @@ -1817,7 +1842,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1891,7 +1916,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1922,12 +1947,17 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1938,11 +1968,15 @@ export const PromptInput: Component = (props) => { keybind={command.keybind("model.choose")} > - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + {(open) => ( + <> + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + )} @@ -1955,10 +1989,13 @@ export const PromptInput: Component = (props) => { @@ -1972,7 +2009,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, + "_hidden group-hover/prompt-input:flex items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -1994,7 +2031,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2036,7 +2074,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2047,7 +2085,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="h-6 w-5.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 1e37d8f6a20..92b060212d6 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -64,8 +64,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { } const circle = () => ( -
- +
+
) @@ -101,7 +101,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {