feat(desktop): add home session actions (#43245)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
usrnk1 2026-08-24 05:18:03 +02:00 committed by GitHub
parent 4876f416cc
commit 3f807d8836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 561 additions and 69 deletions

View file

@ -71,6 +71,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
@ -6499,8 +6500,6 @@
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],

View file

@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("renames, exports, and deletes a home session from its context menu", 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) => {
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
const session = sessions.find((item) => item.id === sessionID)
const payload: unknown = route.request().postDataJSON()
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
throw new Error("Invalid rename payload")
if (session) session.title = payload.title
await route.fulfill({ status: 204, 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("/")
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
await expect(row).toBeVisible()
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
await row.focus()
await row.press("Shift+F10")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
await expect(row).toBeFocused()
const rowBox = await row.boundingBox()
await row.click({ button: "right", position: { x: 48, y: 12 } })
await expect(page).toHaveURL("/")
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
await page.getByRole("menuitem", { name: "Rename" }).click()
const title = page.locator('[data-component="home-session-rename"]')
await expect(title).toBeFocused()
await expect(title).toHaveValue(fixture.expected.targetTitle)
const editorBox = await title.boundingBox()
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
expect(editorBox?.x).toBe(titleBox?.x)
expect(editingAvatarBox).toEqual(avatarBox)
expect(
await title.evaluate((element) => ({
outline: getComputedStyle(element).outlineStyle,
shadow: getComputedStyle(element).boxShadow,
})),
).toEqual({ outline: "none", shadow: "none" })
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
await title.fill("Renamed from Home")
const renamed = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
)
await title.press("Enter")
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click()
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(page).toHaveURL("/")
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
await expect(renamedRow).toBeVisible()
await renamedRow.click({ button: "right" })
const download = page.waitForEvent("download")
const exportItem = page.getByRole("menuitem", { name: "Export..." })
await exportItem.click()
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
await expect(exportItem).toBeHidden()
await renamedRow.click({ button: "right" })
await page.getByRole("menuitem", { name: "Delete..." }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
)
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
})

View file

@ -52,12 +52,12 @@
"vite-plugin-solid": "2.11.14"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@ -69,6 +69,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/keyed": "1.5.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",

View file

@ -1,8 +1,12 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog"
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
@ -11,7 +15,10 @@ import { ServerConnection } from "@/runtime/server/registry"
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
@ -35,6 +42,8 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@ -59,9 +68,13 @@ export function createHomeSessionsController(home: HomeController) {
})
const indexedSessions = createMemo(() => {
const ctx = home.server.focusedContext()
if (!ctx) return []
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
),
HOME_SESSION_LIMIT,
Date.now(),
)
@ -123,6 +136,119 @@ export function createHomeSessionsController(home: HomeController) {
},
])
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
// record so date grouping and ordering do not go stale.
ctx.data.session.invalidate(session.id)
void ctx.data.session.sync(session.id).catch(() => {})
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
)
return true
})
.catch((cause) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
return false
})
}
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!ctx) return
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (cause) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
})
}
}
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
sessionIDs: ids,
})
return true
})
.catch((cause) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(cause, language.t("session.delete.failed.title")),
})
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
const confirm = async () => {
await remove(props.server, props.session)
dialog.close()
}
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="danger" onClick={confirm}>
{language.t("session.delete.button")}
</Button>
</DialogFooter>
</Dialog>
)
}
return {
copy: {
language,
@ -193,6 +319,10 @@ export function createHomeSessionsController(home: HomeController) {
}),
})
},
rename,
export: exportSession,
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) =>

View file

@ -27,6 +27,9 @@ export function HomeSessions(props: {
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}

View file

@ -1,10 +1,14 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo, For, Show, Suspense } from "solid-js"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createStore, type SetStoreFunction } 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 { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
@ -22,6 +26,7 @@ import {
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
const HOME_SESSION_LONG_PRESS_MS = 500
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
// tab in the background without navigating, matching browser conventions.
@ -54,6 +59,9 @@ export type HomeSessionsViewProps = {
onCreateSession: () => void
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
onArchiveSession: (session: SessionInfo) => Promise<void>
onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise<boolean>
onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise<void>
onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void
onSetHoverTarget: (element: HTMLElement) => void
onSetThumbTrack: (element: HTMLDivElement) => void
onSetContent: (element: HTMLDivElement) => void
@ -71,7 +79,16 @@ export type HomeSessionsViewProps = {
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
}
// Session store updates recreate row components, so row-local state would
// close an open context menu or drop an in-progress rename. Keep both keyed
// by session ID at the view root, like the projects list does.
type HomeSessionRowUI = {
menu: { id: string; x: number; y: number } | undefined
editor: { id: string; draft: string; renaming: boolean } | undefined
}
export function HomeSessionsView(props: HomeSessionsViewProps) {
const [rowUI, setRowUI] = createStore<HomeSessionRowUI>({ menu: undefined, editor: undefined })
return (
<section
ref={props.onSetHoverTarget}
@ -122,23 +139,29 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
}
>
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
<For each={props.groups}>
{/* Index keeps group subtrees mounted when the group arrays are
rebuilt, so store updates cannot recreate rows mid-gesture. */}
<Index each={props.groups}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={props.titleOpacity(group.id)}
onSetRef={(element) => props.onSetHeader(group.id, element)}
elevated={index() === 0}
title={group().title}
titleOpacity={props.titleOpacity(group().id)}
onSetRef={(element) => props.onSetHeader(group().id, element)}
elevated={index === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
{/* Rows key by session ID: session.sync replaces the
stored session object wholesale, so reference-keyed
rows would be disposed mid-interaction whenever a
sync response lands. */}
<Key each={group().sessions} by={(record) => record.session.id}>
{(record) => <HomeSessionRow {...props} record={record()} rowUI={rowUI} setRowUI={setRowUI} />}
</Key>
</div>
</>
)}
</For>
</Index>
</div>
</Show>
</Suspense>
@ -412,45 +435,263 @@ function HomeSessionGroupHeader(props: {
)
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
function HomeSessionRow(
props: HomeSessionsViewProps & {
record: HomeSessionRecord
rowUI: HomeSessionRowUI
setRowUI: SetStoreFunction<HomeSessionRowUI>
},
) {
const title = createMemo(() => sessionLabel(props.record.session))
const showProjectName = () => props.showProjectName && props.record.projectName
const sessionID = () => props.record.session.id
const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined)
const editor = () => (props.rowUI.editor?.id === sessionID() ? props.rowUI.editor : undefined)
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let longPressStart: { x: number; y: number } | undefined
let suppressClick = false
let menuInteractedOutside = false
// Focus targets are looked up by session ID: session store updates recreate
// row components, so instance refs can point at detached nodes by the time
// deferred focus runs.
const rowSelector = () => `[data-component="home-session-row-container"][data-session-id="${sessionID()}"]`
const rowButton = () =>
document.querySelector<HTMLButtonElement>(`${rowSelector()} [data-component="home-session-row"]`)
const renameInput = () =>
document.querySelector<HTMLInputElement>(`${rowSelector()} [data-component="home-session-rename"]`)
const clearLongPress = () => {
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
longPressTimer = undefined
longPressStart = undefined
}
onCleanup(clearLongPress)
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
const bounds = element.getBoundingClientRect()
props.setRowUI("menu", { id: sessionID(), x: clientX - bounds.left, y: clientY - bounds.top })
}
const openEditor = () => {
props.setRowUI("editor", { id: sessionID(), draft: title(), renaming: false })
requestAnimationFrame(() => {
const input = renameInput()
input?.focus()
input?.select()
})
}
const closeEditor = () => {
if (editor()?.renaming) return
props.setRowUI("editor", (value) => (value?.id === sessionID() ? undefined : value))
}
const saveEditor = async () => {
const current = editor()
if (!current || current.renaming) return
props.setRowUI("editor", { ...current, renaming: true })
const saved = await props.onRenameSession(props.server, props.record.session, current.draft)
// Disabling the input during the request drops focus to the body; restore
// it unless the user focused another control while the rename was pending.
const restore = document.activeElement === document.body || document.activeElement === renameInput()
props.setRowUI("editor", (value) => {
if (value?.id !== sessionID()) return value
return saved ? undefined : { ...value, renaming: false }
})
if (!restore) return
requestAnimationFrame(() => {
if (saved) {
rowButton()?.focus()
return
}
renameInput()?.focus()
})
}
return (
<div
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
data-component="home-session-row-container"
data-session-id={props.record.session.id}
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
classList={{ group: !!showProjectName() }}
onContextMenu={(event) => {
// While renaming, keep the native menu so paste and spelling work.
if (editor()) return
event.preventDefault()
openMenu(event.currentTarget, event.clientX, event.clientY)
}}
>
<button
type="button"
data-component="home-session-row"
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
<Show
when={!editor()}
fallback={
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={false}
/>
<InlineInput
data-component="home-session-rename"
aria-label={props.language.t("common.rename")}
dir="auto"
value={editor()?.draft ?? ""}
disabled={editor()?.renaming ?? false}
class={`
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
`}
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
onInput={(event) => {
const draft = event.currentTarget.value
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
}}
onKeyDown={(event) => {
event.stopPropagation()
// Enter and Escape during IME composition commit or cancel
// the composition, not the rename. Safari can report the
// composition-confirming keydown with isComposing false but
// keyCode 229.
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveEditor()
return
}
if (event.key !== "Escape") return
event.preventDefault()
closeEditor()
requestAnimationFrame(() => rowButton()?.focus())
}}
onBlur={closeEditor}
/>
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</div>
}
>
<button
type="button"
data-component="home-session-row"
aria-haspopup="menu"
aria-expanded={!!menu()}
class={`
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
`}
onMouseDown={(event) => {
if (event.button === 1) event.preventDefault()
}}
onPointerDown={(event) => {
suppressClick = false
if (event.pointerType !== "touch") return
clearLongPress()
const element = event.currentTarget
const x = event.clientX
const y = event.clientY
longPressStart = { x, y }
longPressTimer = setTimeout(() => {
suppressClick = true
clearLongPress()
openMenu(element, x, y)
}, HOME_SESSION_LONG_PRESS_MS)
}}
onPointerMove={(event) => {
if (!longPressStart) return
if (Math.abs(event.clientX - longPressStart.x) <= 8 && Math.abs(event.clientY - longPressStart.y) <= 8)
return
clearLongPress()
}}
onPointerUp={clearLongPress}
onPointerCancel={() => {
clearLongPress()
suppressClick = false
}}
onKeyDown={(event) => {
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
event.preventDefault()
const bounds = event.currentTarget.getBoundingClientRect()
openMenu(event.currentTarget, bounds.left + 12, bounds.bottom)
}}
onClick={(event) => {
// The flag stays set until the long-press compatibility click
// arrives, however delayed; keyboard activation (detail 0) is
// never that click and passes through.
if (suppressClick) {
suppressClick = false
if (event.detail !== 0) {
event.preventDefault()
return
}
}
props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })
}}
onAuxClick={(event) => {
if (!isBackgroundOpen(event)) return
event.preventDefault()
props.onOpenSession(props.record.session, { background: true })
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
</Show>
<Menu
modal={false}
placement="bottom-start"
gutter={2}
open={!!menu()}
onOpenChange={(open) => {
if (open) return
props.setRowUI("menu", (value) => (value?.id === sessionID() ? undefined : value))
}}
>
<HomeSessionLeadingController
server={props.server}
isOpenTab={props.isOpenTab}
record={props.record}
revealProjectOnHover={!!showProjectName()}
<Menu.Trigger
as="span"
aria-hidden="true"
tabIndex={-1}
class="pointer-events-none absolute size-px"
style={{ left: `${menu()?.x ?? 0}px`, top: `${menu()?.y ?? 0}px` }}
/>
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
<Show when={showProjectName()}>
<HomeSessionProjectName name={props.record.projectName} />
</Show>
</button>
<Menu.Portal>
<Menu.Content
onInteractOutside={() => {
menuInteractedOutside = true
}}
onCloseAutoFocus={(event) => {
// The trigger is an invisible positioning span, so Kobalte's
// default close focus restore has no useful target. Skip the
// row focus when the rename editor owns focus or the user
// dismissed the menu by interacting elsewhere.
event.preventDefault()
const outside = menuInteractedOutside
menuInteractedOutside = false
if (outside || editor()) return
requestAnimationFrame(() => rowButton()?.focus())
}}
>
<Menu.Item onSelect={openEditor}>{props.language.t("common.rename")}</Menu.Item>
<Menu.Item onSelect={() => void props.onExportSession(props.server, props.record.session)}>
{props.language.t("common.export")}...
</Menu.Item>
<Menu.Separator />
<Menu.Item onSelect={() => props.onDeleteSession(props.server, props.record.session)}>
{props.language.t("common.delete")}...
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
<div
class={`
@ -481,6 +722,7 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
return (
<span
data-component="home-session-title"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
classList={{
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,

View file

@ -0,0 +1,15 @@
import { describe, expect, test } from "bun:test"
import { removedSessionIDs } from "./session-domain"
describe("removedSessionIDs", () => {
test("includes all descendants without unrelated sessions", () => {
const sessions = [
{ id: "root" },
{ id: "child", parentID: "root" },
{ id: "grandchild", parentID: "child" },
{ id: "other" },
]
expect([...removedSessionIDs(sessions, "root")]).toEqual(["root", "child", "grandchild"])
})
})

View file

@ -17,3 +17,19 @@ export function selectVisibleSessionUserMessages(messages: SessionMessageUser[],
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export function removedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}

View file

@ -31,19 +31,3 @@ export function timelineChildTitle(input: {
if (input.taskDescription) return input.taskDescription
return input.title?.replace(/\s+\(@[^)]+ subagent\)$/, "") || input.fallback
}
export function timelineRemovedSessionIDs(sessions: readonly { id: string; parentID?: string }[], sessionID: string) {
const removed = new Set([sessionID])
const byParent = Map.groupBy(
sessions.filter((session) => session.parentID),
(session) => session.parentID!,
)
const visit = (id: string) =>
byParent.get(id)?.forEach((child) => {
if (removed.has(child.id)) return
removed.add(child.id)
visit(child.id)
})
visit(sessionID)
return removed
}

View file

@ -11,17 +11,13 @@ import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useTabs } from "@/shell/tabs/tabs"
import type { SessionModel } from "@/session/model"
import { removedSessionIDs } from "@/session/session-domain"
import { useServerSDK } from "@/runtime/server/client"
import { sessionHref } from "@/shell/routes/session"
import { sessionTitle } from "@/session/title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { showToast } from "@/shell/notifications/toast"
import {
applyTimelineMessageHandoff,
timelineChildTitle,
timelineRemovedSessionIDs,
visibleTimelineMessages,
} from "./controller-projection"
import { applyTimelineMessageHandoff, timelineChildTitle, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
@ -171,7 +167,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
return false
})
if (!success) return false
const removed = timelineRemovedSessionIDs(data.session.list(), id)
const removed = removedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true