fix(app): backport session rename and tab menu fixes to v1 (#46116)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
parent
10765ff2a9
commit
9f69463f1d
4 changed files with 196 additions and 22 deletions
141
packages/app/e2e/regression/session-rename.spec.ts
Normal file
141
packages/app/e2e/regression/session-rename.spec.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v1",
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "PATCH") return route.fallback()
|
||||
const id = new URL(route.request().url()).pathname.split("/").at(-1)
|
||||
const session = sessions.find((item) => item.id === id)
|
||||
const payload: unknown = route.request().postDataJSON()
|
||||
if (
|
||||
!session ||
|
||||
!payload ||
|
||||
typeof payload !== "object" ||
|
||||
!("title" in payload) ||
|
||||
typeof payload.title !== "string"
|
||||
)
|
||||
throw new Error("Invalid rename request")
|
||||
session.title = payload.title
|
||||
await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
await page.goto("/")
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const commit of ["Enter", "blur", "click outside"]) {
|
||||
test(`saves the session heading on ${commit}`, async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed session")
|
||||
if (commit === "Enter") await input.press("Enter")
|
||||
if (commit === "blur") await input.press("Tab")
|
||||
if (commit === "click outside") await page.getByRole("textbox", { name: "Prompt", exact: true }).click()
|
||||
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("cancels the session heading with Escape", async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill("Discard this title")
|
||||
await input.press("Escape")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps the draft when saving the session heading fails", async ({ page }) => {
|
||||
await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => {
|
||||
if (route.request().method() !== "PATCH") return route.fallback()
|
||||
return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill("Retry this title")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(input).toBeEnabled()
|
||||
await expect(input).toHaveValue("Retry this title")
|
||||
await expect(
|
||||
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("does not save an empty session heading", async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill(" ")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames and closes the session tab from its context menu", async ({ page }) => {
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
|
||||
await expect(tab).toBeFocused()
|
||||
await tab.press("Shift+F10")
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed from tab")
|
||||
await input.press("Enter")
|
||||
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
||||
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
|
||||
await renamed.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
|
||||
await expect(renamed).toBeHidden()
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(
|
||||
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames an inactive tab without switching sessions", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Inactive tab renamed")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
||||
})
|
||||
|
|
@ -39,7 +39,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
|||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
|
|
@ -33,6 +35,8 @@ export function TabNavItem(props: {
|
|||
pressed?: boolean
|
||||
hidden?: boolean
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [menu, setMenu] = createStore({ open: false, rename: false })
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||
let tabRoot!: HTMLDivElement
|
||||
|
|
@ -76,7 +80,7 @@ export function TabNavItem(props: {
|
|||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||
const previewBlocked = () => !!props.dragging || editing() || menu.open || !!props.pressed || !props.session()
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
|
|
@ -138,9 +142,9 @@ export function TabNavItem(props: {
|
|||
titleEl.textContent = value
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const openRename = (event?: MouseEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
|
|
@ -171,7 +175,7 @@ export function TabNavItem(props: {
|
|||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
const tab = (
|
||||
const tab = () => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
|
|
@ -196,7 +200,11 @@ export function TabNavItem(props: {
|
|||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
<MenuV2.Context.Trigger
|
||||
as="a"
|
||||
disabled={editing() || props.dragging}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.open}
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
|
|
@ -276,7 +284,7 @@ export function TabNavItem(props: {
|
|||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</MenuV2.Context.Trigger>
|
||||
|
||||
<div data-slot="tab-close">
|
||||
<IconButtonV2
|
||||
|
|
@ -289,26 +297,49 @@ export function TabNavItem(props: {
|
|||
}}
|
||||
onClick={closeTab}
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPreviewPopover
|
||||
trigger={tab}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
<MenuV2.Context
|
||||
onOpenChange={(open) => {
|
||||
setMenu("open", open)
|
||||
if (open) setPopoverOpen(false)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<TabPreviewPopover
|
||||
trigger={tab()}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
<MenuV2.Context.Portal>
|
||||
<MenuV2.Context.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!menu.rename) return
|
||||
event.preventDefault()
|
||||
setMenu("rename", false)
|
||||
openRename()
|
||||
}}
|
||||
>
|
||||
<MenuV2.Item disabled={!props.session() || rename.isPending} onSelect={() => setMenu("rename", true)}>
|
||||
{language.t("common.rename")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={props.onClose}>{language.t("common.closeTab")}</MenuV2.Item>
|
||||
</MenuV2.Context.Content>
|
||||
</MenuV2.Context.Portal>
|
||||
</MenuV2.Context>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -777,6 +777,7 @@ export function MessageTimeline(props: {
|
|||
}
|
||||
|
||||
const saveTitleEditor = () => {
|
||||
if (!title.editing) return
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (titleMutation.isPending) return
|
||||
|
|
@ -1447,6 +1448,7 @@ export function MessageTimeline(props: {
|
|||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
|
|
@ -1457,7 +1459,7 @@ export function MessageTimeline(props: {
|
|||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
onBlur={saveTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
Loading…
Reference in a new issue