From 095ed63ea0f6782427a03b4980d468f9cc70dc76 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:20:41 +1000 Subject: [PATCH] fix(app): keep workspace submit buttons neutral (#46052) Co-authored-by: Hona <10430890+Hona@users.noreply.github.com> --- .../e2e/regression/workspace-accent.spec.ts | 233 +++++++++++------- packages/app/src/composer/composer.tsx | 8 +- packages/app/src/composer/editor/editor.tsx | 13 +- packages/app/src/new-session/view.tsx | 2 +- packages/app/src/session/composer/region.tsx | 3 +- packages/app/src/session/screen.tsx | 7 +- 6 files changed, 149 insertions(+), 117 deletions(-) diff --git a/packages/app/e2e/regression/workspace-accent.spec.ts b/packages/app/e2e/regression/workspace-accent.spec.ts index 89d35ec82cb..5394cc4a911 100644 --- a/packages/app/e2e/regression/workspace-accent.spec.ts +++ b/packages/app/e2e/regression/workspace-accent.spec.ts @@ -20,95 +20,134 @@ const inventory: WorktreeDirectory[] = [ test.use({ serviceWorkers: "block" }) -for (const scenario of [ - { name: "managed Git worktree", directory: workspace, accent: true }, - { name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true }, - { - name: "linked Git worktree on a narrow screen", - directory: "C:/OpenCode/LinkedWorkspace", - accent: true, - viewport: { width: 390, height: 844 }, - }, - { name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false }, - { name: "nested main directory", directory: `${root}/packages/app`, accent: false }, - { name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true }, - { - name: "workspace with Windows case and separators", - directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\", - accent: true, - }, - { name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false }, - { name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true }, - { name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true }, -]) { - test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => { - if (scenario.viewport) await page.setViewportSize(scenario.viewport) - const view = await openSession(page, scenario.directory) - await view.input.fill("Inspect this fixture workspace.") - await expect(view.send).toBeEnabled() +for (const theme of ["light", "dark"] as const) { + test.describe(theme, () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript((theme) => { + localStorage.setItem("opencode-theme-id", "oc-2") + localStorage.setItem("opencode-color-scheme", theme) + }, theme) + }) - if (scenario.name === "managed Git worktree") { - // Capture before the color assertion so both red and green runs have evidence. - const path = testInfo.outputPath("workspace-accent.png") - await view.composer.screenshot({ path }) - await testInfo.attach("workspace-accent", { path, contentType: "image/png" }) + for (const scenario of [ + { name: "managed Git worktree", directory: workspace, accent: true }, + { name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true }, + { + name: "linked Git worktree on a narrow screen", + directory: "C:/OpenCode/LinkedWorkspace", + accent: true, + viewport: { width: 390, height: 844 }, + }, + { + name: "main root with Windows case and separators", + directory: "c:\\OPENCODE\\workspaceaccent\\", + accent: false, + }, + { name: "nested main directory", directory: `${root}/packages/app`, accent: false }, + { name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true }, + { + name: "workspace with Windows case and separators", + directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\", + accent: true, + }, + { name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false }, + { name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true }, + { name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true }, + ]) { + test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => { + if (scenario.viewport) await page.setViewportSize(scenario.viewport) + const view = await openSession(page, scenario.directory) + await view.input.fill("Inspect this fixture workspace.") + await expect(view.send).toBeEnabled() + + if (scenario.name === "managed Git worktree") { + // Capture before the color assertion so both red and green runs have evidence. + const path = testInfo.outputPath("workspace-accent.png") + await view.composer.screenshot({ path }) + await testInfo.attach("workspace-accent", { path, contentType: "image/png" }) + } + + await expectBackground(view.send, "contrast") + await view.send.hover() + await expectBackground(view.send, "contrast") + await view.composer.locator('[data-action="composer-model"]').press("Tab") + await expect(view.send).toBeFocused() + await expectBackground(view.send, "contrast") + const message = page.locator('[data-slot="user-message-text"]') + await expect(message).toHaveText("Check this fixture workspace.") + await expectBackground( + message, + scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01", + "background-color", + ) + }) } - await expectBackground(view.send, scenario.accent ? "accent" : "contrast") - const message = page.locator('[data-slot="user-message-text"]') - await expect(message).toHaveText("Check this fixture workspace.") - await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color") + test("inventory updates leave send neutral; disabled and stop stay neutral", async ({ page }) => { + const view = await openSession(page, workspace, [{ directory: root }]) + await view.input.fill("Keep this draft while the inventory changes.") + await expect(view.send).toBeEnabled() + await expectBackground(view.send, "contrast") + const url = page.url() + + const refreshed = page.waitForResponse( + (response) => + new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET", + ) + view.worktrees.push({ directory: workspace, strategy: "git" }) + view.events.push({ + id: "evt_workspace_accent_inventory", + created: 1700000001000, + type: "worktree.updated", + data: { projectID }, + }) + expect((await refreshed).ok()).toBe(true) + await expectBackground(view.send, "contrast") + await expect(page).toHaveURL(url) + await expect(view.input).toHaveText("Keep this draft while the inventory changes.") + await expect(view.send).toBeEnabled() + + await view.input.fill("") + await expect(view.send).toBeDisabled() + await expectBackground(view.send, "contrast") + + view.events.push({ + id: "evt_workspace_accent_running", + created: 1700000002000, + type: "session.execution.started", + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + data: { sessionID }, + }) + const stop = view.composer.getByRole("button", { name: "Stop", exact: true }) + await expect(stop).toBeEnabled() + await expectBackground(stop, "contrast") + + await view.input.fill("Send a follow-up instead of stopping.") + await expect(view.send).toBeEnabled() + await expectBackground(view.send, "contrast") + await expect(page).toHaveURL(url) + }) + + test("new workspace send button stays neutral", async ({ page }) => { + const view = await openSession(page, root, [...inventory], true) + await expect(view.send).toBeDisabled() + await expectBackground(view.send, "contrast") + await page.getByRole("button", { name: "Local", exact: true }).click() + await page.getByRole("menuitem", { name: "New workspace", exact: true }).click() + await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible() + await view.input.fill("Inspect this fixture workspace.") + await expect(view.send).toBeEnabled() + await expectBackground(view.send, "contrast") + await view.send.hover() + await expectBackground(view.send, "contrast") + await view.composer.locator('[data-action="composer-model"]').press("Tab") + await expect(view.send).toBeFocused() + await expectBackground(view.send, "contrast") + }) }) } -test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({ - page, -}) => { - const view = await openSession(page, workspace, [{ directory: root }]) - await view.input.fill("Keep this draft while the inventory changes.") - await expect(view.send).toBeEnabled() - await expectBackground(view.send, "contrast") - const url = page.url() - - const refreshed = page.waitForResponse( - (response) => - new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET", - ) - view.worktrees.push({ directory: workspace, strategy: "git" }) - view.events.push({ - id: "evt_workspace_accent_inventory", - created: 1700000001000, - type: "worktree.updated", - data: { projectID }, - }) - expect((await refreshed).ok()).toBe(true) - await expectBackground(view.send, "accent") - await expect(page).toHaveURL(url) - await expect(view.input).toHaveText("Keep this draft while the inventory changes.") - await expect(view.send).toBeEnabled() - - await view.input.fill("") - await expect(view.send).toBeDisabled() - await expectBackground(view.send, "contrast") - - view.events.push({ - id: "evt_workspace_accent_running", - created: 1700000002000, - type: "session.execution.started", - durable: { aggregateID: sessionID, seq: 1, version: 1 }, - data: { sessionID }, - }) - const stop = view.composer.getByRole("button", { name: "Stop", exact: true }) - await expect(stop).toBeEnabled() - await expectBackground(stop, "contrast") - - await view.input.fill("Send a follow-up instead of stopping.") - await expect(view.send).toBeEnabled() - await expectBackground(view.send, "accent") - await expect(page).toHaveURL(url) -}) - -async function openSession(page: Page, directory: string, worktrees = [...inventory]) { +async function openSession(page: Page, directory: string, worktrees = [...inventory], draft = false) { const events: OpenCodeEvent[] = [] await mockOpenCodeServer(page, { directory, @@ -159,18 +198,32 @@ async function openSession(page: Page, directory: string, worktrees = [...invent if (route.request().method() !== "GET") return route.fallback() return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } }) }) - await page.addInitScript(() => { - localStorage.setItem("opencode-theme-id", "oc-2") - localStorage.setItem("opencode-color-scheme", "light") - }) + if (draft) + await page.addInitScript( + ({ root, server }) => { + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: root, expanded: true }] }, + lastProject: { local: root }, + }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "draft", draftID: "draft_workspace_accent", server, directory: root }]), + ) + }, + { root, server }, + ) const loaded = page.waitForResponse( (response) => new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET", ) - await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await page.goto( + draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`, + ) expect((await loaded).ok()).toBe(true) - await expectSessionReady(page, { server, sessionID, title }) - await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light") + if (!draft) await expectSessionReady(page, { server, sessionID, title }) const composer = page.locator('[data-component="composer"]') await expectAppVisible(composer) const input = composer.getByRole("textbox", { name: "Prompt", exact: true }) diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index 29bd69020e8..0526d870448 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -12,12 +12,7 @@ import { formatKeybind, useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import type { ComposerModel } from "./model" -export function Composer(props: { - class?: string - model: ComposerModel - borderUnderlay?: boolean - accentSubmit?: boolean -}) { +export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) { const dialog = useDialog() const command = useCommand() const language = useLanguage() @@ -26,7 +21,6 @@ export function Composer(props: {