From 6e39fd92df288a017118007516241745301c5698 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:03:36 +0000 Subject: [PATCH 1/2] fix(app): save session titles on blur and add tab context menu --- .../app/e2e/regression/session-rename.spec.ts | 130 ++++++++++++++++++ .../src/session/timeline/message-timeline.tsx | 4 +- packages/app/src/shell/titlebar/tab-nav.tsx | 73 +++++++--- 3 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 packages/app/e2e/regression/session-rename.spec.ts diff --git a/packages/app/e2e/regression/session-rename.spec.ts b/packages/app/e2e/regression/session-rename.spec.ts new file mode 100644 index 000000000000..63baa5a3b863 --- /dev/null +++ b/packages/app/e2e/regression/session-rename.spec.ts @@ -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() +}) diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 67b5a22d7378..1933d9268257 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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()} /> diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index d6bb9305d820..42e16c5d6626 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -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 = () => (