mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 06:34:36 +00:00
fix(app): save session titles on blur and add tab context menu (#46113)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
parent
5ec29e7a87
commit
174d263890
4 changed files with 186 additions and 23 deletions
130
packages/app/e2e/regression/session-rename.spec.ts
Normal file
130
packages/app/e2e/regression/session-rename.spec.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route("**/api/session/*/rename", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const id = new URL(route.request().url()).pathname.split("/").at(-2)
|
||||
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({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
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.locator('[data-component="composer-editor"]').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("**/api/session/*/rename", (route) =>
|
||||
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()
|
||||
})
|
||||
|
|
@ -141,7 +141,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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@ function MessageTimelineView(
|
|||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
if (!title.editing || props.pending.rename()) return
|
||||
if (await props.action.rename(title.draft)) setTitle("editing", false)
|
||||
}
|
||||
|
||||
|
|
@ -634,6 +635,7 @@ function MessageTimelineView(
|
|||
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()
|
||||
|
|
@ -644,7 +646,7 @@ function MessageTimelineView(
|
|||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
onBlur={() => void saveTitleEditor()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -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 { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
|
|
@ -34,6 +36,8 @@ export function TabNavItem(props: {
|
|||
hidden?: boolean
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [menu, setMenu] = createStore({ open: false, rename: false })
|
||||
const [editing, setEditing] = createSignal(false)
|
||||
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
|
||||
let tabRoot!: HTMLDivElement
|
||||
|
|
@ -77,7 +81,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()) {
|
||||
|
|
@ -141,9 +145,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
|
||||
|
|
@ -174,7 +178,7 @@ export function TabNavItem(props: {
|
|||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
const tab = (
|
||||
const tab = () => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
|
|
@ -200,7 +204,11 @@ export function TabNavItem(props: {
|
|||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<a
|
||||
<Menu.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}
|
||||
|
|
@ -288,7 +296,7 @@ export function TabNavItem(props: {
|
|||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</a>
|
||||
</Menu.Context.Trigger>
|
||||
|
||||
<div data-slot="tab-close">
|
||||
<IconButton
|
||||
|
|
@ -301,27 +309,50 @@ export function TabNavItem(props: {
|
|||
}}
|
||||
onClick={closeTab}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPreviewPopover
|
||||
trigger={tab}
|
||||
orientation={props.orientation}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
<Menu.Context
|
||||
onOpenChange={(open) => {
|
||||
setMenu("open", open)
|
||||
if (open) setPopoverOpen(false)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<TabPreviewPopover
|
||||
trigger={tab()}
|
||||
orientation={props.orientation}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!menu.rename) return
|
||||
event.preventDefault()
|
||||
setMenu("rename", false)
|
||||
openRename()
|
||||
}}
|
||||
>
|
||||
<Menu.Item disabled={!props.session || rename.isPending} onSelect={() => setMenu("rename", true)}>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={props.onClose}>{language.t("common.closeTab")}</Menu.Item>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue