diff --git a/packages/app/e2e/regression/home-session-rename.spec.ts b/packages/app/e2e/regression/home-session-rename.spec.ts new file mode 100644 index 000000000000..d42dd0169133 --- /dev/null +++ b/packages/app/e2e/regression/home-session-rename.spec.ts @@ -0,0 +1,185 @@ +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", "Tab"]) { + test(`renames a home session without opening it on ${commit}`, async ({ page }) => { + await page.getByRole("button", { name: "Home", exact: true }).click() + const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }) + const tabs = page.locator('[data-slot="titlebar-tabs"] a') + await expect(tabs.filter({ hasText: fixture.expected.sourceTitle })).toHaveCount(0) + await row.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.getByRole("textbox", { name: "Rename", exact: true }) + await expect(input).toBeFocused() + await expect(input).toHaveValue(fixture.expected.sourceTitle) + await input.fill(" Renamed from home ") + await input.press(commit) + const renamed = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from home" }) + await expect(renamed).toBeVisible() + await expect(page).toHaveURL("/") + await expect(tabs.filter({ hasText: "Renamed from home" })).toHaveCount(0) + await page.reload() + await expect(renamed).toBeVisible() + await renamed.click() + await expect(page.getByRole("heading", { name: "Renamed from home", exact: true })).toBeVisible() + }) +} + +test("renames a home session using the keyboard and updates its open tab", async ({ page }) => { + await page.getByRole("button", { name: "Home", exact: true }).click() + const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }) + await row.click({ button: "right" }) + await page.keyboard.press("Escape") + await expect(row).toBeFocused() + await row.press("Shift+F10") + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.getByRole("textbox", { name: "Rename", exact: true }) + await expect(input).toBeFocused() + await input.fill("Home and tab renamed") + await input.press("Enter") + await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Home and tab renamed" })).toBeVisible() + await expect( + page.locator('[data-component="home-session-row"]').filter({ hasText: "Home and tab renamed" }), + ).toBeVisible() + await expect(page).toHaveURL("/") +}) + +test("cancels home renaming and ignores blank or unchanged titles", async ({ page }) => { + const requests: string[] = [] + page.on("request", (request) => { + if (request.method() === "PATCH" && /\/session\/[^/]+(?:\?.*)?$/.test(request.url())) requests.push(request.url()) + }) + await page.getByRole("button", { name: "Home", exact: true }).click() + const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }) + for (const [title, key] of [ + ["Discard this title", "Escape"], + [" ", "Enter"], + [fixture.expected.sourceTitle, "Tab"], + ]) { + await row.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.getByRole("textbox", { name: "Rename", exact: true }) + await expect(input).toBeFocused() + await input.fill(title) + await input.press(key) + await expect(input).toBeHidden() + await expect(row).toBeVisible() + } + await page.reload() + await expect(row).toBeVisible() + expect(requests).toEqual([]) +}) + +test("keeps a home rename draft after failure and allows retry", 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": "*" } }) + }, + { times: 1 }, + ) + await page.getByRole("button", { name: "Home", exact: true }).click() + await page + .locator('[data-component="home-session-row"]') + .filter({ hasText: fixture.expected.targetTitle }) + .click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.getByRole("textbox", { name: "Rename", exact: true }) + await expect(input).toBeFocused() + await input.fill("Retry home rename") + await input.press("Enter") + await expect(page.getByText("Request failed", { exact: true })).toBeVisible() + await expect(input).toHaveValue("Retry home rename") + await expect(input).toBeEditable() + await expect( + page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }), + ).toBeVisible() + await input.press("Enter") + await expect( + page.locator('[data-component="home-session-row"]').filter({ hasText: "Retry home rename" }), + ).toBeVisible() + await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Retry home rename" })).toBeVisible() +}) + +test("does not submit a home rename twice while saving", async ({ page }) => { + const pending = Promise.withResolvers() + const requests: string[] = [] + await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => { + if (route.request().method() !== "PATCH") return route.fallback() + requests.push(route.request().url()) + await pending.promise + await route.fallback() + }) + await page.getByRole("button", { name: "Home", exact: true }).click() + await page + .locator('[data-component="home-session-row"]') + .filter({ hasText: fixture.expected.sourceTitle }) + .click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + const input = page.getByRole("textbox", { name: "Rename", exact: true }) + await expect(input).toBeFocused() + await input.fill("Delayed rename") + await input.press("Enter") + await expect(input).toHaveAttribute("aria-busy", "true") + await expect(input).not.toBeEditable() + await input.press("Enter") + await input.press("Tab") + pending.resolve() + await expect(page.locator('[data-component="home-session-row"]').filter({ hasText: "Delayed rename" })).toBeVisible() + expect(requests).toHaveLength(1) +}) + +test("still opens home sessions in the background with middle click", 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({ button: "middle" }) + await expect( + page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.sourceTitle }), + ).toBeVisible() + await expect(page).toHaveURL("/") + await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden() +}) diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index f306f208cc80..1a41f588041b 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -1,7 +1,7 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useQuery } from "@tanstack/solid-query" +import { useQuery, useQueryClient } from "@tanstack/solid-query" import { DateTime } from "luxon" import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" import { produce } from "solid-js/store" @@ -43,6 +43,7 @@ export function createHomeSessionsController(home: HomeController) { const command = useCommand() const dialog = useDialog() const language = useLanguage() + const queryClient = useQueryClient() const projectDirectories = createMemo(() => { const project = home.project.selected() if (!project) return home.project.list().flatMap(directories) @@ -204,6 +205,23 @@ export function createHomeSessionsController(home: HomeController) { tabs.select(tab) }) }, + rename: async (session: Session, title: string) => { + const ctx = home.server.focusedContext() + if (!ctx) return false + try { + await ctx.sdk.api.session.rename({ sessionID: session.id, directory: session.directory, title }) + const info = { ...(ctx.sync.session.get(session.id) ?? session), title } + ctx.sync.session.remember(info) + await queryClient.invalidateQueries({ queryKey: ctx.sync.homeSessions.indexKey, exact: true }) + return true + } catch (cause) { + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }) + return false + } + }, archive: async (session: Session) => { const conn = home.server.focused() const ctx = home.server.focusedContext() diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index 72f2f37616ae..0d9e42a9aa0b 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -1,10 +1,13 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { type Accessor, createMemo, For, Show, Suspense } from "solid-js" +import { createStore } from "solid-js/store" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { Spinner } from "@opencode-ai/ui/spinner" import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" @@ -53,6 +56,7 @@ export type HomeSessionsViewProps = { isOpenTab: (record: HomeSessionRecord) => boolean onCreateSession: () => void onOpenSession: (session: Session, options?: OpenSessionOptions) => void + onRenameSession: (session: Session, title: string) => Promise onArchiveSession: (session: Session) => Promise onSetHoverTarget: (element: HTMLElement) => void onSetThumbTrack: (element: HTMLDivElement) => void @@ -122,22 +126,30 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { } >
- - {(group, index) => ( - <> - props.onSetHeader(group.id, element)} - elevated={index() === 0} - /> -
- {(record) => } -
- - )} + group.id)}> + {(id, index) => { + const group = createMemo(() => props.groups().find((group) => group.id === id)!) + const records = createMemo( + () => new Map(group().sessions.map((record) => [record.session.id, record])), + ) + return ( + <> + props.onSetHeader(id, element)} + elevated={index() === 0} + /> +
+ + {(id) => } + +
+ + ) + }}
@@ -417,66 +429,138 @@ function HomeSessionGroupHeader(props: { function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const showProjectName = () => props.showProjectName() && props.record.projectName + const [state, setState] = createStore({ open: false, rename: false, editing: false, draft: "", pending: false }) + let input: HTMLInputElement | undefined + let trigger: HTMLButtonElement | undefined + + const closeRename = () => { + setState("editing", false) + trigger?.focus() + } + + const save = async () => { + if (!state.editing || state.pending) return + const next = state.draft.trim() + if (!next || next === props.record.session.title) { + closeRename() + return + } + setState("pending", true) + const saved = await props.onRenameSession(props.record.session, next) + setState("pending", false) + if (saved) closeRename() + } return ( -
- - -
{ + if (event.button === 1) event.preventDefault() + }} + onClick={(event: MouseEvent) => + props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) }) + } + onAuxClick={(event: MouseEvent) => { + if (!isBackgroundOpen(event)) return + event.preventDefault() + props.onOpenSession(props.record.session, { background: true }) + }} + > + + + + + + +
+ + } + aria-label={props.language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.onArchiveSession(props.record.session) + }} + /> + +
+
+
+ + { + if (!state.rename) return + event.preventDefault() + setState({ rename: false, editing: true, draft: props.record.session.title }) + requestAnimationFrame(() => { + input?.focus() + input?.select() + }) + }} > - - } - aria-label={props.language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.onArchiveSession(props.record.session) - }} - /> - -
- - + setState("rename", true)}>{props.language.t("common.rename")} + + + ) } diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx index 7bda4dd3630f..2f5e9c1fa070 100644 --- a/packages/app/src/pages/home/home-sessions.tsx +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -26,6 +26,7 @@ export function HomeSessions(props: { isOpenTab={props.sessions.tab.isOpen} onCreateSession={props.sessions.session.create} onOpenSession={props.sessions.session.open} + onRenameSession={props.sessions.session.rename} onArchiveSession={props.sessions.session.archive} onSetHoverTarget={props.scroll.viewport.setHoverTarget} onSetThumbTrack={props.scroll.viewport.setThumbTrack}